mirror of
https://github.com/codeninjasllc/discourse.git
synced 2024-11-27 09:36:19 -05:00
FEATURE: Webhooks.
This commit is contained in:
parent
1f70fc9e11
commit
9ce61b4586
58 changed files with 1582 additions and 38 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
});
|
|
@ -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')}`));
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
85
app/assets/javascripts/admin/models/web-hook.js.es6
Normal file
85
app/assets/javascripts/admin/models/web-hook.js.es6
Normal file
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
@ -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' });
|
||||
}
|
||||
});
|
|
@ -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' });
|
||||
}
|
||||
});
|
15
app/assets/javascripts/admin/routes/admin-web-hooks.js.es6
Normal file
15
app/assets/javascripts/admin/routes/admin-web-hooks.js.es6
Normal file
|
@ -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
|
||||
});
|
||||
}
|
||||
});
|
|
@ -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'}}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
{{input id=typeName type="checkbox" name="event-choice" checked=enabled}}
|
||||
<label for={{typeName}}>{{name}}</label>
|
||||
<p>{{details}}</p>
|
|
@ -0,0 +1,19 @@
|
|||
<div class="col first">
|
||||
<span class="{{statusColorClasses}}">{{model.status}}</span>
|
||||
</div>
|
||||
<div class="col event-id">{{model.id}}</div>
|
||||
<div class="col timestamp">{{createdAt}}</div>
|
||||
<div class="col completion">{{completion}}</div>
|
||||
<div class="col actions">
|
||||
{{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'}}
|
||||
</div>
|
||||
{{#if expandDetails}}
|
||||
<div class="details">
|
||||
<h3>{{i18n 'admin.web_hooks.events.headers'}}</h3>
|
||||
<pre><code>{{headers}}</code></pre>
|
||||
<h3>{{bodyLabel}}</h3>
|
||||
<pre><code>{{body}}</code></pre>
|
||||
</div>
|
||||
{{/if}}
|
|
@ -1,3 +1,3 @@
|
|||
{{category-group categories=selectedCategories blacklist=selectedCategories}}
|
||||
{{category-selector categories=selectedCategories blacklist=selectedCategories}}
|
||||
<div class='desc'>{{{unbound setting.description}}}</div>
|
||||
{{setting-validation-message message=validationMessage}}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
<div class="web-hook-direction">
|
||||
{{#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}}
|
||||
</div>
|
||||
|
||||
<div class='web-hook-events-listing'>
|
||||
{{#if model}}
|
||||
{{#load-more selector=".web-hook-events li" action="loadMore"}}
|
||||
<div class='web-hook-events content-list'>
|
||||
<ul>
|
||||
{{#each model as |webHookEvent|}}
|
||||
{{admin-web-hook-event model=webHookEvent}}
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
{{conditional-loading-spinner condition=model.loadingMore}}
|
||||
{{/load-more}}
|
||||
{{else}}
|
||||
<p>{{i18n 'admin.web_hooks.events.none'}}</p>
|
||||
{{/if}}
|
||||
</div>
|
86
app/assets/javascripts/admin/templates/web-hooks-show.hbs
Normal file
86
app/assets/javascripts/admin/templates/web-hooks-show.hbs
Normal file
|
@ -0,0 +1,86 @@
|
|||
{{#link-to 'adminWebHooks' class="go-back"}}
|
||||
{{fa-icon 'arrow-left'}}
|
||||
{{i18n 'admin.web_hooks.go_back'}}
|
||||
{{/link-to}}
|
||||
|
||||
<div class='web-hook-container'>
|
||||
<p>{{i18n 'admin.web_hooks.detailed_instruction'}}</p>
|
||||
<form class='web-hook form-horizontal'>
|
||||
<div>
|
||||
<label for='payload-url'>{{i18n 'admin.web_hooks.payload_url'}}</label>
|
||||
{{text-field name="payload-url" value=model.payload_url placeholderKey="admin.web_hooks.payload_url_placeholder"}}
|
||||
{{input-tip validation=urlValidation}}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for='content-type'>{{i18n 'admin.web_hooks.content_type'}}</label>
|
||||
{{combo-box content=contentTypes
|
||||
name="content-type"
|
||||
nameProperty="name"
|
||||
valueAttribute="id"
|
||||
value=model.content_type}}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for='secret'>{{i18n 'admin.web_hooks.secret'}}</label>
|
||||
{{text-field name="secret" value=model.secret placeholderKey="admin.web_hooks.secret_placeholder"}}
|
||||
{{input-tip validation=secretValidation}}
|
||||
</div>
|
||||
|
||||
<div class="cbox10">
|
||||
<label>{{i18n 'admin.web_hooks.event_chooser'}}</label>
|
||||
<div>
|
||||
{{radio-button class="subscription-choice" name="subscription-choice" value="individual" selection=model.webHookType}}
|
||||
{{i18n 'admin.web_hooks.individual_event'}}
|
||||
{{input-tip validation=eventTypeValidation}}
|
||||
</div>
|
||||
{{#unless model.wildcard_web_hook}}
|
||||
<div class="event-selector">
|
||||
{{#each eventTypes as |type|}}
|
||||
{{admin-web-hook-event-chooser type=type model=model.web_hook_event_types}}
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/unless}}
|
||||
<div>
|
||||
{{radio-button class="subscription-choice" name="subscription-choice" value="wildcard" selection=model.webHookType}}
|
||||
{{i18n 'admin.web_hooks.wildcard_event'}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='filters'>
|
||||
<div>
|
||||
<label>{{fa-icon 'circle' class='tracking'}}{{i18n 'admin.web_hooks.categories_filter'}}</label>
|
||||
{{category-selector categories=model.categoriesFilter blacklist=model.categoriesFilter}}
|
||||
<div class="instructions">{{i18n 'admin.web_hooks.categories_filter_instructions'}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>{{fa-icon 'circle' class='tracking'}}{{i18n 'admin.web_hooks.groups_filter'}}</label>
|
||||
{{group-selector groupNames=model.groupsFilterInName groupFinder=model.groupFinder}}
|
||||
<div class="instructions">{{i18n 'admin.web_hooks.groups_filter_instructions'}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{input type="checkbox" name="verify_certificate" checked=model.verify_certificate}} {{i18n 'admin.web_hooks.verify_certificate'}}
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
{{input type="checkbox" name="active" checked=model.active}} {{i18n 'admin.web_hooks.active'}}
|
||||
</div>
|
||||
{{#if model.active}}
|
||||
<div class="instructions">{{i18n 'admin.web_hooks.active_notice'}}</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class='controls'>
|
||||
<button class='btn btn-default' {{action 'save'}} disabled={{saveButtonDisabled}}>{{saveButtonText}}</button>
|
||||
{{#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}}
|
||||
<span class='saving'>{{savingStatus}}</span>
|
||||
</div>
|
||||
</div>
|
39
app/assets/javascripts/admin/templates/web-hooks.hbs
Normal file
39
app/assets/javascripts/admin/templates/web-hooks.hbs
Normal file
|
@ -0,0 +1,39 @@
|
|||
<div class='pull-right'>
|
||||
{{#link-to 'adminWebHooks.show' 'new' tagName='button' classNames='btn'}}
|
||||
{{fa-icon 'plus'}} {{i18n 'admin.web_hooks.new'}}
|
||||
{{/link-to}}
|
||||
</div>
|
||||
<div class='clearfix'></div>
|
||||
<div class='web-hooks-listing'>
|
||||
{{#if model}}
|
||||
<p>{{i18n 'admin.web_hooks.instruction'}}</p>
|
||||
{{#load-more selector=".web-hooks tr" action="loadMore"}}
|
||||
<table class='web-hooks'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{i18n 'admin.web_hooks.delivery_status.title'}}</th>
|
||||
<th>{{i18n 'admin.web_hooks.payload_url'}}</th>
|
||||
<th>{{i18n 'admin.web_hooks.description'}}</th>
|
||||
<th>{{i18n 'admin.web_hooks.controls'}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each model as |webHook|}}
|
||||
<tr>
|
||||
<td>{{#link-to 'adminWebHooks.showEvents' webHook.id}}{{admin-web-hook-status deliveryStatuses=deliveryStatuses model=webHook}}{{/link-to}}</td>
|
||||
<td>{{#link-to 'adminWebHooks.show' webHook}}{{webHook.payload_url}}{{/link-to}}</td>
|
||||
<td class='description'>{{webHook.description}}</td>
|
||||
<td class='controls'>
|
||||
{{#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"}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{conditional-loading-spinner condition=model.loadingMore}}
|
||||
{{/load-more}}
|
||||
{{else}}
|
||||
<p>{{i18n 'admin.web_hooks.none'}}</p>
|
||||
{{/if}}
|
||||
</div>
|
|
@ -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);
|
||||
},
|
||||
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
||||
}
|
||||
});
|
|
@ -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')
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 = '';
|
||||
|
||||
|
|
|
@ -156,7 +156,7 @@ const Group = Discourse.Model.extend({
|
|||
data: { notification_level },
|
||||
type: "POST"
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
Group.reopenClass({
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
<input class='category-group' type='text'>
|
|
@ -0,0 +1 @@
|
|||
<input class='category-selector' type='text' name='categories'>
|
|
@ -1 +1 @@
|
|||
<input class='ember-text-field group-names' type="text" placeholder={{placeholder}} name="groups">
|
||||
<input class='group-selector' placeholder={{placeholder}} type='text' name='groups'>
|
||||
|
|
|
@ -250,7 +250,7 @@
|
|||
<label class="control-label">{{i18n 'user.categories_settings'}}</label>
|
||||
<div class="controls category-controls">
|
||||
<label><span class="icon fa fa-exclamation-circle watching"></span> {{i18n 'user.watched_categories'}}</label>
|
||||
{{category-group categories=model.watchedCategories blacklist=selectedCategories}}
|
||||
{{category-selector categories=model.watchedCategories blacklist=selectedCategories}}
|
||||
</div>
|
||||
<div class="instructions">{{i18n 'user.watched_categories_instructions'}}</div>
|
||||
<div class="controls category-controls">
|
||||
|
@ -259,17 +259,17 @@
|
|||
<div class="instructions"></div>
|
||||
<div class="controls category-controls">
|
||||
<label><span class="icon fa fa-circle tracking"></span> {{i18n 'user.tracked_categories'}}</label>
|
||||
{{category-group categories=model.trackedCategories blacklist=selectedCategories}}
|
||||
{{category-selector categories=model.trackedCategories blacklist=selectedCategories}}
|
||||
</div>
|
||||
<div class="instructions">{{i18n 'user.tracked_categories_instructions'}}</div>
|
||||
<div class="controls category-controls">
|
||||
<label><span class="icon fa fa-dot-circle-o watching-first-post"></span> {{i18n 'user.watched_first_post_categories'}}</label>
|
||||
{{category-group categories=model.watchedFirstPostCategories}}
|
||||
{{category-selector categories=model.watchedFirstPostCategories}}
|
||||
</div>
|
||||
<div class="instructions">{{i18n 'user.watched_first_post_categories_instructions'}}</div>
|
||||
<div class="controls category-controls">
|
||||
<label><span class="icon fa fa-times-circle muted"></span> {{i18n 'user.muted_categories'}}</label>
|
||||
{{category-group categories=model.mutedCategories blacklist=selectedCategories}}
|
||||
{{category-selector categories=model.mutedCategories blacklist=selectedCategories}}
|
||||
</div>
|
||||
<div class="instructions">{{i18n 'user.muted_categories_instructions'}}</div>
|
||||
<div class="controls category-controls">
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
}
|
||||
|
||||
.user-preferences {
|
||||
input.category-group, input.user-selector {
|
||||
input.category-selector, input.user-selector {
|
||||
width: 530px;
|
||||
}
|
||||
|
||||
|
|
|
@ -86,7 +86,7 @@
|
|||
}
|
||||
|
||||
.user-preferences {
|
||||
input.category-group {
|
||||
input.category-selector {
|
||||
}
|
||||
|
||||
textarea {
|
||||
|
|
114
app/controllers/admin/web_hooks_controller.rb
Normal file
114
app/controllers/admin/web_hooks_controller.rb
Normal file
|
@ -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
|
101
app/jobs/regular/emit_web_hook_event.rb
Normal file
101
app/jobs/regular/emit_web_hook_event.rb
Normal file
|
@ -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
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
86
app/models/web_hook.rb
Normal file
86
app/models/web_hook.rb
Normal file
|
@ -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
|
||||
#
|
37
app/models/web_hook_event.rb
Normal file
37
app/models/web_hook_event.rb
Normal file
|
@ -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)
|
||||
#
|
18
app/models/web_hook_event_type.rb
Normal file
18
app/models/web_hook_event_type.rb
Normal file
|
@ -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
|
||||
#
|
17
app/serializers/admin_web_hook_event_serializer.rb
Normal file
17
app/serializers/admin_web_hook_event_serializer.rb
Normal file
|
@ -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
|
18
app/serializers/admin_web_hook_serializer.rb
Normal file
18
app/serializers/admin_web_hook_serializer.rb
Normal file
|
@ -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
|
|
@ -2428,6 +2428,69 @@ en:
|
|||
all_users: "All Users"
|
||||
note_html: "Keep this key <strong>secret</strong>, 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"
|
||||
|
|
|
@ -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: "<div class='missing-profile'>the About Me field of your profile is currently blank, <a href='/users/%{username_lower}/preferences/about-me'>would you like to fill it out?</a></div>"
|
||||
|
|
|
@ -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 }
|
||||
|
|
9
db/fixtures/007_web_hook_event_types.rb
Normal file
9
db/fixtures/007_web_hook_event_types.rb
Normal file
|
@ -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
|
7
db/migrate/20160905082217_create_web_hook_event_types.rb
Normal file
7
db/migrate/20160905082217_create_web_hook_event_types.rb
Normal file
|
@ -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
|
16
db/migrate/20160905082248_create_web_hooks.rb
Normal file
16
db/migrate/20160905082248_create_web_hooks.rb
Normal file
|
@ -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
|
15
db/migrate/20160905084502_create_web_hook_events.rb
Normal file
15
db/migrate/20160905084502_create_web_hook_events.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
30
spec/fabricators/web_hook_fabricator.rb
Normal file
30
spec/fabricators/web_hook_fabricator.rb
Normal file
|
@ -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
|
91
spec/jobs/emit_web_hook_event_spec.rb
Normal file
91
spec/jobs/emit_web_hook_event_spec.rb
Normal file
|
@ -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
|
16
spec/models/web_hook_event_spec.rb
Normal file
16
spec/models/web_hook_event_spec.rb
Normal file
|
@ -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
|
5
spec/models/web_hook_event_type_spec.rb
Normal file
5
spec/models/web_hook_event_type_spec.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
require 'rails_helper'
|
||||
|
||||
describe WebHookEventType do
|
||||
it { is_expected.to validate_presence_of :name }
|
||||
end
|
132
spec/models/web_hook_spec.rb
Normal file
132
spec/models/web_hook_spec.rb
Normal file
|
@ -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
|
|
@ -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() {
|
||||
|
|
Loading…
Reference in a new issue