FEATURE: Webhooks.

This commit is contained in:
Erick Guan 2016-06-15 19:49:57 +02:00 committed by Guo Xiang Tan
parent 1f70fc9e11
commit 9ce61b4586
58 changed files with 1582 additions and 38 deletions

View File

@ -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;
}
}
});

View File

@ -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);
}
}
}
});

View File

@ -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')}`));
}
});

View File

@ -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);
}
}
});

View File

@ -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);
}
});
}
}
});

View File

@ -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();
}
}
});

View 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();
}
});

View File

@ -36,6 +36,10 @@ export default {
}); });
}); });
this.route('api'); 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.resource('admin.backups', { path: '/backups' }, function() {
this.route('logs'); this.route('logs');

View File

@ -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' });
}
});

View File

@ -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' });
}
});

View 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
});
}
});

View File

@ -20,6 +20,7 @@
{{#if currentUser.admin}} {{#if currentUser.admin}}
{{nav-item route='adminCustomize' label='admin.customize.title'}} {{nav-item route='adminCustomize' label='admin.customize.title'}}
{{nav-item route='admin.api' label='admin.api.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'}} {{nav-item route='admin.backups' label='admin.backups.title'}}
{{/if}} {{/if}}
{{nav-item route='adminPlugins' label='admin.plugins.title'}} {{nav-item route='adminPlugins' label='admin.plugins.title'}}

View File

@ -0,0 +1,3 @@
{{input id=typeName type="checkbox" name="event-choice" checked=enabled}}
<label for={{typeName}}>{{name}}</label>
<p>{{details}}</p>

View File

@ -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}}

View File

@ -1,3 +1,3 @@
{{category-group categories=selectedCategories blacklist=selectedCategories}} {{category-selector categories=selectedCategories blacklist=selectedCategories}}
<div class='desc'>{{{unbound setting.description}}}</div> <div class='desc'>{{{unbound setting.description}}}</div>
{{setting-validation-message message=validationMessage}} {{setting-validation-message message=validationMessage}}

View File

@ -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>

View 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>

View 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>

View File

