From 9ce61b45869df28b6da6eaee012c9f7d1dd7f3e2 Mon Sep 17 00:00:00 2001 From: Erick Guan Date: Wed, 15 Jun 2016 19:49:57 +0200 Subject: [PATCH] FEATURE: Webhooks. --- .../admin-web-hook-event-chooser.js.es6 | 42 ++++++ .../components/admin-web-hook-event.js.es6 | 78 +++++++++++ .../components/admin-web-hook-status.js.es6 | 28 ++++ .../admin-web-hooks-show-events.js.es6 | 14 ++ .../controllers/admin-web-hooks-show.js.es6 | 98 +++++++++++++ .../admin/controllers/admin-web-hooks.js.es6 | 19 +++ .../javascripts/admin/models/web-hook.js.es6 | 85 +++++++++++ .../admin/routes/admin-route-map.js.es6 | 4 + .../routes/admin-web-hooks-show-events.js.es6 | 13 ++ .../admin/routes/admin-web-hooks-show.js.es6 | 26 ++++ .../admin/routes/admin-web-hooks.js.es6 | 15 ++ .../javascripts/admin/templates/admin.hbs | 1 + .../admin-web-hook-event-chooser.hbs | 3 + .../components/admin-web-hook-event.hbs | 19 +++ .../site-settings/category-list.hbs | 2 +- .../admin/templates/web-hooks-show-events.hbs | 26 ++++ .../admin/templates/web-hooks-show.hbs | 86 ++++++++++++ .../javascripts/admin/templates/web-hooks.hbs | 39 ++++++ .../discourse/adapters/rest.js.es6 | 7 +- ...-group.js.es6 => category-selector.js.es6} | 22 +-- .../components/group-selector.js.es6 | 17 ++- .../discourse/lib/formatter.js.es6 | 17 +++ .../discourse/lib/utilities.js.es6 | 11 +- .../javascripts/discourse/models/group.js.es6 | 2 +- .../javascripts/discourse/models/store.js.es6 | 4 +- ...=> category-selector-autocomplete.raw.hbs} | 0 .../templates/components/category-group.hbs | 1 - .../components/category-selector.hbs | 1 + .../templates/components/group-selector.hbs | 2 +- .../discourse/templates/user/preferences.hbs | 8 +- .../stylesheets/common/admin/admin_base.scss | 107 +++++++++++++- app/assets/stylesheets/desktop/user.scss | 2 +- app/assets/stylesheets/mobile/user.scss | 2 +- app/controllers/admin/web_hooks_controller.rb | 114 +++++++++++++++ app/jobs/regular/emit_web_hook_event.rb | 101 ++++++++++++++ app/models/category.rb | 2 + app/models/group.rb | 2 + app/models/web_hook.rb | 86 ++++++++++++ app/models/web_hook_event.rb | 37 +++++ app/models/web_hook_event_type.rb | 18 +++ .../admin_web_hook_event_serializer.rb | 17 +++ app/serializers/admin_web_hook_serializer.rb | 18 +++ config/locales/client.en.yml | 63 +++++++++ config/locales/server.en.yml | 5 +- config/routes.rb | 6 + db/fixtures/007_web_hook_event_types.rb | 9 ++ ...60905082217_create_web_hook_event_types.rb | 7 + db/migrate/20160905082248_create_web_hooks.rb | 16 +++ .../20160905084502_create_web_hook_events.rb | 15 ++ ...in_table_web_hooks_web_hook_event_types.rb | 9 ++ ...1958_create_join_table_web_hooks_groups.rb | 6 + ..._create_join_table_web_hooks_categories.rb | 6 + spec/fabricators/web_hook_fabricator.rb | 30 ++++ spec/jobs/emit_web_hook_event_spec.rb | 91 ++++++++++++ spec/models/web_hook_event_spec.rb | 16 +++ spec/models/web_hook_event_type_spec.rb | 5 + spec/models/web_hook_spec.rb | 132 ++++++++++++++++++ test/javascripts/lib/utilities-test.js.es6 | 8 ++ 58 files changed, 1582 insertions(+), 38 deletions(-) create mode 100644 app/assets/javascripts/admin/components/admin-web-hook-event-chooser.js.es6 create mode 100644 app/assets/javascripts/admin/components/admin-web-hook-event.js.es6 create mode 100644 app/assets/javascripts/admin/components/admin-web-hook-status.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/admin-web-hooks-show-events.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/admin-web-hooks-show.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/admin-web-hooks.js.es6 create mode 100644 app/assets/javascripts/admin/models/web-hook.js.es6 create mode 100644 app/assets/javascripts/admin/routes/admin-web-hooks-show-events.js.es6 create mode 100644 app/assets/javascripts/admin/routes/admin-web-hooks-show.js.es6 create mode 100644 app/assets/javascripts/admin/routes/admin-web-hooks.js.es6 create mode 100644 app/assets/javascripts/admin/templates/components/admin-web-hook-event-chooser.hbs create mode 100644 app/assets/javascripts/admin/templates/components/admin-web-hook-event.hbs create mode 100644 app/assets/javascripts/admin/templates/web-hooks-show-events.hbs create mode 100644 app/assets/javascripts/admin/templates/web-hooks-show.hbs create mode 100644 app/assets/javascripts/admin/templates/web-hooks.hbs rename app/assets/javascripts/discourse/components/{category-group.js.es6 => category-selector.js.es6} (61%) rename app/assets/javascripts/discourse/templates/{category-group-autocomplete.raw.hbs => category-selector-autocomplete.raw.hbs} (100%) delete mode 100644 app/assets/javascripts/discourse/templates/components/category-group.hbs create mode 100644 app/assets/javascripts/discourse/templates/components/category-selector.hbs create mode 100644 app/controllers/admin/web_hooks_controller.rb create mode 100644 app/jobs/regular/emit_web_hook_event.rb create mode 100644 app/models/web_hook.rb create mode 100644 app/models/web_hook_event.rb create mode 100644 app/models/web_hook_event_type.rb create mode 100644 app/serializers/admin_web_hook_event_serializer.rb create mode 100644 app/serializers/admin_web_hook_serializer.rb create mode 100644 db/fixtures/007_web_hook_event_types.rb create mode 100644 db/migrate/20160905082217_create_web_hook_event_types.rb create mode 100644 db/migrate/20160905082248_create_web_hooks.rb create mode 100644 db/migrate/20160905084502_create_web_hook_events.rb create mode 100644 db/migrate/20160905085445_create_join_table_web_hooks_web_hook_event_types.rb create mode 100644 db/migrate/20160905091958_create_join_table_web_hooks_groups.rb create mode 100644 db/migrate/20160905092148_create_join_table_web_hooks_categories.rb create mode 100644 spec/fabricators/web_hook_fabricator.rb create mode 100644 spec/jobs/emit_web_hook_event_spec.rb create mode 100644 spec/models/web_hook_event_spec.rb create mode 100644 spec/models/web_hook_event_type_spec.rb create mode 100644 spec/models/web_hook_spec.rb diff --git a/app/assets/javascripts/admin/components/admin-web-hook-event-chooser.js.es6 b/app/assets/javascripts/admin/components/admin-web-hook-event-chooser.js.es6 new file mode 100644 index 000000000..976b48399 --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-web-hook-event-chooser.js.es6 @@ -0,0 +1,42 @@ +import computed from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + classNames: ['hook-event'], + typeName: Ember.computed.alias('type.name'), + + @computed('typeName') + name(typeName) { + return I18n.t(`admin.web_hooks.${typeName}_event.name`); + }, + + @computed('typeName') + details(typeName) { + return I18n.t(`admin.web_hooks.${typeName}_event.details`); + }, + + @computed('model.[]', 'typeName') + eventTypeExists(eventTypes, typeName) { + return eventTypes.any(event => event.name === typeName); + }, + + @computed('eventTypeExists') + enabled: { + get(eventTypeExists) { + return eventTypeExists; + }, + set(value, eventTypeExists) { + const type = this.get('type'); + const model = this.get('model'); + // add an association when not exists + if (value !== eventTypeExists) { + if (value) { + model.addObject(type); + } else { + model.removeObjects(model.filter(eventType => eventType.name === type.name)); + } + } + + return value; + } + } +}); diff --git a/app/assets/javascripts/admin/components/admin-web-hook-event.js.es6 b/app/assets/javascripts/admin/components/admin-web-hook-event.js.es6 new file mode 100644 index 000000000..3c27f644b --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-web-hook-event.js.es6 @@ -0,0 +1,78 @@ +import computed from 'ember-addons/ember-computed-decorators'; +import { ajax } from 'discourse/lib/ajax'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import { ensureJSON, plainJSON, prettyJSON } from 'discourse/lib/formatter'; + +export default Ember.Component.extend({ + tagName: 'li', + expandDetails: null, + + @computed('model.status') + statusColorClasses(status) { + if (!status) return ''; + + if (status >= 200 && status <= 299) { + return 'text-successful'; + } else { + return 'text-danger'; + } + }, + + @computed('model.created_at') + createdAt(createdAt) { + return moment(createdAt).format('YYYY-MM-DD HH:mm:ss'); + }, + + @computed('model.duration') + completion(duration) { + const seconds = Math.floor(duration / 10.0) / 100.0; + return I18n.t('admin.web_hooks.events.completion', { seconds }); + }, + + actions: { + redeliver() { + return bootbox.confirm(I18n.t('admin.web_hooks.events.redeliver_confirm'), I18n.t('no_value'), I18n.t('yes_value'), result => { + if (result) { + ajax(`/admin/web_hooks/${this.get('model.web_hook_id')}/events/${this.get('model.id')}/redeliver`, { type: 'POST' }).then(json => { + this.set('model', json.web_hook_event); + }).catch(popupAjaxError); + } + }); + }, + + toggleRequest() { + const expandDetailsKey = 'request'; + + if (this.get('expandDetails') !== expandDetailsKey) { + let headers = _.extend({ + 'Request URL': this.get('model.request_url'), + 'Request method': 'POST' + }, ensureJSON(this.get('model.headers'))); + this.setProperties({ + headers: plainJSON(headers), + body: prettyJSON(this.get('model.payload')), + expandDetails: expandDetailsKey, + bodyLabel: I18n.t('admin.web_hooks.events.payload') + }); + } else { + this.set('expandDetails', null); + } + }, + + toggleResponse() { + const expandDetailsKey = 'response'; + + if (this.get('expandDetails') !== expandDetailsKey) { + this.setProperties({ + headers: plainJSON(this.get('model.response_headers')), + body: this.get('model.response_body'), + expandDetails: expandDetailsKey, + bodyLabel: I18n.t('admin.web_hooks.events.body') + }); + } else { + this.set('expandDetails', null); + } + } + } + +}); diff --git a/app/assets/javascripts/admin/components/admin-web-hook-status.js.es6 b/app/assets/javascripts/admin/components/admin-web-hook-status.js.es6 new file mode 100644 index 000000000..ee2b38c62 --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-web-hook-status.js.es6 @@ -0,0 +1,28 @@ +import computed from 'ember-addons/ember-computed-decorators'; +import StringBuffer from 'discourse/mixins/string-buffer'; +import { iconHTML } from 'discourse/helpers/fa-icon'; + +export default Ember.Component.extend(StringBuffer, { + classes: ["text-muted", "text-danger", "text-successful"], + icons: ["circle-o", "times-circle", "circle"], + + @computed('deliveryStatuses', 'model.last_delivery_status') + status(deliveryStatuses, lastDeliveryStatus) { + return deliveryStatuses.find(s => s.id === lastDeliveryStatus); + }, + + @computed('status.id', 'icons') + icon(statusId, icons) { + return icons[statusId - 1]; + }, + + @computed('status.id', 'classes') + class(statusId, classes) { + return classes[statusId - 1]; + }, + + renderString(buffer) { + buffer.push(iconHTML(this.get('icon'), { class: this.get('class') })); + buffer.push(I18n.t(`admin.web_hooks.delivery_status.${this.get('status.name')}`)); + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-web-hooks-show-events.js.es6 b/app/assets/javascripts/admin/controllers/admin-web-hooks-show-events.js.es6 new file mode 100644 index 000000000..9b4cc91f5 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-web-hooks-show-events.js.es6 @@ -0,0 +1,14 @@ +import { ajax } from 'discourse/lib/ajax'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +export default Ember.Controller.extend({ + actions: { + loadMore() { + this.get('model').loadMore(); + }, + + ping() { + ajax(`/admin/web_hooks/${this.get('model.extras.web_hook_id')}/ping`, {type: 'POST'}).catch(popupAjaxError); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-web-hooks-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-web-hooks-show.js.es6 new file mode 100644 index 000000000..7cb76be26 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-web-hooks-show.js.es6 @@ -0,0 +1,98 @@ +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import { extractDomainFromUrl } from 'discourse/lib/utilities'; +import computed from 'ember-addons/ember-computed-decorators'; +import InputValidation from 'discourse/models/input-validation'; + +export default Ember.Controller.extend({ + needs: ['adminWebHooks'], + eventTypes: Em.computed.alias('controllers.adminWebHooks.eventTypes'), + defaultEventTypes: Em.computed.alias('controllers.adminWebHooks.defaultEventTypes'), + contentTypes: Em.computed.alias('controllers.adminWebHooks.contentTypes'), + + @computed('model.isSaving', 'saved', 'saveButtonDisabled') + savingStatus(isSaving, saved, saveButtonDisabled) { + if (isSaving) { + return I18n.t('saving'); + } else if (!saveButtonDisabled && saved) { + return I18n.t('saved'); + } + // Use side effect of validation to clear saved text + this.set('saved', false); + return ''; + }, + + @computed('model.isNew') + saveButtonText(isNew) { + return isNew ? I18n.t('admin.web_hooks.create') : I18n.t('admin.web_hooks.save'); + }, + + @computed('model.secret') + secretValidation(secret) { + if (!Ember.isEmpty(secret)) { + if (secret.indexOf(' ') !== -1) { + return InputValidation.create({ + failed: true, + reason: I18n.t('admin.web_hooks.secret_invalid') + }); + } + + if (secret.length < 12) { + return InputValidation.create({ + failed: true, + reason: I18n.t('admin.web_hooks.secret_too_short') + }); + } + } + }, + + @computed('model.wildcard_web_hook', 'model.web_hook_event_types.[]') + eventTypeValidation(isWildcard, eventTypes) { + if (!isWildcard && Ember.isEmpty(eventTypes)) { + return InputValidation.create({ + failed: true, + reason: I18n.t('admin.web_hooks.event_type_missing') + }); + } + }, + + @computed('model.isSaving', 'secretValidation', 'eventTypeValidation') + saveButtonDisabled(isSaving, secretValidation, eventTypeValidation) { + return isSaving ? false : secretValidation || eventTypeValidation; + }, + + actions: { + save() { + this.set('saved', false); + const url = extractDomainFromUrl(this.get('model.payload_url')); + const model = this.get('model'); + const saveWebHook = () => { + return model.save().then(() => { + this.set('saved', true); + this.get('controllers.adminWebHooks').get('model').addObject(model); + }).catch(popupAjaxError); + }; + + if (url === 'localhost' || url.match(/192\.168\.\d+\.\d+/) || url.match(/127\.\d+\.\d+\.\d+/) || url === Discourse.BaseUrl) { + return bootbox.confirm(I18n.t('admin.web_hooks.warn_local_payload_url'), I18n.t('no_value'), I18n.t('yes_value'), result => { + if (result) { + return saveWebHook(); + } + }); + } + + return saveWebHook(); + }, + + destroy() { + return bootbox.confirm(I18n.t('admin.web_hooks.delete_confirm'), I18n.t('no_value'), I18n.t('yes_value'), result => { + if (result) { + const model = this.get('model'); + model.destroyRecord().then(() => { + this.get('controllers.adminWebHooks').get('model').removeObject(model); + this.transitionToRoute('adminWebHooks'); + }).catch(popupAjaxError); + } + }); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-web-hooks.js.es6 b/app/assets/javascripts/admin/controllers/admin-web-hooks.js.es6 new file mode 100644 index 000000000..50b8b370f --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-web-hooks.js.es6 @@ -0,0 +1,19 @@ +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +export default Ember.Controller.extend({ + actions: { + destroy(webhook) { + return bootbox.confirm(I18n.t('admin.web_hooks.delete_confirm'), I18n.t('no_value'), I18n.t('yes_value'), result => { + if (result) { + webhook.destroyRecord().then(() => { + this.get('model').removeObject(webhook); + }).catch(popupAjaxError); + } + }); + }, + + loadMore() { + this.get('model').loadMore(); + } + } +}); diff --git a/app/assets/javascripts/admin/models/web-hook.js.es6 b/app/assets/javascripts/admin/models/web-hook.js.es6 new file mode 100644 index 000000000..e688a4dd0 --- /dev/null +++ b/app/assets/javascripts/admin/models/web-hook.js.es6 @@ -0,0 +1,85 @@ +import RestModel from 'discourse/models/rest'; +import Category from 'discourse/models/category'; +import Group from 'discourse/models/group'; +import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; + +export default RestModel.extend({ + content_type: 1, // json + last_delivery_status: 1, // inactive + wildcard_web_hook: false, + verify_certificate: true, + active: false, + web_hook_event_types: null, + categoriesFilter: null, + groupsFilterInName: null, + + @computed('wildcard_web_hook') + webHookType: { + get(wildcard) { + return wildcard ? 'wildcard' : 'individual'; + }, + set(value) { + this.set('wildcard_web_hook', value === 'wildcard'); + } + }, + + @observes('category_ids') + updateCategoriesFilter() { + this.set('categoriesFilter', Category.findByIds(this.get('category_ids'))); + }, + + @observes('group_ids') + updateGroupsFilter() { + const groupIds = this.get('group_ids'); + this.set('groupsFilterInName', Discourse.Site.currentProp('groups').reduce((groupNames, g) => { + if (groupIds.includes(g.id)) { groupNames.push(g.name); } + return groupNames; + }, [])); + }, + + groupFinder(term) { + return Group.findAll({search: term, ignore_automatic: false}); + }, + + @computed('wildcard_web_hook', 'web_hook_event_types.[]') + description(isWildcardWebHook, types) { + let desc = ''; + + types.forEach(type => { + const name = `${type.name.toLowerCase()}_event`; + desc += (desc !== '' ? `, ${name}` : name); + }); + + return (isWildcardWebHook ? '*' : desc); + }, + + createProperties() { + const types = this.get('web_hook_event_types'); + const categories = this.get('categoriesFilter'); + // Hack as {{group-selector}} accepts a comma-separated string as data source, but + // we use an array to populate the datasource above. + const groupsFilter = this.get('groupsFilterInName'); + const groupNames = typeof groupsFilter === 'string' ? groupsFilter.split(',') : groupsFilter; + + return { + payload_url: this.get('payload_url'), + content_type: this.get('content_type'), + secret: this.get('secret'), + wildcard_web_hook: this.get('wildcard_web_hook'), + verify_certificate: this.get('verify_certificate'), + active: this.get('active'), + web_hook_event_type_ids: Ember.isEmpty(types) ? [null] : types.map(type => type.id), + category_ids: Ember.isEmpty(categories) ? [null] : categories.map(c => c.id), + group_ids: Ember.isEmpty(groupNames) || Ember.isEmpty(groupNames[0]) ? [null] : Discourse.Site.currentProp('groups') + .reduce((groupIds, g) => { + if (groupNames.includes(g.name)) { groupIds.push(g.id); } + return groupIds; + }, []) + }; + }, + + updateProperties() { + return this.createProperties(); + } +}); + diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index 8c1f988af..df27e2c07 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -36,6 +36,10 @@ export default { }); }); this.route('api'); + this.resource('adminWebHooks', { path: '/web_hooks' }, function() { + this.route('show', { path: '/:web_hook_id' }); + this.route('showEvents', { path: '/:web_hook_id/events' }); + }); this.resource('admin.backups', { path: '/backups' }, function() { this.route('logs'); diff --git a/app/assets/javascripts/admin/routes/admin-web-hooks-show-events.js.es6 b/app/assets/javascripts/admin/routes/admin-web-hooks-show-events.js.es6 new file mode 100644 index 000000000..ea4e33ac6 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-web-hooks-show-events.js.es6 @@ -0,0 +1,13 @@ +export default Discourse.Route.extend({ + model(params) { + return this.store.findAll('web-hook-event', Ember.get(params, 'web_hook_id')); + }, + + setupController(controller, model) { + controller.set('model', model); + }, + + renderTemplate() { + this.render('admin/templates/web-hooks-show-events', { into: 'admin' }); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-web-hooks-show.js.es6 b/app/assets/javascripts/admin/routes/admin-web-hooks-show.js.es6 new file mode 100644 index 000000000..533d7c09f --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-web-hooks-show.js.es6 @@ -0,0 +1,26 @@ +export default Discourse.Route.extend({ + serialize(model) { + return { web_hook_id: model.get('id') || 'new' }; + }, + + model(params) { + if (params.web_hook_id === 'new') { + return this.store.createRecord('web-hook'); + } + return this.store.find('web-hook', Ember.get(params, 'web_hook_id')); + }, + + setupController(controller, model) { + if (model.get('isNew') || Ember.isEmpty(model.get('web_hook_event_types'))) { + model.set('web_hook_event_types', controller.get('defaultEventTypes')); + } + + model.set('category_ids', model.get('category_ids')); + model.set('group_ids', model.get('group_ids')); + controller.setProperties({ model, saved: false }); + }, + + renderTemplate() { + this.render('admin/templates/web-hooks-show', { into: 'admin' }); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-web-hooks.js.es6 b/app/assets/javascripts/admin/routes/admin-web-hooks.js.es6 new file mode 100644 index 000000000..69d7b4bd2 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-web-hooks.js.es6 @@ -0,0 +1,15 @@ +export default Ember.Route.extend({ + model() { + return this.store.findAll('web-hook'); + }, + + setupController(controller, model) { + controller.setProperties({ + model, + eventTypes: model.extras.event_types, + defaultEventTypes: model.extras.default_event_types, + contentTypes: model.extras.content_types, + deliveryStatuses: model.extras.delivery_statuses + }); + } +}); diff --git a/app/assets/javascripts/admin/templates/admin.hbs b/app/assets/javascripts/admin/templates/admin.hbs index 13afd577f..05076b740 100644 --- a/app/assets/javascripts/admin/templates/admin.hbs +++ b/app/assets/javascripts/admin/templates/admin.hbs @@ -20,6 +20,7 @@ {{#if currentUser.admin}} {{nav-item route='adminCustomize' label='admin.customize.title'}} {{nav-item route='admin.api' label='admin.api.title'}} + {{nav-item route='adminWebHooks' label='admin.web_hooks.title'}} {{nav-item route='admin.backups' label='admin.backups.title'}} {{/if}} {{nav-item route='adminPlugins' label='admin.plugins.title'}} diff --git a/app/assets/javascripts/admin/templates/components/admin-web-hook-event-chooser.hbs b/app/assets/javascripts/admin/templates/components/admin-web-hook-event-chooser.hbs new file mode 100644 index 000000000..0ecb553d3 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/admin-web-hook-event-chooser.hbs @@ -0,0 +1,3 @@ +{{input id=typeName type="checkbox" name="event-choice" checked=enabled}} + +

{{details}}

diff --git a/app/assets/javascripts/admin/templates/components/admin-web-hook-event.hbs b/app/assets/javascripts/admin/templates/components/admin-web-hook-event.hbs new file mode 100644 index 000000000..be4252c42 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/admin-web-hook-event.hbs @@ -0,0 +1,19 @@ +
+ {{model.status}} +
+
{{model.id}}
+
{{createdAt}}
+
{{completion}}
+
+ {{d-button icon='ellipsis-v' action='toggleRequest' label='admin.web_hooks.events.request'}} + {{d-button icon='ellipsis-v' action='toggleResponse' label='admin.web_hooks.events.response'}} + {{d-button icon='refresh' action='redeliver' label='admin.web_hooks.events.redeliver'}} +
+{{#if expandDetails}} +
+

{{i18n 'admin.web_hooks.events.headers'}}

+
{{headers}}
+

{{bodyLabel}}

+
{{body}}
+
+{{/if}} diff --git a/app/assets/javascripts/admin/templates/components/site-settings/category-list.hbs b/app/assets/javascripts/admin/templates/components/site-settings/category-list.hbs index 621f3fa70..8c0b4eda1 100644 --- a/app/assets/javascripts/admin/templates/components/site-settings/category-list.hbs +++ b/app/assets/javascripts/admin/templates/components/site-settings/category-list.hbs @@ -1,3 +1,3 @@ -{{category-group categories=selectedCategories blacklist=selectedCategories}} +{{category-selector categories=selectedCategories blacklist=selectedCategories}}
{{{unbound setting.description}}}
{{setting-validation-message message=validationMessage}} diff --git a/app/assets/javascripts/admin/templates/web-hooks-show-events.hbs b/app/assets/javascripts/admin/templates/web-hooks-show-events.hbs new file mode 100644 index 000000000..7cfb1bd79 --- /dev/null +++ b/app/assets/javascripts/admin/templates/web-hooks-show-events.hbs @@ -0,0 +1,26 @@ +
+ {{#link-to 'adminWebHooks' tagName='button' classNames='btn'}} + {{fa-icon 'list'}} {{i18n 'admin.web_hooks.events.go_list'}} + {{/link-to}} + {{d-button icon="send" label="admin.web_hooks.events.ping" action="ping"}} + {{#link-to 'adminWebHooks.show' model.extras.web_hook_id tagName='button' classNames='btn'}} + {{fa-icon 'edit'}} {{i18n 'admin.web_hooks.events.go_details'}} + {{/link-to}} +
+ +
+{{#if model}} + {{#load-more selector=".web-hook-events li" action="loadMore"}} +
+
    + {{#each model as |webHookEvent|}} + {{admin-web-hook-event model=webHookEvent}} + {{/each}} +
+
+ {{conditional-loading-spinner condition=model.loadingMore}} + {{/load-more}} +{{else}} +

{{i18n 'admin.web_hooks.events.none'}}

+{{/if}} +
diff --git a/app/assets/javascripts/admin/templates/web-hooks-show.hbs b/app/assets/javascripts/admin/templates/web-hooks-show.hbs new file mode 100644 index 000000000..b0e0c0c0b --- /dev/null +++ b/app/assets/javascripts/admin/templates/web-hooks-show.hbs @@ -0,0 +1,86 @@ +{{#link-to 'adminWebHooks' class="go-back"}} + {{fa-icon 'arrow-left'}} + {{i18n 'admin.web_hooks.go_back'}} +{{/link-to}} + +
+

{{i18n 'admin.web_hooks.detailed_instruction'}}

+
+
+ + {{text-field name="payload-url" value=model.payload_url placeholderKey="admin.web_hooks.payload_url_placeholder"}} + {{input-tip validation=urlValidation}} +
+ +
+ + {{combo-box content=contentTypes + name="content-type" + nameProperty="name" + valueAttribute="id" + value=model.content_type}} +
+ +
+ + {{text-field name="secret" value=model.secret placeholderKey="admin.web_hooks.secret_placeholder"}} + {{input-tip validation=secretValidation}} +
+ +
+ +
+ {{radio-button class="subscription-choice" name="subscription-choice" value="individual" selection=model.webHookType}} + {{i18n 'admin.web_hooks.individual_event'}} + {{input-tip validation=eventTypeValidation}} +
+ {{#unless model.wildcard_web_hook}} +
+ {{#each eventTypes as |type|}} + {{admin-web-hook-event-chooser type=type model=model.web_hook_event_types}} + {{/each}} +
+ {{/unless}} +
+ {{radio-button class="subscription-choice" name="subscription-choice" value="wildcard" selection=model.webHookType}} + {{i18n 'admin.web_hooks.wildcard_event'}} +
+
+ +
+
+ + {{category-selector categories=model.categoriesFilter blacklist=model.categoriesFilter}} +
{{i18n 'admin.web_hooks.categories_filter_instructions'}}
+
+
+ + {{group-selector groupNames=model.groupsFilterInName groupFinder=model.groupFinder}} +
{{i18n 'admin.web_hooks.groups_filter_instructions'}}
+
+
+ +
+ {{input type="checkbox" name="verify_certificate" checked=model.verify_certificate}} {{i18n 'admin.web_hooks.verify_certificate'}} +
+
+
+ {{input type="checkbox" name="active" checked=model.active}} {{i18n 'admin.web_hooks.active'}} +
+ {{#if model.active}} +
{{i18n 'admin.web_hooks.active_notice'}}
+ {{/if}} +
+
+ +
+ + {{#unless model.isNew}} + {{d-button class="btn-danger" label="admin.web_hooks.destroy" action="destroy"}} + {{#link-to 'adminWebHooks.showEvents' model.id class="btn"}} + {{i18n 'admin.web_hooks.events.go_events'}} + {{/link-to}} + {{/unless}} + {{savingStatus}} +
+
diff --git a/app/assets/javascripts/admin/templates/web-hooks.hbs b/app/assets/javascripts/admin/templates/web-hooks.hbs new file mode 100644 index 000000000..082cf170b --- /dev/null +++ b/app/assets/javascripts/admin/templates/web-hooks.hbs @@ -0,0 +1,39 @@ +
+ {{#link-to 'adminWebHooks.show' 'new' tagName='button' classNames='btn'}} + {{fa-icon 'plus'}} {{i18n 'admin.web_hooks.new'}} + {{/link-to}} +
+
+
+{{#if model}} +

{{i18n 'admin.web_hooks.instruction'}}

+ {{#load-more selector=".web-hooks tr" action="loadMore"}} + + + + + + + + + + + {{#each model as |webHook|}} + + + + + + + {{/each}} + +
{{i18n 'admin.web_hooks.delivery_status.title'}}{{i18n 'admin.web_hooks.payload_url'}}{{i18n 'admin.web_hooks.description'}}{{i18n 'admin.web_hooks.controls'}}
{{#link-to 'adminWebHooks.showEvents' webHook.id}}{{admin-web-hook-status deliveryStatuses=deliveryStatuses model=webHook}}{{/link-to}}{{#link-to 'adminWebHooks.show' webHook}}{{webHook.payload_url}}{{/link-to}}{{webHook.description}} + {{#link-to 'adminWebHooks.show' webHook tagName='button' classNames='btn btn-default no-text'}}{{fa-icon 'edit'}}{{/link-to}} + {{d-button class="destroy btn-danger" action='destroy' actionParam=webHook icon="remove"}} +
+ {{conditional-loading-spinner condition=model.loadingMore}} + {{/load-more}} +{{else}} +

{{i18n 'admin.web_hooks.none'}}

+{{/if}} +
diff --git a/app/assets/javascripts/discourse/adapters/rest.js.es6 b/app/assets/javascripts/discourse/adapters/rest.js.es6 index 75366ca80..72d05fb7d 100644 --- a/app/assets/javascripts/discourse/adapters/rest.js.es6 +++ b/app/assets/javascripts/discourse/adapters/rest.js.es6 @@ -1,8 +1,7 @@ import { ajax } from 'discourse/lib/ajax'; import { hashString } from 'discourse/lib/hash'; -const ADMIN_MODELS = ['plugin', 'site-customization', 'embeddable-host']; - +const ADMIN_MODELS = ['plugin', 'site-customization', 'embeddable-host', 'web-hook', 'web-hook-event']; export function Result(payload, responseJson) { this.payload = payload; @@ -57,8 +56,8 @@ export default Ember.Object.extend({ return this.appendQueryParams(path, findArgs); }, - findAll(store, type) { - return ajax(this.pathFor(store, type)).catch(rethrow); + findAll(store, type, findArgs) { + return ajax(this.pathFor(store, type, findArgs)).catch(rethrow); }, diff --git a/app/assets/javascripts/discourse/components/category-group.js.es6 b/app/assets/javascripts/discourse/components/category-selector.js.es6 similarity index 61% rename from app/assets/javascripts/discourse/components/category-group.js.es6 rename to app/assets/javascripts/discourse/components/category-selector.js.es6 index 4daca78a6..eb0604062 100644 --- a/app/assets/javascripts/discourse/components/category-group.js.es6 +++ b/app/assets/javascripts/discourse/components/category-selector.js.es6 @@ -1,37 +1,37 @@ import { categoryBadgeHTML } from 'discourse/helpers/category-link'; import Category from 'discourse/models/category'; +import { on } from 'ember-addons/ember-computed-decorators'; export default Ember.Component.extend({ - - _initializeAutocomplete: function() { + @on('didInsertElement') + _initializeAutocomplete() { const self = this, - template = this.container.lookup('template:category-group-autocomplete.raw'), + template = this.container.lookup('template:category-selector-autocomplete.raw'), regexp = new RegExp(`href=['\"]${Discourse.getURL('/c/')}([^'\"]+)`); this.$('input').autocomplete({ items: this.get('categories'), single: false, allowAny: false, - dataSource(term){ - return Category.list().filter(function(category){ - const regex = new RegExp(term, "i"); - return category.get("name").match(regex) && + dataSource(term) { + return Category.list().filter(category => { + const regex = new RegExp(term, 'i'); + return category.get('name').match(regex) && !_.contains(self.get('blacklist') || [], category) && !_.contains(self.get('categories'), category) ; }); }, onChangeItems(items) { - const categories = _.map(items, function(link) { + const categories = _.map(items, link => { const slug = link.match(regexp)[1]; return Category.findSingleBySlug(slug); }); - Em.run.next(() => self.set("categories", categories)); + Em.run.next(() => self.set('categories', categories)); }, template, transformComplete(category) { return categoryBadgeHTML(category, {allowUncategorized: true}); } }); - }.on('didInsertElement') - + } }); diff --git a/app/assets/javascripts/discourse/components/group-selector.js.es6 b/app/assets/javascripts/discourse/components/group-selector.js.es6 index dc23f0f1e..6b278e7a8 100644 --- a/app/assets/javascripts/discourse/components/group-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/group-selector.js.es6 @@ -1,15 +1,20 @@ -export default Ember.Component.extend({ - placeholder: function(){ - return I18n.t(this.get("placeholderKey")); - }.property("placeholderKey"), +import { on, default as computed } from 'ember-addons/ember-computed-decorators'; - _initializeAutocomplete: function() { +export default Ember.Component.extend({ + @computed('placeholderKey') + placeholder(placeholderKey) { + return placeholderKey ? I18n.t(placeholderKey) : ''; + }, + + @on('didInsertElement') + _initializeAutocomplete() { var self = this; var selectedGroups; var template = this.container.lookup('template:group-selector-autocomplete.raw'); self.$('input').autocomplete({ allowAny: false, + items: this.get('groupNames'), onChangeItems: function(items){ selectedGroups = items; self.set("groupNames", items.join(",")); @@ -31,5 +36,5 @@ export default Ember.Component.extend({ }, template: template }); - }.on('didInsertElement') + } }); diff --git a/app/assets/javascripts/discourse/lib/formatter.js.es6 b/app/assets/javascripts/discourse/lib/formatter.js.es6 index 6e4b7c2a9..a15c175de 100644 --- a/app/assets/javascripts/discourse/lib/formatter.js.es6 +++ b/app/assets/javascripts/discourse/lib/formatter.js.es6 @@ -265,3 +265,20 @@ export function number(val) { } return val.toString(); } + +export function ensureJSON(json) { + return typeof json === 'string' ? JSON.parse(json) : json; +} + +export function plainJSON(val) { + let json = ensureJSON(val); + let headers = ''; + Object.keys(json).forEach(k => { + headers += `${k}: ${json[k]}\n`; + }); + return headers; +} + +export function prettyJSON(json) { + return JSON.stringify(ensureJSON(json), null, 2); +} diff --git a/app/assets/javascripts/discourse/lib/utilities.js.es6 b/app/assets/javascripts/discourse/lib/utilities.js.es6 index 6dc7b93ec..32f470163 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js.es6 +++ b/app/assets/javascripts/discourse/lib/utilities.js.es6 @@ -71,10 +71,19 @@ export function userUrl(username) { export function emailValid(email) { // see: http://stackoverflow.com/questions/46155/validate-email-address-in-javascript - var re = /^[a-zA-Z0-9!#$%&'*+\/=?\^_`{|}~\-]+(?:\.[a-zA-Z0-9!#$%&'\*+\/=?\^_`{|}~\-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?$/; + const re = /^[a-zA-Z0-9!#$%&'*+\/=?\^_`{|}~\-]+(?:\.[a-zA-Z0-9!#$%&'\*+\/=?\^_`{|}~\-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?$/; return re.test(email); } +export function extractDomainFromUrl(url) { + if (url.indexOf("://") > -1) { + url = url.split('/')[2]; + } else { + url = url.split('/')[0]; + } + return url.split(':')[0]; +} + export function selectedText() { var html = ''; diff --git a/app/assets/javascripts/discourse/models/group.js.es6 b/app/assets/javascripts/discourse/models/group.js.es6 index 5da7628d9..9980acc81 100644 --- a/app/assets/javascripts/discourse/models/group.js.es6 +++ b/app/assets/javascripts/discourse/models/group.js.es6 @@ -156,7 +156,7 @@ const Group = Discourse.Model.extend({ data: { notification_level }, type: "POST" }); - }, + } }); Group.reopenClass({ diff --git a/app/assets/javascripts/discourse/models/store.js.es6 b/app/assets/javascripts/discourse/models/store.js.es6 index 1dd6ee2a1..87a589a5a 100644 --- a/app/assets/javascripts/discourse/models/store.js.es6 +++ b/app/assets/javascripts/discourse/models/store.js.es6 @@ -49,9 +49,9 @@ export default Ember.Object.extend({ this._plurals[thing] = plural; }, - findAll(type) { + findAll(type, findArgs) { const self = this; - return this.adapterFor(type).findAll(this, type).then(function(result) { + return this.adapterFor(type).findAll(this, type, findArgs).then(function(result) { return self._resultSet(type, result); }); }, diff --git a/app/assets/javascripts/discourse/templates/category-group-autocomplete.raw.hbs b/app/assets/javascripts/discourse/templates/category-selector-autocomplete.raw.hbs similarity index 100% rename from app/assets/javascripts/discourse/templates/category-group-autocomplete.raw.hbs rename to app/assets/javascripts/discourse/templates/category-selector-autocomplete.raw.hbs diff --git a/app/assets/javascripts/discourse/templates/components/category-group.hbs b/app/assets/javascripts/discourse/templates/components/category-group.hbs deleted file mode 100644 index e3c47acdf..000000000 --- a/app/assets/javascripts/discourse/templates/components/category-group.hbs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/assets/javascripts/discourse/templates/components/category-selector.hbs b/app/assets/javascripts/discourse/templates/components/category-selector.hbs new file mode 100644 index 000000000..a265ef5f1 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/category-selector.hbs @@ -0,0 +1 @@ + diff --git a/app/assets/javascripts/discourse/templates/components/group-selector.hbs b/app/assets/javascripts/discourse/templates/components/group-selector.hbs index 2ae1e71d1..af73c13cb 100644 --- a/app/assets/javascripts/discourse/templates/components/group-selector.hbs +++ b/app/assets/javascripts/discourse/templates/components/group-selector.hbs @@ -1 +1 @@ - + diff --git a/app/assets/javascripts/discourse/templates/user/preferences.hbs b/app/assets/javascripts/discourse/templates/user/preferences.hbs index 928fbf900..96c061cfb 100644 --- a/app/assets/javascripts/discourse/templates/user/preferences.hbs +++ b/app/assets/javascripts/discourse/templates/user/preferences.hbs @@ -250,7 +250,7 @@
- {{category-group categories=model.watchedCategories blacklist=selectedCategories}} + {{category-selector categories=model.watchedCategories blacklist=selectedCategories}}
{{i18n 'user.watched_categories_instructions'}}
@@ -259,17 +259,17 @@
- {{category-group categories=model.trackedCategories blacklist=selectedCategories}} + {{category-selector categories=model.trackedCategories blacklist=selectedCategories}}
{{i18n 'user.tracked_categories_instructions'}}
- {{category-group categories=model.watchedFirstPostCategories}} + {{category-selector categories=model.watchedFirstPostCategories}}
{{i18n 'user.watched_first_post_categories_instructions'}}
- {{category-group categories=model.mutedCategories blacklist=selectedCategories}} + {{category-selector categories=model.mutedCategories blacklist=selectedCategories}}
{{i18n 'user.muted_categories_instructions'}}
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index 8688fdfb5..d8a734cf7 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -297,7 +297,7 @@ td.flaggers td { height: 150px; } -.groups, .badges { +.groups, .badges, .web-hook-container { .form-horizontal { label { font-weight: bold; @@ -311,12 +311,24 @@ td.flaggers td { width: 350px; } - input[type="checkbox"] { + input[type="checkbox"], input[type="radio"] { width: 20px; } } } +.text-successful { + color: $success; +} + +.text-danger { + color: $danger; +} + +.text-muted { + color: lighten($primary, 40); +} + .admin-nav { width: 18.018%; position: relative; @@ -394,7 +406,7 @@ td.flaggers td { float: left; width: 53%; padding-right: 20px; - .category-group { + .category-selector { width: 95%; } @media (max-width: $mobile-breakpoint) { @@ -1248,6 +1260,21 @@ table.api-keys { } +.hook-event { + display: inline-block; + width: 40%; + + margin-left: 20px; + + label { + display: inline-block; + } + + p { + margin: 0 0 5px 25px; + } +} + .email-template { input { width: 100%; @@ -1805,6 +1832,80 @@ table#user-badges { } } +// Webhook + +.web-hook-container { + > p { + padding-bottom: 10px; + border-bottom: darken($secondary, 10%) 1px solid; + } + .filters { + margin: 5px 0; + padding-bottom: 5px; + border-bottom: darken($secondary, 5%) 1px solid; + } + .instructions { + margin-top: 5px; + } + .subscription-choice { + margin-bottom: 10px; + } +} + +.web-hook-direction { + button { + margin-right: 10px; + } +} + +.web-hook-events { + margin-top: 15px; + + li { + padding: 2px 0; + } + + .col { + display: inline-block; + padding-top: 6px; + vertical-align: top; + overflow-y: auto; + overflow-x: hidden; + } + + .col.first { + width: 30px; + } + + .col.event-id { + width: 300px; + } + + .col.timestamp { + width: 150px; + } + + .col.completion { + width: 220px; + } + + .col.actions { + width: 305px; + padding-top: 0; + a { + text-decoration: underline; + } + } + + .details { + display: block; + margin-top: 10px; + } + label { + font-size: 1.05em; + } +} + // Mobile specific styles // Mobile view text-inputs need some padding .mobile-view .admin-contents { diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index 6a1d03446..4c57444da 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -20,7 +20,7 @@ } .user-preferences { - input.category-group, input.user-selector { + input.category-selector, input.user-selector { width: 530px; } diff --git a/app/assets/stylesheets/mobile/user.scss b/app/assets/stylesheets/mobile/user.scss index 6a559260b..a82419bd9 100644 --- a/app/assets/stylesheets/mobile/user.scss +++ b/app/assets/stylesheets/mobile/user.scss @@ -86,7 +86,7 @@ } .user-preferences { - input.category-group { + input.category-selector { } textarea { diff --git a/app/controllers/admin/web_hooks_controller.rb b/app/controllers/admin/web_hooks_controller.rb new file mode 100644 index 000000000..d70048c74 --- /dev/null +++ b/app/controllers/admin/web_hooks_controller.rb @@ -0,0 +1,114 @@ +class Admin::WebHooksController < Admin::AdminController + before_filter :fetch_web_hook, only: %i(show update destroy list_events ping) + + def index + limit = 50 + offset = params[:offset].to_i + + web_hooks = WebHook.limit(limit) + .offset(offset) + .includes(:web_hook_event_types) + .includes(:categories) + .includes(:groups) + + json = { + web_hooks: serialize_data(web_hooks, AdminWebHookSerializer), + extras: { + event_types: WebHookEventType.all, + default_event_types: WebHook.default_event_types, + content_types: WebHook.content_types.map { |name, id| { id: id, name: name } }, + delivery_statuses: WebHook.last_delivery_statuses.map { |name, id| { id: id, name: name.to_s } }, + }, + total_rows_web_hooks: WebHook.count, + load_more_web_hooks: admin_web_hooks_path(limit: limit, offset: offset + limit, format: :json) + } + + render json: MultiJson.dump(json), status: 200 + end + + def show + render_serialized(@web_hook, AdminWebHookSerializer, root: 'web_hook') + end + + def create + web_hook = WebHook.new(web_hook_params) + + if web_hook.save + render_serialized(web_hook, AdminWebHookSerializer, root: 'web_hook') + else + render_json_error web_hook.errors.full_messages + end + end + + def update + if @web_hook.update_attributes(web_hook_params) + render_serialized(@web_hook, AdminWebHookSerializer, root: 'web_hook') + else + render_json_error @web_hook.errors.full_messages + end + end + + def destroy + @web_hook.destroy! + render json: success_json + end + + def new + end + + def list_events + limit = 50 + offset = params[:offset].to_i + + json = { + web_hook_events: serialize_data(@web_hook.web_hook_events.limit(limit).offset(offset), AdminWebHookEventSerializer), + total_rows_web_hook_events: @web_hook.web_hook_events.count, + load_more_web_hook_events: admin_web_hook_events_path(limit: limit, offset: offset + limit, format: :json), + extras: { + web_hook_id: @web_hook.id + } + } + + render json: MultiJson.dump(json), status: 200 + end + + def redeliver_event + web_hook_event = WebHookEvent.find(params[:event_id]) + + if web_hook_event + web_hook = web_hook_event.web_hook + conn = Excon.new(URI(web_hook.payload_url).to_s, + ssl_verify_peer: web_hook.verify_certificate, + retry_limit: 0) + + now = Time.zone.now + response = conn.post(headers: MultiJson.load(web_hook_event.headers), body: web_hook_event.payload) + web_hook_event.update_attributes!(status: response.status, + response_headers: MultiJson.dump(response.headers), + response_body: response.body, + duration: ((Time.zone.now - now) * 1000).to_i) + render_serialized(web_hook_event, AdminWebHookEventSerializer, root: 'web_hook_event') + else + render json: failed_json + end + end + + def ping + Jobs.enqueue(:emit_web_hook_event, web_hook_id: @web_hook.id, event_type: 'ping') + render json: success_json + end + + private + + def web_hook_params + params.require(:web_hook).permit(:payload_url, :content_type, :secret, + :wildcard_web_hook, :active, :verify_certificate, + web_hook_event_type_ids: [], + group_ids: [], + category_ids: []) + end + + def fetch_web_hook + @web_hook = WebHook.find(params[:id]) + end +end diff --git a/app/jobs/regular/emit_web_hook_event.rb b/app/jobs/regular/emit_web_hook_event.rb new file mode 100644 index 000000000..56a31b6c7 --- /dev/null +++ b/app/jobs/regular/emit_web_hook_event.rb @@ -0,0 +1,101 @@ +require 'excon' + +module Jobs + class EmitWebHookEvent < Jobs::Base + def execute(args) + raise Discourse::InvalidParameters.new(:web_hook_id) unless args[:web_hook_id].present? + raise Discourse::InvalidParameters.new(:event_type) unless args[:event_type].present? + + @web_hook = WebHook.find(args[:web_hook_id]) + + unless args[:event_type] == 'ping' + return unless @web_hook.active? + return if @web_hook.group_ids.present? && (args[:group_id].present? || + !@web_hook.group_ids.include?(args[:group_id])) + return if @web_hook.category_ids.present? && (!args[:category_id].present? || + !@web_hook.category_ids.include?(args[:category_id])) + end + + @opts = args + + web_hook_request + end + + private + + def web_hook_request + uri = URI(@web_hook.payload_url) + conn = Excon.new(uri.to_s, + ssl_verify_peer: @web_hook.verify_certificate, + retry_limit: 0) + + body = build_web_hook_body + web_hook_event = WebHookEvent.create!(web_hook_id: @web_hook.id) + + begin + content_type = case @web_hook.content_type + when WebHook.content_types['application/x-www-form-urlencoded'] + 'application/x-www-form-urlencoded' + else + 'application/json' + end + headers = { + 'Accept' => '*/*', + 'Connection' => 'close', + 'Content-Length' => body.size, + 'Content-Type' => content_type, + 'Host' => uri.host, + 'User-Agent' => "Discourse/" + Discourse::VERSION::STRING, + 'X-Discourse-Event-Id' => web_hook_event.id, + 'X-Discourse-Event-Type' => @opts[:event_type] + } + headers['X-Discourse-Event'] = @opts[:event_name] if @opts[:event_name].present? + + if @web_hook.secret.present? + headers['X-Discourse-Event-Signature'] = "sha256=" + OpenSSL::HMAC.hexdigest("sha256", @web_hook.secret, body) + end + + now = Time.zone.now + response = conn.post(headers: headers, body: body) + rescue + web_hook_event.destroy! + end + + web_hook_event.update_attributes!(headers: MultiJson.dump(headers), + payload: body, + status: response.status, + response_headers: MultiJson.dump(response.headers), + response_body: response.body, + duration: ((Time.zone.now - now) * 1000).to_i) + end + + def build_web_hook_body + body = {} + web_hook_user = Discourse.system_user + guardian = Guardian.new(web_hook_user) + + if @opts[:topic_id] + topic_view = TopicView.new(@opts[:topic_id], web_hook_user) + body[:topic] = TopicViewSerializer.new(topic_view, scope: guardian, root: false).as_json + end + + if @opts[:post_id] + post = Post.find(@opts[:post_id]) + body[:post] = PostSerializer.new(post, scope: guardian, root: false).as_json + end + + if @opts[:user_id] + user = User.find(@opts[:user_id]) + body[:user] = UserSerializer.new(user, scope: guardian, root: false).as_json + end + + body[:ping] = 'OK' if @opts[:event_type] == 'ping' + + raise Discourse::InvalidParameters.new if body.empty? + + MultiJson.dump(body) + end + + end + +end diff --git a/app/models/category.rb b/app/models/category.rb index bc841d62b..f6925a428 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -27,6 +27,8 @@ class Category < ActiveRecord::Base has_many :category_groups, dependent: :destroy has_many :groups, through: :category_groups + has_and_belongs_to_many :web_hooks + validates :user_id, presence: true validates :name, if: Proc.new { |c| c.new_record? || c.name_changed? }, presence: true, diff --git a/app/models/group.rb b/app/models/group.rb index 133dfdf08..772f689f0 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -10,6 +10,8 @@ class Group < ActiveRecord::Base has_many :categories, through: :category_groups has_many :users, through: :group_users + has_and_belongs_to_many :web_hooks + before_save :downcase_incoming_email after_save :destroy_deletions diff --git a/app/models/web_hook.rb b/app/models/web_hook.rb new file mode 100644 index 000000000..de1fc9d12 --- /dev/null +++ b/app/models/web_hook.rb @@ -0,0 +1,86 @@ +class WebHook < ActiveRecord::Base + has_and_belongs_to_many :web_hook_event_types + has_and_belongs_to_many :groups + has_and_belongs_to_many :categories + + has_many :web_hook_events, dependent: :destroy + + default_scope { order('id ASC') } + + validates :payload_url, presence: true, format: URI::regexp(%w(http https)) + validates :secret, length: { minimum: 12 }, allow_blank: true + validates_presence_of :content_type + validates_presence_of :last_delivery_status + validates_presence_of :web_hook_event_types, unless: :wildcard_web_hook? + + def self.content_types + @content_types ||= Enum.new('application/json' => 1, + 'application/x-www-form-urlencoded' => 2) + end + + def self.last_delivery_statuses + @last_delivery_statuses ||= Enum.new(inactive: 1, + failed: 2, + successful: 3) + end + + def self.default_event_types + [WebHookEventType.find(WebHookEventType::POST)] + end + + def self.find_by_type(type) + WebHook.where(active: true) + .joins(:web_hook_event_types) + .where("web_hooks.wildcard_web_hook = ? OR web_hook_event_types.name = ?", true, type.to_s) + end + + def self.enqueue_hooks(type, opts = {}) + find_by_type(type).each do |w| + Jobs.enqueue(:emit_web_hook_event, opts.merge(web_hook_id: w.id, event_type: type.to_s)) + end + end + + def self.enqueue_topic_hooks(topic, user) + WebHook.enqueue_hooks(:topic, topic_id: topic.id, user_id: user&.id, category_id: topic&.category&.id) + end + + %i(topic_destroyed topic_recovered).each do |event| + DiscourseEvent.on(event) do |topic, user| + WebHook.enqueue_topic_hooks(topic, user) + end + end + + DiscourseEvent.on(:topic_created) do |topic, _, user| + WebHook.enqueue_topic_hooks(topic, user) + end + + %i(post_created + post_destroyed + post_recovered).each do |event| + + DiscourseEvent.on(event) do |post, _, user| + WebHook.enqueue_hooks(:post, + post_id: post.id, + topic_id: post&.topic&.id, + user_id: user&.id, + category_id: post.topic&.category&.id + ) + end + end +end + +# == Schema Information +# +# Table name: web_hooks +# +# id :integer not null, primary key +# payload_url :string not null +# content_type :integer default(1), not null +# last_delivery_status :integer default(1), not null +# secret :string default("") +# wildcard_web_hook :boolean default(FALSE), not null +# verify_certificate :boolean default(TRUE), not null +# active :boolean default(FALSE), not null +# created_at :datetime +# updated_at :datetime +# diff --git a/app/models/web_hook_event.rb b/app/models/web_hook_event.rb new file mode 100644 index 000000000..f2720a912 --- /dev/null +++ b/app/models/web_hook_event.rb @@ -0,0 +1,37 @@ +class WebHookEvent < ActiveRecord::Base + belongs_to :web_hook + + after_save :update_web_hook_delivery_status + + default_scope { order('created_at DESC') } + + def update_web_hook_delivery_status + web_hook.last_delivery_status = case status + when 200..299 + WebHook.last_delivery_statuses[:successful] + else + WebHook.last_delivery_statuses[:failed] + end + web_hook.save! + end +end + +# == Schema Information +# +# Table name: web_hook_events +# +# id :integer not null, primary key +# web_hook_id :integer not null +# headers :string +# payload :text +# status :integer +# response_headers :string +# response_body :text +# duration :integer default(0) +# created_at :datetime +# updated_at :datetime +# +# Indexes +# +# index_web_hook_events_on_web_hook_id (web_hook_id) +# diff --git a/app/models/web_hook_event_type.rb b/app/models/web_hook_event_type.rb new file mode 100644 index 000000000..139dd912f --- /dev/null +++ b/app/models/web_hook_event_type.rb @@ -0,0 +1,18 @@ +class WebHookEventType < ActiveRecord::Base + TOPIC = 1 + POST = 2 + + has_and_belongs_to_many :web_hooks + + default_scope { order('id ASC') } + + validates :name, presence: true, uniqueness: true +end + +# == Schema Information +# +# Table name: web_hook_event_types +# +# id :integer not null, primary key +# name :string not null +# diff --git a/app/serializers/admin_web_hook_event_serializer.rb b/app/serializers/admin_web_hook_event_serializer.rb new file mode 100644 index 000000000..0343d1739 --- /dev/null +++ b/app/serializers/admin_web_hook_event_serializer.rb @@ -0,0 +1,17 @@ +class AdminWebHookEventSerializer < ApplicationSerializer + attributes :id, + :web_hook_id, + :request_url, + :headers, + :payload, + :status, + :response_headers, + :response_body, + :duration, + :created_at + + + def request_url + object.web_hook.payload_url + end +end diff --git a/app/serializers/admin_web_hook_serializer.rb b/app/serializers/admin_web_hook_serializer.rb new file mode 100644 index 000000000..ff74ae3f9 --- /dev/null +++ b/app/serializers/admin_web_hook_serializer.rb @@ -0,0 +1,18 @@ +class AdminWebHookSerializer < ApplicationSerializer + attributes :id, + :payload_url, + :content_type, + :last_delivery_status, + :secret, + :wildcard_web_hook, + :verify_certificate, + :active, + :web_hook_event_types + + has_many :categories, serializer: BasicCategorySerializer, embed: :ids, include: false + has_many :groups, serializer: BasicGroupSerializer, embed: :ids, include: false + + def web_hook_event_types + ActiveModel::ArraySerializer.new(object.web_hook_event_types).as_json + end +end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 2897d0cfb..fbe90abc7 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2428,6 +2428,69 @@ en: all_users: "All Users" note_html: "Keep this key secret, all users that have it may create arbitrary posts as any user." + web_hooks: + title: "Webhooks" + none: "There are no webhooks right now." + instruction: "Webhooks allows Discourse to notify external services when certain event happens in your site. When the webhook is triggered, a POST request will send to URLs provided." + detailed_instruction: "A POST request will be sent to provided URL when chosen event happens." + new: "New Webhook" + create: "Create" + save: "Save" + destroy: "Delete" + description: "Description" + controls: "Controls" + go_back: "Back to list" + payload_url: "Payload URL" + payload_url_placeholder: "https://example.com/postreceive" + warn_local_payload_url: "It seems you are trying to set up the webhook to a local url. Event delivered to a local address may cause side-effect or unexpected behaviours. Continue?" + secret_invalid: "Secret must not have any blank characters." + secret_too_short: "Secret should be at least 12 characters." + secret_placeholder: "A optional string, used for generating signature" + event_type_missing: "You need to set up at least one event type." + content_type: "Content Type" + secret: "Secret" + event_chooser: "Which events would you like to trigger this webhook?" + wildcard_event: "Send me everything." + individual_event: "Select individual events." + verify_certificate: "Check TLS certificate of payload url" + active: "Active" + active_notice: "We will deliver event details when it happens." + categories_filter_instructions: "Relevant webhooks will only be triggered if the event is related with specified categories. Leave blank to trigger webhooks for all categories." + categories_filter: "Triggered Categories" + groups_filter_instructions: "Relevant webhooks will only be triggered if the event is related with specified groups. Leave blank to trigger webhooks for all groups." + groups_filter: "Triggered Groups" + delete_confirm: "Delete this webhook?" + topic_event: + name: "Topic Event" + details: "When there is a new topic, revised, changed or deleted." + post_event: + name: "Post Event" + details: "When there is a new reply, edit, deleted or recovered." + invitation_event: + name: "Invitation Event" + details: "When a invitation is sent or accepted." + user_event: + name: "User Event" + details: "When there is a user is created or changed." + delivery_status: + title: "Delivery Status" + inactive: "Inactive" + failed: "Failed" + successful: "Successful" + events: + none: "There are no related events." + redeliver: "Redeliver" + completion: "Completed in %{seconds} seconds." + request: "Request" + response: "Response" + redeliver_confirm: "Are you sure you want to redeliver the same payload?" + headers: "Headers" + payload: "Payload" + body: "Body" + go_list: "Go to list" + go_details: "Edit webhook" + go_events: "Go to events" + ping: "Ping" plugins: title: "Plugins" installed: "Installed Plugins" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 524c9c52f..c9ba20b69 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -350,7 +350,10 @@ en: post_reply: base: different_topic: "Post and reply must belong to the same topic." - + web_hook: + attributes: + payload_url: + invalid: "URL is invalid. URL should includes http:// or https://. And no blank is allowed." user_profile: no_info_me: "
the About Me field of your profile is currently blank, would you like to fill it out?
" diff --git a/config/routes.rb b/config/routes.rb index 19740cb18..ca9e1d2df 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -206,6 +206,12 @@ Discourse::Application.routes.draw do end end + resources :web_hooks, constraints: AdminConstraint.new + get 'web_hook_events/:id' => 'web_hooks#list_events', constraints: AdminConstraint.new, as: :web_hook_events + get 'web_hooks/:id/events' => 'web_hooks#list_events', constraints: AdminConstraint.new + post 'web_hooks/:web_hook_id/events/:event_id/redeliver' => 'web_hooks#redeliver_event', constraints: AdminConstraint.new + post 'web_hooks/:id/ping' => 'web_hooks#ping', constraints: AdminConstraint.new + resources :backups, only: [:index, :create], constraints: AdminConstraint.new do member do get "" => "backups#show", constraints: { id: BACKUP_ROUTE_FORMAT } diff --git a/db/fixtures/007_web_hook_event_types.rb b/db/fixtures/007_web_hook_event_types.rb new file mode 100644 index 000000000..afa74b88d --- /dev/null +++ b/db/fixtures/007_web_hook_event_types.rb @@ -0,0 +1,9 @@ +WebHookEventType.seed do |b| + b.id = WebHookEventType::TOPIC + b.name = "topic" +end + +WebHookEventType.seed do |b| + b.id = WebHookEventType::POST + b.name = "post" +end diff --git a/db/migrate/20160905082217_create_web_hook_event_types.rb b/db/migrate/20160905082217_create_web_hook_event_types.rb new file mode 100644 index 000000000..44466d451 --- /dev/null +++ b/db/migrate/20160905082217_create_web_hook_event_types.rb @@ -0,0 +1,7 @@ +class CreateWebHookEventTypes < ActiveRecord::Migration + def change + create_table :web_hook_event_types do |t| + t.string :name, null: false + end + end +end diff --git a/db/migrate/20160905082248_create_web_hooks.rb b/db/migrate/20160905082248_create_web_hooks.rb new file mode 100644 index 000000000..f0f592507 --- /dev/null +++ b/db/migrate/20160905082248_create_web_hooks.rb @@ -0,0 +1,16 @@ +class CreateWebHooks < ActiveRecord::Migration + def change + create_table :web_hooks do |t| + t.string :payload_url, null: false + t.integer :content_type, default: 1, null: false + t.integer :last_delivery_status, default: 1, null: false + t.integer :status, default: 1, null: false + t.string :secret, default: '' + t.boolean :wildcard_web_hook, default: false, null: false + t.boolean :verify_certificate, default: true, null: false + t.boolean :active, default: false, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20160905084502_create_web_hook_events.rb b/db/migrate/20160905084502_create_web_hook_events.rb new file mode 100644 index 000000000..9f1552015 --- /dev/null +++ b/db/migrate/20160905084502_create_web_hook_events.rb @@ -0,0 +1,15 @@ +class CreateWebHookEvents < ActiveRecord::Migration + def change + create_table :web_hook_events do |t| + t.belongs_to :web_hook, null: false, index: true + t.string :headers + t.text :payload + t.integer :status, default: 0 + t.string :response_headers + t.text :response_body + t.integer :duration, default: 0 + + t.timestamps + end + end +end diff --git a/db/migrate/20160905085445_create_join_table_web_hooks_web_hook_event_types.rb b/db/migrate/20160905085445_create_join_table_web_hooks_web_hook_event_types.rb new file mode 100644 index 000000000..28c54d3ab --- /dev/null +++ b/db/migrate/20160905085445_create_join_table_web_hooks_web_hook_event_types.rb @@ -0,0 +1,9 @@ +class CreateJoinTableWebHooksWebHookEventTypes < ActiveRecord::Migration + def change + create_join_table :web_hooks, :web_hook_event_types + + add_index :web_hook_event_types_hooks, [:web_hook_event_type_id, :web_hook_id], + name: 'idx_web_hook_event_types_hooks_on_ids', + unique: true + end +end diff --git a/db/migrate/20160905091958_create_join_table_web_hooks_groups.rb b/db/migrate/20160905091958_create_join_table_web_hooks_groups.rb new file mode 100644 index 000000000..a23760771 --- /dev/null +++ b/db/migrate/20160905091958_create_join_table_web_hooks_groups.rb @@ -0,0 +1,6 @@ +class CreateJoinTableWebHooksGroups < ActiveRecord::Migration + def change + create_join_table :web_hooks, :groups + add_index :groups_web_hooks, [:web_hook_id, :group_id], unique: true + end +end diff --git a/db/migrate/20160905092148_create_join_table_web_hooks_categories.rb b/db/migrate/20160905092148_create_join_table_web_hooks_categories.rb new file mode 100644 index 000000000..8759ff64a --- /dev/null +++ b/db/migrate/20160905092148_create_join_table_web_hooks_categories.rb @@ -0,0 +1,6 @@ +class CreateJoinTableWebHooksCategories < ActiveRecord::Migration + def change + create_join_table :web_hooks, :categories + add_index :categories_web_hooks, [:web_hook_id, :category_id], unique: true + end +end diff --git a/spec/fabricators/web_hook_fabricator.rb b/spec/fabricators/web_hook_fabricator.rb new file mode 100644 index 000000000..3d10b267a --- /dev/null +++ b/spec/fabricators/web_hook_fabricator.rb @@ -0,0 +1,30 @@ +Fabricator(:web_hook) do + payload_url 'https://meta.discourse.org/webhook_listener' + content_type WebHook.content_types['application/json'] + wildcard_web_hook false + secret 'my_lovely_secret_for_web_hook' + verify_certificate true + active true + + transient post_hook: WebHookEventType.find_by(name: 'post') + + after_build do |web_hook, transients| + web_hook.web_hook_event_types << transients[:post_hook] + end +end + +Fabricator(:inactive_web_hook, from: :web_hook) do + active false +end + +Fabricator(:wildcard_web_hook, from: :web_hook) do + wildcard_web_hook true +end + +Fabricator(:topic_web_hook, from: :web_hook) do + transient topic_hook: WebHookEventType.find_by(name: 'topic') + + after_build do |web_hook, transients| + web_hook.web_hook_event_types = [transients[:topic_hook]] + end +end diff --git a/spec/jobs/emit_web_hook_event_spec.rb b/spec/jobs/emit_web_hook_event_spec.rb new file mode 100644 index 000000000..e7ba30fb0 --- /dev/null +++ b/spec/jobs/emit_web_hook_event_spec.rb @@ -0,0 +1,91 @@ +require 'rails_helper' + +describe Jobs::EmitWebHookEvent do + let(:post_hook) { Fabricate(:web_hook) } + let(:inactive_hook) { Fabricate(:inactive_web_hook) } + let(:post) { Fabricate(:post) } + let(:user) { Fabricate(:user) } + + it 'raises an error when there is no web hook record' do + expect { subject.execute(event_type: 'post') }.to raise_error(Discourse::InvalidParameters) + end + + it 'raises an error when there is no event name' do + expect { subject.execute(web_hook_id: 1) }.to raise_error(Discourse::InvalidParameters) + end + + it 'raises an error when event name is invalid' do + expect { subject.execute(web_hook_id: post_hook.id, event_type: 'post_random') }.to raise_error(Discourse::InvalidParameters) + end + + it "doesn't emit when the hook is inactive" do + Jobs::EmitWebHookEvent.any_instance.expects(:web_hook_request).never + subject.execute(web_hook_id: inactive_hook.id, event_type: 'post', post_id: post.id) + end + + it 'emits normally with sufficient arguments' do + Jobs::EmitWebHookEvent.any_instance.expects(:web_hook_request).once + subject.execute(web_hook_id: post_hook.id, event_type: 'post', post_id: post.id) + end + + context 'with category filters' do + let(:category) { Fabricate(:category) } + let(:topic) { Fabricate(:topic) } + let(:topic_with_category) { Fabricate(:topic, category_id: category.id) } + let(:topic_hook) { Fabricate(:topic_web_hook, categories: [category]) } + + it "doesn't emit when event is not related with defined categories" do + Jobs::EmitWebHookEvent.any_instance.expects(:web_hook_request).never + + subject.execute(web_hook_id: topic_hook.id, + event_type: 'topic', + topic_id: topic.id, + user_id: user.id, + category_id: topic.category.id) + end + + it 'emit when event is related with defined categories' do + Jobs::EmitWebHookEvent.any_instance.expects(:web_hook_request).once + + subject.execute(web_hook_id: topic_hook.id, + event_type: 'topic', + topic_id: topic_with_category.id, + user_id: user.id, + category_id: topic_with_category.category.id) + end + end + + describe '.web_hook_request' do + before(:all) { Excon.defaults[:mock] = true } + after(:all) { Excon.defaults[:mock] = false } + after(:each) { Excon.stubs.clear } + + it 'creates delivery event record' do + Excon.stub({ url: "https://meta.discourse.org/webhook_listener" }, + { body: 'OK', status: 200 }) + + expect do + subject.execute(web_hook_id: post_hook.id, event_type: 'post', post_id: post.id) + end.to change(WebHookEvent, :count).by(1) + end + + it 'sets up proper request headers' do + Excon.stub({ url: "https://meta.discourse.org/webhook_listener" }, + { headers: { test: 'string' }, body: 'OK', status: 200 }) + + subject.execute(web_hook_id: post_hook.id, event_type: 'ping', event_name: 'ping') + event = WebHookEvent.last + headers = MultiJson.load(event.headers) + expect(headers['Content-Length']).to eq(13) + expect(headers['Host']).to eq("meta.discourse.org") + expect(headers['X-Discourse-Event-Id']).to eq(event.id) + expect(headers['X-Discourse-Event-Type']).to eq('ping') + expect(headers['X-Discourse-Event']).to eq('ping') + expect(headers['X-Discourse-Event-Signature']).to eq('sha256=162f107f6b5022353274eb1a7197885cfd35744d8d08e5bcea025d309386b7d6') + expect(event.payload).to eq(MultiJson.dump({ping: 'OK'})) + expect(event.status).to eq(200) + expect(MultiJson.load(event.response_headers)['test']).to eq('string') + expect(event.response_body).to eq('OK') + end + end +end diff --git a/spec/models/web_hook_event_spec.rb b/spec/models/web_hook_event_spec.rb new file mode 100644 index 000000000..5f6a9046b --- /dev/null +++ b/spec/models/web_hook_event_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +describe WebHookEvent do + let(:event) { WebHookEvent.new(status: 200, web_hook: Fabricate(:web_hook)) } + let(:failed_event) { WebHookEvent.new(status: 400, web_hook: Fabricate(:web_hook)) } + + it 'update last delivery status for associated WebHook record' do + event.update_web_hook_delivery_status + expect(event.web_hook.last_delivery_status).to eq(WebHook.last_delivery_statuses[:successful]) + end + + it 'sets last delivery status to failed' do + failed_event.update_web_hook_delivery_status + expect(failed_event.web_hook.last_delivery_status).to eq(WebHook.last_delivery_statuses[:failed]) + end +end diff --git a/spec/models/web_hook_event_type_spec.rb b/spec/models/web_hook_event_type_spec.rb new file mode 100644 index 000000000..bd6b8ef41 --- /dev/null +++ b/spec/models/web_hook_event_type_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +describe WebHookEventType do + it { is_expected.to validate_presence_of :name } +end diff --git a/spec/models/web_hook_spec.rb b/spec/models/web_hook_spec.rb new file mode 100644 index 000000000..4d5586c90 --- /dev/null +++ b/spec/models/web_hook_spec.rb @@ -0,0 +1,132 @@ +require 'rails_helper' + +describe WebHook do + it { is_expected.to validate_presence_of :payload_url } + it { is_expected.to validate_presence_of :content_type } + it { is_expected.to validate_presence_of :last_delivery_status } + it { is_expected.to validate_presence_of :web_hook_event_types } + + describe '#content_types' do + subject { WebHook.content_types } + + it "'json' (application/json) should be at 1st position" do + expect(subject['application/json']).to eq(1) + end + + it "'url_encoded' (application/x-www-form-urlencoded) should be at 2st position" do + expect(subject['application/x-www-form-urlencoded']).to eq(2) + end + end + + describe '#last_delivery_statuses' do + subject { WebHook.last_delivery_statuses } + + it "inactive should be at 1st position" do + expect(subject[:inactive]).to eq(1) + end + + it "failed should be at 2st position" do + expect(subject[:failed]).to eq(2) + end + + it "successful should be at 3st position" do + expect(subject[:successful]).to eq(3) + end + end + + context 'web hooks' do + let!(:post_hook) { Fabricate(:web_hook) } + let!(:topic_hook) { Fabricate(:topic_web_hook) } + + describe '#find_by_type' do + it 'find relevant hooks' do + expect(WebHook.find_by_type(:post)).to eq([post_hook]) + expect(WebHook.find_by_type(:topic)).to eq([topic_hook]) + end + + it 'excludes inactive hooks' do + post_hook.update_attributes!(active: false) + + expect(WebHook.find_by_type(:post)).to eq([]) + expect(WebHook.find_by_type(:topic)).to eq([topic_hook]) + end + end + + describe '#enqueue_hooks' do + it 'enqueues hooks with id and name' do + Jobs.expects(:enqueue).with(:emit_web_hook_event, web_hook_id: post_hook.id, event_type: 'post') + + WebHook.enqueue_hooks(:post) + end + + it 'accepts additional parameters' do + Jobs.expects(:enqueue).with(:emit_web_hook_event, web_hook_id: post_hook.id, post_id: 1, event_type: 'post') + + WebHook.enqueue_hooks(:post, post_id: 1) + end + end + + context 'includes wildcard hooks' do + let!(:wildcard_hook) { Fabricate(:wildcard_web_hook) } + + describe '#find_by_type' do + it 'can find wildcard hooks' do + expect(WebHook.find_by_type(:wildcard)).to eq([wildcard_hook]) + end + + it 'can include wildcard hooks' do + expect(WebHook.find_by_type(:post).sort_by(&:id)).to eq([post_hook, wildcard_hook]) + expect(WebHook.find_by_type(:topic).sort_by(&:id)).to eq([topic_hook, wildcard_hook]) + + end + end + + describe '#enqueue_hooks' do + it 'enqueues hooks with ids' do + Jobs.expects(:enqueue).with(:emit_web_hook_event, web_hook_id: post_hook.id, event_type: 'post') + Jobs.expects(:enqueue).with(:emit_web_hook_event, web_hook_id: wildcard_hook.id, event_type: 'post') + + WebHook.enqueue_hooks(:post) + end + + it 'accepts additional parameters' do + Jobs.expects(:enqueue).with(:emit_web_hook_event, web_hook_id: post_hook.id, post_id: 1, event_type: 'post') + Jobs.expects(:enqueue).with(:emit_web_hook_event, web_hook_id: wildcard_hook.id, post_id: 1, event_type: 'post') + + WebHook.enqueue_hooks(:post, post_id: 1) + end + end + end + end + + describe 'enqueues hooks' do + let!(:post_hook) { Fabricate(:web_hook) } + let!(:topic_hook) { Fabricate(:topic_web_hook) } + let(:user) { Fabricate(:user) } + let(:topic) { Fabricate(:topic, user: user) } + let(:post) { Fabricate(:post, topic: topic) } + let(:post2) { Fabricate(:post, topic: topic) } + + it 'should enqueue the right hooks for topic events' do + WebHook.expects(:enqueue_topic_hooks).once + PostCreator.create(user, { raw: 'post', title: 'topic', skip_validations: true }) + + WebHook.expects(:enqueue_topic_hooks).once + PostDestroyer.new(user, post).destroy + + WebHook.expects(:enqueue_topic_hooks).once + PostDestroyer.new(user, post).recover + end + + it 'should enqueue the right hooks for post events' do + WebHook.expects(:enqueue_hooks).once + PostCreator.create(user, { raw: 'post', topic_id: topic.id, reply_to_post_number: 1, skip_validations: true }) + + WebHook.expects(:enqueue_hooks).once + PostDestroyer.new(user, post2).destroy + + WebHook.expects(:enqueue_hooks).once + PostDestroyer.new(user, post2).recover + end + end +end diff --git a/test/javascripts/lib/utilities-test.js.es6 b/test/javascripts/lib/utilities-test.js.es6 index 75b8cc194..59281334f 100644 --- a/test/javascripts/lib/utilities-test.js.es6 +++ b/test/javascripts/lib/utilities-test.js.es6 @@ -2,6 +2,7 @@ import { blank } from 'helpers/qunit-helpers'; import { emailValid, + extractDomainFromUrl, isAnImage, avatarUrl, allowsAttachments, @@ -21,6 +22,13 @@ test("emailValid", function() { ok(emailValid('bob@EXAMPLE.com'), "allows upper case in the email domain"); }); +test("extractDomainFromUrl", function() { + equal(extractDomainFromUrl('http://meta.discourse.org:443/random'), 'meta.discourse.org', "extract domain name from url"); + equal(extractDomainFromUrl('meta.discourse.org:443/random'), 'meta.discourse.org', "extract domain regardless of scheme presence"); + equal(extractDomainFromUrl('http://192.168.0.1:443/random'), '192.168.0.1', "works for IP address"); + equal(extractDomainFromUrl('http://localhost:443/random'), 'localhost', "works for localhost"); +}); + var validUpload = validateUploadedFiles; test("validateUploadedFiles", function() {