FEATURE: Webhooks.
This commit is contained in:
parent
1f70fc9e11
commit
9ce61b4586
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
|
@ -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.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');
|
||||||
|
|
|
@ -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' });
|
||||||
|
}
|
||||||
|
});
|
|
@ -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}}
|
{{#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'}}
|
||||||
|
|
|
@ -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>
|
<div class='desc'>{{{unbound setting.description}}}</div>
|
||||||
{{setting-validation-message message=validationMessage}}
|
{{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>
|
|
@ -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>
|
|
@ -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 { 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);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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')
|
}
|
||||||
|
|
||||||
});
|
});
|
|
@ -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')
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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 = '';
|
||||||
|
|
||||||
|
|
|
@ -156,7 +156,7 @@ const Group = Discourse.Model.extend({
|
||||||
data: { notification_level },
|
data: { notification_level },
|
||||||
type: "POST"
|
type: "POST"
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Group.reopenClass({
|
Group.reopenClass({
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>
|
<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">
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-preferences {
|
.user-preferences {
|
||||||
input.category-group, input.user-selector {
|
input.category-selector, input.user-selector {
|
||||||
width: 530px;
|
width: 530px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -86,7 +86,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-preferences {
|
.user-preferences {
|
||||||
input.category-group {
|
input.category-selector {
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
|
|
|
@ -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
|
|
@ -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 :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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
#
|
|
@ -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)
|
||||||
|
#
|
|
@ -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
|
||||||
|
#
|
|
@ -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
|
|
@ -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"
|
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"
|
||||||
|
|
|
@ -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>"
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe WebHookEventType do
|
||||||
|
it { is_expected.to validate_presence_of :name }
|
||||||
|
end
|
|
@ -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 { 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() {
|
||||||
|
|
Loading…
Reference in New Issue