@ -1,8 +1,7 @@
import { ajax } from 'discourse/lib/ajax'; import { ajax } from 'discourse/lib/ajax';
import { hashString } from 'discourse/lib/hash'; 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) { export function Result(payload, responseJson) {
this.payload = payload; this.payload = payload;
@ -57,8 +56,8 @@ export default Ember.Object.extend({
return this.appendQueryParams(path, findArgs); return this.appendQueryParams(path, findArgs);
}, },
findAll(store, type) { findAll(store, type, findArgs) {
return ajax(this.pathFor(store, type)).catch(rethrow); return ajax(this.pathFor(store, type, findArgs)).catch(rethrow);
}, },

View File

@ -1,37 +1,37 @@
import { categoryBadgeHTML } from 'discourse/helpers/category-link'; import { categoryBadgeHTML } from 'discourse/helpers/category-link';
import Category from 'discourse/models/category'; import Category from 'discourse/models/category';
import { on } from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({ export default Ember.Component.extend({
@on('didInsertElement')
_initializeAutocomplete: function() { _initializeAutocomplete() {
const self = this, 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/')}([^'\"]+)`); regexp = new RegExp(`href=['\"]${Discourse.getURL('/c/')}([^'\"]+)`);
this.$('input').autocomplete({ this.$('input').autocomplete({
items: this.get('categories'), items: this.get('categories'),
single: false, single: false,
allowAny: false, allowAny: false,
dataSource(term){ dataSource(term) {
return Category.list().filter(function(category){ return Category.list().filter(category => {
const regex = new RegExp(term, "i"); const regex = new RegExp(term, 'i');
return category.get("name").match(regex) && return category.get('name').match(regex) &&
!_.contains(self.get('blacklist') || [], category) && !_.contains(self.get('blacklist') || [], category) &&
!_.contains(self.get('categories'), category) ; !_.contains(self.get('categories'), category) ;
}); });
}, },
onChangeItems(items) { onChangeItems(items) {
const categories = _.map(items, function(link) { const categories = _.map(items, link => {
const slug = link.match(regexp)[1]; const slug = link.match(regexp)[1];
return Category.findSingleBySlug(slug); return Category.findSingleBySlug(slug);
}); });
Em.run.next(() => self.set("categories", categories)); Em.run.next(() => self.set('categories', categories));
}, },
template, template,
transformComplete(category) { transformComplete(category) {
return categoryBadgeHTML(category, {allowUncategorized: true}); return categoryBadgeHTML(category, {allowUncategorized: true});
} }
}); });
}.on('didInsertElement') }
}); });

View File

@ -1,15 +1,20 @@
export default Ember.Component.extend({ import { on, default as computed } from 'ember-addons/ember-computed-decorators';
placeholder: function(){
return I18n.t(this.get("placeholderKey"));
}.property("placeholderKey"),
_initializeAutocomplete: function() { export default Ember.Component.extend({
@computed('placeholderKey')
placeholder(placeholderKey) {
return placeholderKey ? I18n.t(placeholderKey) : '';
},
@on('didInsertElement')
_initializeAutocomplete() {
var self = this; var self = this;
var selectedGroups; var selectedGroups;
var template = this.container.lookup('template:group-selector-autocomplete.raw'); var template = this.container.lookup('template:group-selector-autocomplete.raw');
self.$('input').autocomplete({ self.$('input').autocomplete({
allowAny: false, allowAny: false,
items: this.get('groupNames'),
onChangeItems: function(items){ onChangeItems: function(items){
selectedGroups = items; selectedGroups = items;
self.set("groupNames", items.join(",")); self.set("groupNames", items.join(","));
@ -31,5 +36,5 @@ export default Ember.Component.extend({
}, },
template: template template: template
}); });
}.on('didInsertElement') }
}); });

View File

@ -265,3 +265,20 @@ export function number(val) {
} }
return val.toString(); 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);
}

View File

@ -71,10 +71,19 @@ export function userUrl(username) {
export function emailValid(email) { export function emailValid(email) {
// see: http://stackoverflow.com/questions/46155/validate-email-address-in-javascript // 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); 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() { export function selectedText() {
var html = ''; var html = '';

View File

@ -156,7 +156,7 @@ const Group = Discourse.Model.extend({
data: { notification_level }, data: { notification_level },
type: "POST" type: "POST"
}); });
}, }
}); });
Group.reopenClass({ Group.reopenClass({

View File

@ -49,9 +49,9 @@ export default Ember.Object.extend({
this._plurals[thing] = plural; this._plurals[thing] = plural;
}, },
findAll(type) { findAll(type, findArgs) {
const self = this; 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); return self._resultSet(type, result);
}); });
}, },

View File

@ -1 +0,0 @@
<input class='category-group' type='text'>

View File

@ -0,0 +1 @@
<input class='category-selector' type='text' name='categories'>

View File

@ -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'>

View File

@ -250,7 +250,7 @@
<label class="control-label">{{i18n 'user.categories_settings'}}</label> <label class="control-label">{{i18n 'user.categories_settings'}}</label>
<div class="controls category-controls"> <div class="controls category-controls">
<label><span class="icon fa fa-exclamation-circle watching"></span> {{i18n 'user.watched_categories'}}</label> <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>
<div class="instructions">{{i18n 'user.watched_categories_instructions'}}</div> <div class="instructions">{{i18n 'user.watched_categories_instructions'}}</div>
<div class="controls category-controls"> <div class="controls category-controls">
@ -259,17 +259,17 @@
<div class="instructions"></div> <div class="instructions"></div>
<div class="controls category-controls"> <div class="controls category-controls">
<label><span class="icon fa fa-circle tracking"></span> {{i18n 'user.tracked_categories'}}</label> <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>
<div class="instructions">{{i18n 'user.tracked_categories_instructions'}}</div> <div class="instructions">{{i18n 'user.tracked_categories_instructions'}}</div>
<div class="controls category-controls"> <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> <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>
<div class="instructions">{{i18n 'user.watched_first_post_categories_instructions'}}</div> <div class="instructions">{{i18n 'user.watched_first_post_categories_instructions'}}</div>
<div class="controls category-controls"> <div class="controls category-controls">
<label><span class="icon fa fa-times-circle muted"></span> {{i18n 'user.muted_categories'}}</label> <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>
<div class="instructions">{{i18n 'user.muted_categories_instructions'}}</div> <div class="instructions">{{i18n 'user.muted_categories_instructions'}}</div>
<div class="controls category-controls"> <div class="controls category-controls">

View File

@ -297,7 +297,7 @@ td.flaggers td {
height: 150px; height: 150px;
} }
.groups, .badges { .groups, .badges, .web-hook-container {
.form-horizontal { .form-horizontal {
label { label {
font-weight: bold; font-weight: bold;
@ -311,12 +311,24 @@ td.flaggers td {
width: 350px; width: 350px;
} }
input[type="checkbox"] { input[type="checkbox"], input[type="radio"] {
width: 20px; width: 20px;
} }
} }
} }
.text-successful {
color: $success;
}
.text-danger {
color: $danger;
}
.text-muted {
color: lighten($primary, 40);
}
.admin-nav { .admin-nav {
width: 18.018%; width: 18.018%;
position: relative; position: relative;
@ -394,7 +406,7 @@ td.flaggers td {
float: left; float: left;
width: 53%; width: 53%;
padding-right: 20px; padding-right: 20px;
.category-group { .category-selector {
width: 95%; width: 95%;
} }
@media (max-width: $mobile-breakpoint) { @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 { .email-template {
input { input {
width: 100%; 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 specific styles
// Mobile view text-inputs need some padding // Mobile view text-inputs need some padding
.mobile-view .admin-contents { .mobile-view .admin-contents {

View File

@ -20,7 +20,7 @@
} }
.user-preferences { .user-preferences {
input.category-group, input.user-selector { input.category-selector, input.user-selector {
width: 530px; width: 530px;
} }

View File

@ -86,7 +86,7 @@
} }
.user-preferences { .user-preferences {
input.category-group { input.category-selector {
} }
textarea { textarea {

View 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

View 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

View File

@ -27,6 +27,8 @@ class Category < ActiveRecord::Base
has_many :category_groups, dependent: :destroy has_many :category_groups, dependent: :destroy
has_many :groups, through: :category_groups has_many :groups, through: :category_groups
has_and_belongs_to_many :web_hooks
validates :user_id, presence: true validates :user_id, presence: true
validates :name, if: Proc.new { |c| c.new_record? || c.name_changed? }, validates :name, if: Proc.new { |c| c.new_record? || c.name_changed? },
presence: true, presence: true,

View File

@ -10,6 +10,8 @@ class Group < ActiveRecord::Base
has_many :categories, through: :category_groups has_many :categories, through: :category_groups
has_many :users, through: :group_users has_many :users, through: :group_users
has_and_belongs_to_many :web_hooks
before_save :downcase_incoming_email before_save :downcase_incoming_email
after_save :destroy_deletions after_save :destroy_deletions

86
app/models/web_hook.rb Normal file
View 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
#

View 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)
#

View 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
#

View 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

View 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

View File

@ -2428,6 +2428,69 @@ en:
all_users: "All Users" all_users: "All Users"
note_html: "Keep this key <strong>secret</strong>, all users that have it may create arbitrary posts as any user." 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: plugins:
title: "Plugins" title: "Plugins"
installed: "Installed Plugins" installed: "Installed Plugins"

View File

@ -350,7 +350,10 @@ en:
post_reply: post_reply:
base: base:
different_topic: "Post and reply must belong to the same topic." 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: 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>" 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>"

View File

@ -206,6 +206,12 @@ Discourse::Application.routes.draw do
end end
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 resources :backups, only: [:index, :create], constraints: AdminConstraint.new do
member do member do
get "" => "backups#show", constraints: { id: BACKUP_ROUTE_FORMAT } get "" => "backups#show", constraints: { id: BACKUP_ROUTE_FORMAT }

View 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

View 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

View 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

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View 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

View 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

View File

@ -0,0 +1,5 @@
require 'rails_helper'
describe WebHookEventType do
it { is_expected.to validate_presence_of :name }
end

View 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

View File

@ -2,6 +2,7 @@
import { blank } from 'helpers/qunit-helpers'; import { blank } from 'helpers/qunit-helpers';
import { import {
emailValid, emailValid,
extractDomainFromUrl,
isAnImage, isAnImage,
avatarUrl, avatarUrl,
allowsAttachments, allowsAttachments,
@ -21,6 +22,13 @@ test("emailValid", function() {
ok(emailValid('bob@EXAMPLE.com'), "allows upper case in the email domain"); 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; var validUpload = validateUploadedFiles;
test("validateUploadedFiles", function() { test("validateUploadedFiles", function() {