@@ -41,3 +41,12 @@ services:
|
||||
environment:
|
||||
DEBUG: "preview-service:*"
|
||||
PG_CONNECTION_STRING: "postgres://speckle:speckle@postgres/speckle"
|
||||
|
||||
webhook-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: packages/webhook-service/Dockerfile
|
||||
restart: always
|
||||
environment:
|
||||
DEBUG: "webhook-service:*"
|
||||
PG_CONNECTION_STRING: "postgres://speckle:speckle@postgres/speckle"
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
class="px-0"
|
||||
@click="editStreamDialog = true"
|
||||
>
|
||||
<v-icon small class="mr-2 float-left">mdi-cog-outline</v-icon>
|
||||
<v-icon small class="mr-2 float-left">mdi-pencil-outline</v-icon>
|
||||
Edit
|
||||
</v-btn>
|
||||
|
||||
@@ -125,6 +125,13 @@
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text v-if="userRole === 'owner'">
|
||||
<v-btn block small elevation="0" :to="`/settings/streams/${stream.id}/general`">
|
||||
<v-icon small class="mr-2 float-left">mdi-cog-outline</v-icon>
|
||||
Settings
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-title v-show="isHomeRoute"><h5>Collaborators</h5></v-card-title>
|
||||
<v-card-text v-show="isHomeRoute">
|
||||
<v-row no-gutters>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
<template>
|
||||
<v-card class="rounded-lg" v-bind="$attrs">
|
||||
<template slot="progress">
|
||||
<v-progress-linear indeterminate></v-progress-linear>
|
||||
</template>
|
||||
<v-card-title class="d-flex justify-space-between">
|
||||
<span class="text--secondary">{{ title }}</span>
|
||||
<span>
|
||||
<slot name="menu"></slot>
|
||||
</span>
|
||||
</v-card-title>
|
||||
<v-card-subtitle>
|
||||
<span>
|
||||
<slot name="subtitle"></slot>
|
||||
</span>
|
||||
</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<slot></slot>
|
||||
</v-card-text>
|
||||
@@ -14,9 +22,9 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "AdminCard",
|
||||
props: ["title"]
|
||||
};
|
||||
name: 'AdminCard',
|
||||
props: ['title']
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-form ref="form" v-model="valid" class="px-2" @submit.prevent="sendInvite">
|
||||
<v-text-field
|
||||
v-model="url"
|
||||
:rules="validation.urlRules"
|
||||
label="URL"
|
||||
hint="A POST request will be sent to this URL when this webhook is triggered"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="description"
|
||||
:rules="validation.descriptionRules"
|
||||
label="Description"
|
||||
hint="An optional description to help you identify this webhook."
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="secret"
|
||||
:rules="validation.secretRules"
|
||||
label="Secret"
|
||||
:hint="
|
||||
webhook == null
|
||||
? `An optional secret. You'll be able to change this in the future, but you won't be able to retrieve it.`
|
||||
: `Change your secret. Note that anything using your old secret will need to be updated.`
|
||||
"
|
||||
/>
|
||||
<v-autocomplete
|
||||
v-model="triggers"
|
||||
:items="allTriggers"
|
||||
:rules="validation.triggersRules"
|
||||
label="Choose what events will trigger this webhook"
|
||||
multiple
|
||||
small-chips
|
||||
deletable-chips
|
||||
/>
|
||||
<v-switch
|
||||
v-model="enabled"
|
||||
:label="enabled ? 'Enabled' : 'Disabled'"
|
||||
hint="Get notified when this webhook is triggered"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-form>
|
||||
|
||||
<v-divider class="mt-4 mb-3" />
|
||||
|
||||
<v-card-actions v-if="webhook != null">
|
||||
<v-btn outlined color="success" type="submit" :disabled="!valid" @click="saveChanges">
|
||||
Save Changes
|
||||
</v-btn>
|
||||
<v-btn color="error" text @click="showDelete = true">Delete Webhook</v-btn>
|
||||
<v-dialog v-model="showDelete" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title>Are you sure?</v-card-title>
|
||||
<v-card-text>
|
||||
You cannot undo this action. This webhook will be permanently deleted.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn @click="showDelete = false">Cancel</v-btn>
|
||||
<v-btn color="error" text @click="deleteWebhook">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-card-actions>
|
||||
|
||||
<v-card-actions v-else>
|
||||
<v-btn outlined color="success" type="submit" :disabled="!valid" @click="addWebhook">
|
||||
Add Webhook
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import webhookQuery from '../../graphql/webhook.gql'
|
||||
|
||||
export default {
|
||||
name: 'WebhookForm',
|
||||
props: {
|
||||
webhookId: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
streamId: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
apollo: {
|
||||
webhook: {
|
||||
query: webhookQuery,
|
||||
variables() {
|
||||
return {
|
||||
streamId: this.streamId,
|
||||
webhookId: this.webhookId
|
||||
}
|
||||
},
|
||||
update(data) {
|
||||
let webhook = data.stream.webhooks.items[0]
|
||||
this.secret = null
|
||||
if (webhook)
|
||||
({
|
||||
url: this.url,
|
||||
description: this.description,
|
||||
triggers: this.triggers,
|
||||
enabled: this.enabled
|
||||
} = webhook)
|
||||
|
||||
return webhook
|
||||
},
|
||||
skip() {
|
||||
return !this.webhookId
|
||||
}
|
||||
}
|
||||
},
|
||||
data: () => ({
|
||||
showDelete: false,
|
||||
valid: false,
|
||||
url: null,
|
||||
description: null,
|
||||
triggers: [],
|
||||
secret: null,
|
||||
enabled: true,
|
||||
allTriggers: [
|
||||
'stream_update',
|
||||
'stream_delete',
|
||||
'branch_create',
|
||||
'branch_update',
|
||||
'branch_delete',
|
||||
'commit_create',
|
||||
'commit_update',
|
||||
'commit_delete',
|
||||
'stream_permissions_add',
|
||||
'stream_permissions_remove'
|
||||
],
|
||||
validation: {
|
||||
urlRules: [
|
||||
(v) => !!v || 'URL is required',
|
||||
(v) => (/^https?:\/\//.test(v) ? true : `That doesn't look like a valid url`)
|
||||
],
|
||||
descriptionRules: [
|
||||
(v) => {
|
||||
if (v?.length >= 1024) return 'Description too long!'
|
||||
return true
|
||||
}
|
||||
],
|
||||
secretRules: [
|
||||
(v) => {
|
||||
if (v?.length >= 100) return 'Secret should be less than 100 characters'
|
||||
return true
|
||||
}
|
||||
],
|
||||
triggersRules: [
|
||||
(v) => {
|
||||
if (!v || v.length === 0) return 'You must select at least one trigger'
|
||||
return true
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
methods: {
|
||||
async saveChanges() {
|
||||
this.$emit('update:loading', true)
|
||||
this.$matomo && this.$matomo.trackPageView('stream/webhook/update')
|
||||
|
||||
let params = {
|
||||
id: this.webhook.id,
|
||||
streamId: this.streamId,
|
||||
url: this.url,
|
||||
description: this.description,
|
||||
triggers: this.triggers,
|
||||
enabled: this.enabled
|
||||
}
|
||||
if (this.secret) params.secret = this.secret
|
||||
|
||||
await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation webhookUpdate($params: WebhookUpdateInput!) {
|
||||
webhookUpdate(webhook: $params)
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
params: params
|
||||
}
|
||||
})
|
||||
this.$emit('refetch-webhooks')
|
||||
this.$emit('update:loading', false)
|
||||
this.$router.push({ name: 'webhooks' })
|
||||
},
|
||||
async addWebhook() {
|
||||
this.$emit('update:loading', true)
|
||||
this.$matomo && this.$matomo.trackPageView('stream/webhook/create')
|
||||
|
||||
await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation webhookCreate($params: WebhookCreateInput!) {
|
||||
webhookCreate(webhook: $params)
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
params: {
|
||||
streamId: this.streamId,
|
||||
url: this.url,
|
||||
description: this.description,
|
||||
triggers: this.triggers,
|
||||
enabled: this.enabled,
|
||||
secret: this.secret
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.$emit('refetch-webhooks')
|
||||
this.$emit('update:loading', false)
|
||||
this.$router.push({ name: 'webhooks' })
|
||||
},
|
||||
async deleteWebhook() {
|
||||
this.$emit('update:loading', true)
|
||||
this.$matomo && this.$matomo.trackPageView('stream/webhook/delete')
|
||||
|
||||
await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation webhookDelete($params: WebhookDeleteInput!) {
|
||||
webhookDelete(webhook: $params)
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
params: { id: this.webhookId, streamId: this.streamId }
|
||||
}
|
||||
})
|
||||
|
||||
this.$emit('refetch-webhooks')
|
||||
this.$emit('update:loading', false)
|
||||
this.$router.push({ name: 'webhooks' })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
query webhook($streamId: String!, $webhookId: String!) {
|
||||
stream(id: $streamId) {
|
||||
id
|
||||
webhooks(id: $webhookId) {
|
||||
items {
|
||||
id
|
||||
streamId
|
||||
url
|
||||
description
|
||||
triggers
|
||||
enabled
|
||||
history(limit: 1) {
|
||||
items {
|
||||
status
|
||||
statusInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
query webhooks($streamId: String!) {
|
||||
stream(id: $streamId) {
|
||||
id
|
||||
webhooks {
|
||||
items {
|
||||
id
|
||||
streamId
|
||||
url
|
||||
description
|
||||
triggers
|
||||
enabled
|
||||
history(limit: 1) {
|
||||
items {
|
||||
status
|
||||
statusInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,45 @@ const routes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/streams/:streamId/',
|
||||
name: 'settings',
|
||||
props: true,
|
||||
component: () => import('../views/settings/StreamSettings.vue'),
|
||||
children: [
|
||||
{
|
||||
path: 'general/',
|
||||
name: 'general',
|
||||
meta: {
|
||||
title: 'Stream Settings | Speckle'
|
||||
},
|
||||
props: true,
|
||||
component: () => import('../views/settings/SettingsGeneral.vue')
|
||||
},
|
||||
{
|
||||
path: 'webhooks/',
|
||||
name: 'webhooks',
|
||||
meta: {
|
||||
title: 'Webhooks | Speckle'
|
||||
},
|
||||
props: true,
|
||||
component: () => import('../views/settings/SettingsWebhooks.vue'),
|
||||
children: [
|
||||
{
|
||||
path: 'edit/:webhookId/',
|
||||
name: 'edit webhook',
|
||||
props: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'webhooks/new/',
|
||||
name: 'add webhook',
|
||||
props: true,
|
||||
component: () => import('../views/settings/SettingsWebhooks.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'profile',
|
||||
@@ -169,19 +208,18 @@ const routes = [
|
||||
component: () => import('../views/admin/AdminOverview.vue')
|
||||
},
|
||||
{
|
||||
name: "Admin | Users",
|
||||
path: "users",
|
||||
name: 'Admin | Users',
|
||||
path: 'users',
|
||||
component: () => import('../views/admin/AdminUsers.vue')
|
||||
|
||||
},
|
||||
{
|
||||
name: "Admin | Streams",
|
||||
path: "streams",
|
||||
name: 'Admin | Streams',
|
||||
path: 'streams',
|
||||
component: () => import('../views/admin/AdminStreams.vue')
|
||||
},
|
||||
{
|
||||
name: "Admin | Settings",
|
||||
path: "settings",
|
||||
name: 'Admin | Settings',
|
||||
path: 'settings',
|
||||
component: () => import('../views/admin/AdminSettings.vue')
|
||||
}
|
||||
],
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<admin-card :loading="loading" title="General">
|
||||
<v-card-text class="py-0 my-0">
|
||||
<v-form ref="form" v-model="valid" class="px-2" @submit.prevent="save">
|
||||
<v-text-field
|
||||
v-model="name"
|
||||
:rules="validation.nameRules"
|
||||
label="Name"
|
||||
hint="The name of this stream."
|
||||
/>
|
||||
<p class="subtitle-1">Description</p>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="12" md="6">
|
||||
<p class="caption">
|
||||
Use Markdown! Tips:
|
||||
<code>#, ##, ###</code>
|
||||
prefix headings, links:
|
||||
<code>[speckle](https://speckle.systems)</code>
|
||||
, images:
|
||||
<code></code>
|
||||
, list items are prefixed by
|
||||
<code>-</code>
|
||||
on new lines,
|
||||
<b>bold</b>
|
||||
text by surrounding it with
|
||||
<code>**</code>
|
||||
, etc.
|
||||
</p>
|
||||
<v-textarea
|
||||
v-model="description"
|
||||
auto-grow
|
||||
filled
|
||||
rows="10"
|
||||
style="font-size: 12px; line-height: 10px"
|
||||
></v-textarea>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="12" md="6">
|
||||
<p class="subtitle">Preview</p>
|
||||
<div class="marked-preview" v-html="compiledMarkdown"></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-switch
|
||||
v-model="isPublic"
|
||||
:label="isPublic ? 'Public' : 'Private'"
|
||||
:hint="
|
||||
isPublic
|
||||
? 'Anyone can view this stream. It is also visible on your profile page. Only collaborators can edit it.'
|
||||
: 'Only collaborators can access this stream.'
|
||||
"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider class="mt-4 mb-3" />
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn outlined color="success" type="submit" :disabled="!valid" @click="save">
|
||||
Save Changes
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</admin-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import marked from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import gql from 'graphql-tag'
|
||||
import streamQuery from '@/graphql/stream.gql'
|
||||
|
||||
export default {
|
||||
name: 'SettingsGeneral',
|
||||
components: {
|
||||
AdminCard: () => import('@/components/admin/AdminCard')
|
||||
},
|
||||
props: {
|
||||
userRole: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
apollo: {
|
||||
stream: {
|
||||
query: streamQuery,
|
||||
variables() {
|
||||
return {
|
||||
id: this.$attrs.streamId
|
||||
}
|
||||
},
|
||||
update(data) {
|
||||
let stream = data.stream
|
||||
if (stream)
|
||||
({ name: this.name, description: this.description, isPublic: this.isPublic } = stream)
|
||||
|
||||
return stream
|
||||
}
|
||||
}
|
||||
},
|
||||
data: () => ({
|
||||
loading: false,
|
||||
valid: false,
|
||||
name: null,
|
||||
description: null,
|
||||
isPublic: true,
|
||||
validation: {
|
||||
nameRules: [(v) => !!v || 'A stream must have a name!']
|
||||
}
|
||||
}),
|
||||
computed: {
|
||||
compiledMarkdown() {
|
||||
if (!this.description) return ''
|
||||
let md = marked(this.description)
|
||||
return DOMPurify.sanitize(md)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async save() {
|
||||
this.loading = true
|
||||
this.$matomo && this.$matomo.trackPageView('stream/update')
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: gql`
|
||||
mutation editDescription($input: StreamUpdateInput!) {
|
||||
streamUpdate(stream: $input)
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
id: this.stream.id,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
isPublic: this.isPublic
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
|
||||
this.$apollo.queries.stream.refetch()
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<admin-card v-if="selectedWebhook != undefined" :loading="loading" title="Edit Webhook">
|
||||
<template #subtitle>
|
||||
<v-icon dense class="text-subtitle-1 pr-1">mdi-webhook</v-icon>
|
||||
<code>{{ selectedWebhook.id }}</code>
|
||||
</template>
|
||||
<webhook-form
|
||||
:loading.sync="loading"
|
||||
:stream-id="$attrs.streamId"
|
||||
:webhook-id="selectedWebhook.id"
|
||||
@refetch-webhooks="refetchWebhooks"
|
||||
/>
|
||||
</admin-card>
|
||||
|
||||
<admin-card v-else-if="$route.name === 'add webhook'" :loading="loading" title="Add Webhook">
|
||||
<webhook-form
|
||||
:loading.sync="loading"
|
||||
:stream-id="$attrs.streamId"
|
||||
@refetch-webhooks="refetchWebhooks"
|
||||
/>
|
||||
</admin-card>
|
||||
|
||||
<admin-card v-else title="Webhooks">
|
||||
<template #menu>
|
||||
<v-btn
|
||||
small
|
||||
outlined
|
||||
color="primary"
|
||||
:to="`/settings/streams/${$attrs.streamId}/webhooks/new`"
|
||||
>
|
||||
Add Webhook
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-card-text v-if="webhooks.length == 0">
|
||||
You don't have any webhooks on this stream yet. Click the blue "Add Webhook" button in the top
|
||||
right to add one.
|
||||
</v-card-text>
|
||||
|
||||
<v-list subheader two-line>
|
||||
<v-list-item
|
||||
v-for="wh in webhooks"
|
||||
:key="wh.id"
|
||||
:to="`/settings/streams/${$attrs.streamId}/webhooks/edit/${wh.id}`"
|
||||
>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
<v-tooltip left>
|
||||
<template #activator="{ on }" class="ml-1">
|
||||
<v-icon class="pb-2 pr-1" small :color="wh.statusIcon.color" v-on="on">
|
||||
{{ wh.statusIcon.icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>{{ getStatusInfo(wh) }}</span>
|
||||
</v-tooltip>
|
||||
<span id="description">
|
||||
{{ wh.description ? wh.description : `webhook ${wh.id}` }}
|
||||
</span>
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ wh.url }}</v-list-item-subtitle>
|
||||
<v-list-item-subtitle>{{ `( ${wh.triggers.join(', ')} )` }}</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</admin-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import webhooksQuery from '../../graphql/webhooks.gql'
|
||||
export default {
|
||||
name: 'SettingsWebhooks',
|
||||
components: {
|
||||
AdminCard: () => import('@/components/admin/AdminCard'),
|
||||
WebhookForm: () => import('@/components/settings/WebhookForm')
|
||||
},
|
||||
props: {
|
||||
userRole: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
apollo: {
|
||||
webhooks: {
|
||||
query: webhooksQuery,
|
||||
variables() {
|
||||
return {
|
||||
streamId: this.$attrs.streamId
|
||||
}
|
||||
},
|
||||
update(data) {
|
||||
let webhooks = data.stream.webhooks.items
|
||||
webhooks.forEach((wh) => {
|
||||
wh.statusIcon = this.getStatusIcon(wh)
|
||||
})
|
||||
return webhooks
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedWebhook() {
|
||||
if (this.$apollo.loading || !this.$attrs.webhookId) return
|
||||
|
||||
return this.webhooks.find(({ id }) => id === this.$attrs.webhookId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getStatusIcon(webhook) {
|
||||
let status = 5 // default 5 if no events
|
||||
if (webhook.history.items.length) status = webhook.history.items[0].status
|
||||
switch (status) {
|
||||
case 0:
|
||||
case 1:
|
||||
return { color: 'amber', icon: 'mdi-alert-outline' }
|
||||
case 2:
|
||||
return { color: 'green', icon: 'mdi-check' }
|
||||
case 3:
|
||||
return { color: 'red', icon: 'mdi-close' }
|
||||
|
||||
default:
|
||||
return { color: 'blue-grey', icon: 'mdi-alert-circle-outline' }
|
||||
}
|
||||
},
|
||||
getStatusInfo(webhook) {
|
||||
if (!webhook.history.items.length) return 'No events yet'
|
||||
let msg = webhook.history.items[0].statusInfo
|
||||
|
||||
return msg
|
||||
},
|
||||
refetchWebhooks() {
|
||||
this.$apollo.queries.webhooks.refetch()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#description {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 300px;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<v-container v-if="isOwner">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="12" md="4" lg="3" xl="3" class="pt-md-10">
|
||||
<v-card id="sideMenu" elevation="1" class="rounded-lg overflow-hidden">
|
||||
<v-card-title class="tmr-8 display-1 text--secondary">
|
||||
{{ stream.name }}
|
||||
<br />
|
||||
<v-btn plain small class="mt-3 pa-0" :to="'/streams/' + stream.id">
|
||||
<v-icon small>mdi-chevron-left</v-icon>
|
||||
back to stream
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<div v-for="child in childRoutes" :key="child.to">
|
||||
<router-link v-slot="{ isActive, navigate }" :to="child.to">
|
||||
<v-hover v-slot="{ hover }">
|
||||
<span
|
||||
:class="{ 'active-border primary--text': isActive, 'primary--text': hover }"
|
||||
class="pa-2 pl-6 text-left d-flex menu-item bold"
|
||||
@click="navigate"
|
||||
>
|
||||
{{ child.name }}
|
||||
</span>
|
||||
</v-hover>
|
||||
</router-link>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="12" md="8" lg="9" xl="9" class="pt-md-10">
|
||||
<v-fade-transition mode="out-in">
|
||||
<router-view :user-role="userRole" />
|
||||
</v-fade-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<v-container v-else-if="!isOwner && !$apollo.loading">
|
||||
<v-card>
|
||||
<v-card-text class="text-center">
|
||||
<v-icon size="50" color="error">mdi-alert</v-icon>
|
||||
<h3>Sorry...but maybe you shouldn't be here!</h3>
|
||||
<p>
|
||||
Either this stream does not exist or you do not have the required permissions to edit this
|
||||
stream's settings.
|
||||
</p>
|
||||
<v-btn @click="$router.back()">Go back</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import streamQuery from '../../graphql/stream.gql'
|
||||
|
||||
export default {
|
||||
name: 'Settings',
|
||||
components: {},
|
||||
apollo: {
|
||||
stream: {
|
||||
query: streamQuery,
|
||||
variables() {
|
||||
return {
|
||||
id: this.$attrs.streamId
|
||||
}
|
||||
},
|
||||
error(err) {
|
||||
if (err.message) this.error = err.message.replace('GraphQL error: ', '')
|
||||
else this.error = err
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
childRoutes: [
|
||||
{
|
||||
name: 'General',
|
||||
to: `/settings/streams/${this.$attrs.streamId}/general`
|
||||
},
|
||||
{
|
||||
name: 'Webhooks',
|
||||
to: `/settings/streams/${this.$attrs.streamId}/webhooks`
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userRole() {
|
||||
let uuid = localStorage.getItem('uuid')
|
||||
if (!uuid) return null
|
||||
if (this.$apollo.loading) return null
|
||||
if (!this.stream) return null
|
||||
let contrib = this.stream.collaborators.find((u) => u.id === uuid)
|
||||
if (contrib) return contrib.role.split(':')[1]
|
||||
else return null
|
||||
},
|
||||
isOwner() {
|
||||
return this.userRole === 'owner'
|
||||
}
|
||||
},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.gray-border {
|
||||
border-top: 1pt solid var(--v-background-base) !important;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border-top: 1pt solid var(--v-background-base) !important;
|
||||
cursor: pointer;
|
||||
transition: 0.1s all ease-out, border-top-color 0s;
|
||||
|
||||
&::before {
|
||||
@include speckle-gradient-bg;
|
||||
|
||||
position: absolute;
|
||||
content: '';
|
||||
width: 0;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transition: all 0.1s ease-in-out, border-top-color 0s;
|
||||
}
|
||||
|
||||
&.active-border::before {
|
||||
width: 4pt;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@
|
||||
const appRoot = require( 'app-root-path' )
|
||||
const knex = require( `${appRoot}/db/knex` )
|
||||
|
||||
const { dispatchStreamEvent } = require( '../../webhooks/services/webhooks' )
|
||||
const StreamActivity = () => knex( 'stream_activity' )
|
||||
const StreamAcl = ( ) => knex( 'stream_acl' )
|
||||
|
||||
@@ -19,6 +20,18 @@ module.exports = {
|
||||
message
|
||||
}
|
||||
await StreamActivity( ).insert( dbObject )
|
||||
if ( streamId ) {
|
||||
let webhooksPayload = {
|
||||
streamId: streamId,
|
||||
userId: userId,
|
||||
activityMessage: message,
|
||||
event: {
|
||||
'event_name': actionType,
|
||||
'data': info
|
||||
}
|
||||
}
|
||||
dispatchStreamEvent( { streamId, event: actionType, eventPayload: webhooksPayload } )
|
||||
}
|
||||
},
|
||||
|
||||
async getStreamActivity( { streamId, actionType, after, before, limit } ) {
|
||||
|
||||
@@ -86,7 +86,7 @@ module.exports = {
|
||||
resourceId: id,
|
||||
actionType: 'commit_create',
|
||||
userId: context.userId,
|
||||
info: { commit: args.commit },
|
||||
info: { id: id, commit: args.commit },
|
||||
message: `Commit created on branch ${args.commit.branchName}: ${id} (${args.commit.message})`
|
||||
} )
|
||||
await pubsub.publish( COMMIT_CREATED, {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
const appRoot = require( 'app-root-path' )
|
||||
const { ForbiddenError } = require( 'apollo-server-express' )
|
||||
|
||||
const { authorizeResolver } = require( `${appRoot}/modules/shared` )
|
||||
const { createWebhook, getWebhook, updateWebhook, deleteWebhook, getStreamWebhooks, getLastWebhookEvents, getWebhookEventsCount } = require( '../../services/webhooks' )
|
||||
|
||||
|
||||
module.exports = {
|
||||
Stream: {
|
||||
async webhooks( parent, args, context, info ) {
|
||||
await authorizeResolver( context.userId, parent.id, 'stream:owner' )
|
||||
|
||||
if ( args.id ) {
|
||||
let wh = await getWebhook( { id: args.id } )
|
||||
let items = wh ? [ wh ] : []
|
||||
return { items, totalCount: items.length }
|
||||
}
|
||||
|
||||
let items = await getStreamWebhooks( { streamId: parent.id } )
|
||||
return { items, totalCount: items.length }
|
||||
}
|
||||
},
|
||||
|
||||
Webhook: {
|
||||
async history( parent, args, context, info ) {
|
||||
let items = await getLastWebhookEvents( { webhookId: parent.id, limit: args.limit } )
|
||||
let totalCount = await getWebhookEventsCount( { webhookId: parent.id } )
|
||||
|
||||
return { items, totalCount }
|
||||
}
|
||||
},
|
||||
|
||||
Mutation: {
|
||||
async webhookCreate( parent, args, context, info ) {
|
||||
await authorizeResolver( context.userId, args.webhook.streamId, 'stream:owner' )
|
||||
|
||||
let id = await createWebhook( { streamId: args.webhook.streamId, url: args.webhook.url, description: args.webhook.description, secret: args.webhook.secret, enabled: args.webhook.enabled !== false, triggers: args.webhook.triggers } )
|
||||
|
||||
return id
|
||||
},
|
||||
async webhookUpdate( parent, args, context, info ) {
|
||||
await authorizeResolver( context.userId, args.webhook.streamId, 'stream:owner' )
|
||||
|
||||
let wh = await getWebhook( { id: args.webhook.id } )
|
||||
if ( args.webhook.streamId !== wh.streamId )
|
||||
throw new ForbiddenError( 'The webhook id and stream id do not match. Please check your inputs.' )
|
||||
|
||||
let updated = await updateWebhook( { id: args.webhook.id, url: args.webhook.url, description: args.webhook.description, secret: args.webhook.secret, enabled: args.webhook.enabled !== false, triggers: args.webhook.triggers } )
|
||||
|
||||
return !!updated
|
||||
},
|
||||
async webhookDelete( parent, args, context, info ) {
|
||||
await authorizeResolver( context.userId, args.webhook.streamId, 'stream:owner' )
|
||||
|
||||
let wh = await getWebhook( { id: args.webhook.id } )
|
||||
if ( args.webhook.streamId !== wh.streamId )
|
||||
throw new ForbiddenError( 'The webhook id and stream id do not match. Please check your inputs.' )
|
||||
|
||||
let deleted = await deleteWebhook( { id: args.webhook.id } )
|
||||
|
||||
return !!deleted
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
extend type Stream {
|
||||
webhooks(id: String): WebhookCollection
|
||||
@hasRole(role: "server:user")
|
||||
@hasScope(scope: "streams:write")
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
"""
|
||||
Creates a new webhook on a stream
|
||||
"""
|
||||
webhookCreate(webhook: WebhookCreateInput!): String!
|
||||
@hasRole(role: "server:user")
|
||||
@hasScope(scope: "streams:write")
|
||||
|
||||
"""
|
||||
Updates an existing webhook
|
||||
"""
|
||||
webhookUpdate(webhook: WebhookUpdateInput!): String!
|
||||
@hasRole(role: "server:user")
|
||||
@hasScope(scope: "streams:write")
|
||||
|
||||
"""
|
||||
Deletes an existing webhook
|
||||
"""
|
||||
webhookDelete(webhook: WebhookDeleteInput!): String!
|
||||
@hasRole(role: "server:user")
|
||||
@hasScope(scope: "streams:write")
|
||||
}
|
||||
|
||||
type WebhookCollection {
|
||||
totalCount: Int
|
||||
items: [Webhook]
|
||||
}
|
||||
|
||||
type Webhook {
|
||||
id: String!
|
||||
streamId: String!
|
||||
url: String!
|
||||
description: String
|
||||
triggers: [String]!
|
||||
enabled: Boolean
|
||||
history(limit: Int! = 25): WebhookEventCollection
|
||||
}
|
||||
|
||||
input WebhookCreateInput {
|
||||
streamId: String!
|
||||
url: String!
|
||||
description: String
|
||||
triggers: [String]!
|
||||
secret: String
|
||||
enabled: Boolean
|
||||
}
|
||||
|
||||
input WebhookUpdateInput {
|
||||
id: String!
|
||||
streamId: String!
|
||||
url: String
|
||||
description: String
|
||||
secret: String
|
||||
enabled: Boolean
|
||||
triggers: [String]
|
||||
}
|
||||
|
||||
input WebhookDeleteInput {
|
||||
id: String!
|
||||
streamId: String!
|
||||
}
|
||||
|
||||
type WebhookEventCollection{
|
||||
totalCount: Int
|
||||
items: [WebhookEvent]
|
||||
}
|
||||
|
||||
type WebhookEvent {
|
||||
id: String!
|
||||
webhookId: String!
|
||||
status: Int!
|
||||
statusInfo: String!
|
||||
retryCount: Int!
|
||||
lastUpdate: DateTime!
|
||||
payload: String!
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* istanbul ignore file */
|
||||
'use strict'
|
||||
|
||||
exports.up = async knex => {
|
||||
await knex.schema.createTable( 'webhooks_config', table => {
|
||||
table.string( 'id' ).primary( )
|
||||
table.string( 'streamId', 10 ).references( 'id' ).inTable( 'streams' ).onDelete( 'cascade' )
|
||||
table.text( 'url' )
|
||||
table.text( 'description' )
|
||||
table.jsonb( 'triggers' )
|
||||
table.string( 'secret' )
|
||||
table.boolean( 'enabled' ).defaultTo( true )
|
||||
|
||||
table.index( 'streamId' )
|
||||
} )
|
||||
|
||||
await knex.schema.createTable( 'webhooks_events', table => {
|
||||
table.string( 'id' ).primary( )
|
||||
table.string( 'webhookId' ).references( 'id' ).inTable( 'webhooks_config' ).onDelete( 'cascade' )
|
||||
|
||||
table.integer( 'status' ).notNullable( ).defaultTo( 0 )
|
||||
table.text( 'statusInfo' ).notNullable( ).defaultTo( 'Pending' )
|
||||
|
||||
table.timestamp( 'lastUpdate' ).notNullable( ).defaultTo( knex.fn.now( ) )
|
||||
|
||||
table.text( 'payload' )
|
||||
|
||||
table.index( 'webhookId' )
|
||||
table.index( 'status' )
|
||||
} )
|
||||
}
|
||||
|
||||
exports.down = async knex => {
|
||||
await knex.schema.dropTableIfExists( 'webhooks_events' )
|
||||
await knex.schema.dropTableIfExists( 'webhooks_config' )
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use strict'
|
||||
|
||||
const appRoot = require( 'app-root-path' )
|
||||
const knex = require( `${appRoot}/db/knex` )
|
||||
const crs = require( 'crypto-random-string' )
|
||||
|
||||
const WebhooksConfig = ( ) => knex( 'webhooks_config' )
|
||||
const WebhooksEvents = ( ) => knex( 'webhooks_events' )
|
||||
const Users = ( ) => knex( 'users' )
|
||||
|
||||
const { getServerInfo } = require( '../../core/services/generic' )
|
||||
const { getStream } = require( '../../core/services/streams' )
|
||||
|
||||
let MAX_STREAM_WEBHOOKS = 100
|
||||
|
||||
module.exports = {
|
||||
|
||||
async createWebhook( { streamId, url, description, secret, enabled, triggers } ) {
|
||||
let streamWebhookCount = await module.exports.getStreamWebhooksCount( { streamId } )
|
||||
if ( streamWebhookCount >= MAX_STREAM_WEBHOOKS ) {
|
||||
throw new Error( `Maximum number of webhooks for a stream reached (${MAX_STREAM_WEBHOOKS})` )
|
||||
}
|
||||
|
||||
let triggersObj = Object.assign( {}, ...triggers.map( ( x ) => ( { [ x ]: true } ) ) )
|
||||
|
||||
let [ id ] = await WebhooksConfig( ).returning( 'id' ).insert( {
|
||||
id: crs( { length: 10 } ),
|
||||
streamId,
|
||||
url,
|
||||
description,
|
||||
secret,
|
||||
enabled,
|
||||
triggers: triggersObj
|
||||
} )
|
||||
return id
|
||||
},
|
||||
|
||||
async getWebhook( { id } ) {
|
||||
let webhook = await WebhooksConfig().select( '*' ).where( { id } ).first()
|
||||
if ( webhook ) {
|
||||
webhook.triggers = Object.keys( webhook.triggers )
|
||||
}
|
||||
|
||||
return webhook
|
||||
},
|
||||
|
||||
async updateWebhook( { id, url, description, secret, enabled, triggers } ) {
|
||||
let fieldsToUpdate = {}
|
||||
if ( url !== undefined ) fieldsToUpdate.url = url
|
||||
if ( description !== undefined ) fieldsToUpdate.description = description
|
||||
if ( secret !== undefined ) fieldsToUpdate.secret = secret
|
||||
if ( enabled !== undefined ) fieldsToUpdate.enabled = enabled
|
||||
if ( triggers !== undefined ) {
|
||||
let triggersObj = Object.assign( {}, ...triggers.map( ( x ) => ( { [ x ]: true } ) ) )
|
||||
fieldsToUpdate.triggers = triggersObj
|
||||
}
|
||||
|
||||
let [ res ] = await WebhooksConfig( )
|
||||
.returning( 'id' )
|
||||
.where( { id } )
|
||||
.update( fieldsToUpdate )
|
||||
return res
|
||||
},
|
||||
|
||||
async deleteWebhook( { id } ) {
|
||||
return await WebhooksConfig( ).where( { id } ).del( )
|
||||
},
|
||||
|
||||
async getStreamWebhooks( { streamId } ) {
|
||||
let webhooks = await WebhooksConfig( ).select( '*' ).where( { streamId } )
|
||||
for ( let webhook of webhooks ) {
|
||||
webhook.triggers = Object.keys( webhook.triggers )
|
||||
}
|
||||
|
||||
return webhooks
|
||||
},
|
||||
|
||||
async getStreamWebhooksCount( { streamId } ) {
|
||||
let [ res ] = await WebhooksConfig( ).count().where( { streamId } )
|
||||
return parseInt( res.count )
|
||||
},
|
||||
|
||||
async dispatchStreamEvent( { streamId, event, eventPayload } ) {
|
||||
// Add server info
|
||||
eventPayload.server = await getServerInfo()
|
||||
eventPayload.server.canonicalUrl = process.env.CANONICAL_URL
|
||||
delete eventPayload.server.id
|
||||
|
||||
// Add stream info
|
||||
if ( eventPayload.streamId ) {
|
||||
eventPayload.stream = await getStream( { streamId: eventPayload.streamId, userId: eventPayload.userId } )
|
||||
}
|
||||
|
||||
// Add user info (except email and pwd)
|
||||
if ( eventPayload.userId ) {
|
||||
eventPayload.user = await Users( ).where( { id: eventPayload.userId } ).select( '*' ).first( )
|
||||
if ( eventPayload.user ) {
|
||||
delete eventPayload.user.passwordDigest
|
||||
delete eventPayload.user.email
|
||||
}
|
||||
}
|
||||
|
||||
let { rows } = await knex.raw( `
|
||||
SELECT * FROM webhooks_config WHERE "streamId" = ?
|
||||
`, [ streamId ] )
|
||||
for ( let wh of rows ) {
|
||||
if ( !wh.enabled )
|
||||
continue
|
||||
if ( !( event in wh.triggers ) )
|
||||
continue
|
||||
|
||||
// Add webhook info (the key `webhook` will be replaced for each webhook configured, before serializing the payload and storing it)
|
||||
eventPayload.webhook = wh
|
||||
eventPayload.webhook.triggers = Object.keys( eventPayload.webhook.triggers )
|
||||
delete eventPayload.webhook.secret
|
||||
|
||||
await WebhooksEvents( ).insert( {
|
||||
id: crs( { length: 20 } ),
|
||||
webhookId: wh.id,
|
||||
payload: JSON.stringify( eventPayload )
|
||||
} )
|
||||
}
|
||||
},
|
||||
|
||||
async getLastWebhookEvents( { webhookId, limit } ) {
|
||||
if ( !limit ) {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
return await WebhooksEvents( ).select( '*' ).where( { webhookId } ).orderBy( 'lastUpdate', 'desc' ).limit( limit )
|
||||
},
|
||||
|
||||
async getWebhookEventsCount( { webhookId } ) {
|
||||
let [ res ] = await WebhooksEvents().count().where( { webhookId } )
|
||||
|
||||
return parseInt( res.count )
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/* istanbul ignore file */
|
||||
const chai = require( 'chai' )
|
||||
const chaiHttp = require( 'chai-http' )
|
||||
const assert = require( 'assert' )
|
||||
|
||||
const appRoot = require( 'app-root-path' )
|
||||
const { init, startHttp } = require( `${appRoot}/app` )
|
||||
const knex = require( `${appRoot}/db/knex` )
|
||||
const { createPersonalAccessToken } = require( '../../core/services/tokens' )
|
||||
const { createWebhook, getStreamWebhooks, getLastWebhookEvents, getWebhook, updateWebhook, deleteWebhook, dispatchStreamEvent } = require( '../services/webhooks' )
|
||||
const { createUser } = require( '../../core/services/users' )
|
||||
const { createStream, getStream, grantPermissionsStream } = require( '../../core/services/streams' )
|
||||
|
||||
const expect = chai.expect
|
||||
chai.use( chaiHttp )
|
||||
|
||||
const port = 3420
|
||||
const serverAddress = `http://localhost:${port}`
|
||||
|
||||
|
||||
describe( 'Webhooks @webhooks', () => {
|
||||
let testServer
|
||||
|
||||
let userOne = {
|
||||
name: 'User',
|
||||
email: 'user@gmail.com',
|
||||
password: 'jdsadjsadasfdsa'
|
||||
}
|
||||
|
||||
let streamOne = {
|
||||
name: 'streamOne',
|
||||
description: 'stream',
|
||||
isPublic: true
|
||||
}
|
||||
|
||||
let webhookOne = {
|
||||
streamId: null, // filled in `before`
|
||||
url: 'http://localhost:42/non-existent',
|
||||
description: 'test wh',
|
||||
secret: 'secret',
|
||||
enabled: true,
|
||||
triggers: [ 'commit_create', 'commit_update' ]
|
||||
}
|
||||
|
||||
before( async ( ) => {
|
||||
await knex.migrate.rollback( )
|
||||
await knex.migrate.latest( )
|
||||
let { app } = await init()
|
||||
let { server } = await startHttp( app, port )
|
||||
testServer = server
|
||||
|
||||
userOne.id = await createUser( userOne )
|
||||
streamOne.ownerId = userOne.id
|
||||
streamOne.id = await createStream( streamOne )
|
||||
|
||||
webhookOne.streamId = streamOne.id
|
||||
} )
|
||||
|
||||
after( async ( ) => {
|
||||
// await knex.migrate.rollback( )
|
||||
testServer.close( )
|
||||
} )
|
||||
|
||||
describe( 'Create, Read, Update, Delete Webhooks', ( ) => {
|
||||
it( 'Should create a webhook', async ( ) => {
|
||||
webhookOne.id = await createWebhook( webhookOne )
|
||||
expect( webhookOne ).to.have.property( 'id' )
|
||||
expect( webhookOne.id ).to.not.be.null
|
||||
} )
|
||||
|
||||
it( 'Should get a webhook', async ( ) => {
|
||||
let webhook = await getWebhook( { id: webhookOne.id } )
|
||||
expect( webhook ).to.not.be.null
|
||||
expect( webhook ).to.have.property( 'url' )
|
||||
expect( webhook.url ).to.equal( webhookOne.url )
|
||||
} )
|
||||
|
||||
it( 'Should update a webhook', async ( ) => {
|
||||
let newUrl = 'http://localhost:42/new-url'
|
||||
await updateWebhook( { id: webhookOne.id, url: newUrl } )
|
||||
let webhook = await getWebhook( { id: webhookOne.id } )
|
||||
expect( webhook ).to.not.be.null
|
||||
expect( webhook ).to.have.property( 'url' )
|
||||
expect( webhook.url ).to.equal( newUrl )
|
||||
} )
|
||||
|
||||
it( 'Should delete a webhook', async ( ) => {
|
||||
await deleteWebhook( { id: webhookOne.id } )
|
||||
let webhook = await getWebhook( { id: webhookOne.id } )
|
||||
expect( webhook ).to.be.undefined
|
||||
} )
|
||||
|
||||
it( 'Should get webhooks for stream', async ( ) => {
|
||||
let streamWebhooks = await getStreamWebhooks( { streamId: streamOne.id } )
|
||||
expect( streamWebhooks ).to.have.lengthOf( 0 )
|
||||
|
||||
webhookOne.id = await createWebhook( webhookOne )
|
||||
streamWebhooks = await getStreamWebhooks( { streamId: streamOne.id } )
|
||||
expect( streamWebhooks ).to.have.lengthOf( 1 )
|
||||
expect( streamWebhooks[ 0 ] ).to.have.property( 'url' )
|
||||
expect( streamWebhooks[ 0 ].url ).to.equal( webhookOne.url )
|
||||
} )
|
||||
|
||||
it( 'Should dispatch and get events', async () => {
|
||||
await dispatchStreamEvent( { streamId: streamOne.id, event: 'commit_create', eventPayload: { test: 'payload123' } } )
|
||||
let lastEvents = await getLastWebhookEvents( { webhookId: webhookOne.id } )
|
||||
expect( lastEvents ).to.have.lengthOf( 1 )
|
||||
expect( JSON.parse( lastEvents[ 0 ].payload ).test ).to.equal( 'payload123' )
|
||||
} )
|
||||
} )
|
||||
|
||||
describe( 'GraphQL API Webhooks @webhooks-api', () => {
|
||||
let userTwo = {
|
||||
name: 'User2',
|
||||
email: 'user2@gmail.com',
|
||||
password: 'jdsadjsadasfdsa'
|
||||
}
|
||||
|
||||
let webhookTwo = {
|
||||
streamId: null,
|
||||
url: 'http://localhost:42/non-existent-two',
|
||||
description: 'test wh no 2',
|
||||
secret: 'secret',
|
||||
enabled: true,
|
||||
triggers: [ 'commit_create', 'commit_update' ]
|
||||
}
|
||||
|
||||
let streamTwo = {
|
||||
name: 'streamTwo',
|
||||
description: 'stream',
|
||||
isPublic: true
|
||||
}
|
||||
|
||||
before( async () => {
|
||||
userTwo.id = await createUser( userTwo )
|
||||
streamTwo.ownerId = userTwo.id
|
||||
streamTwo.id = await createStream( streamTwo )
|
||||
webhookTwo.streamId = streamTwo.id
|
||||
|
||||
|
||||
userOne.token = `Bearer ${( await createPersonalAccessToken( userOne.id, 'userOne test token', [ 'streams:read', 'streams:write' ] ) )}`
|
||||
userTwo.token = `Bearer ${( await createPersonalAccessToken( userTwo.id, 'userTwo test token', [ 'streams:read', 'streams:write' ] ) )}`
|
||||
await grantPermissionsStream( { streamId: streamTwo.id, userId: userOne.id, role: 'stream:contributor' } )
|
||||
} )
|
||||
|
||||
it( 'Should create a webhook', async () => {
|
||||
const res = await sendRequest( userTwo.token, { query: 'mutation createWebhook($webhook: WebhookCreateInput!) { webhookCreate( webhook: $webhook ) }', variables: { webhook: webhookTwo } } )
|
||||
expect( noErrors( res ) )
|
||||
expect( res.body.data.webhookCreate ).to.not.be.null
|
||||
webhookTwo.id = res.body.data.webhookCreate
|
||||
} )
|
||||
|
||||
it( 'Should get stream webhooks and the previous events', async () => {
|
||||
await dispatchStreamEvent( { streamId: streamTwo.id, event: 'commit_create', eventPayload: { test: 'payload321' } } )
|
||||
const res = await sendRequest( userTwo.token, { query: `query {
|
||||
stream(id: "${streamTwo.id}") {
|
||||
webhooks { totalCount items { id url enabled
|
||||
history { totalCount items { status statusInfo payload } } }
|
||||
}
|
||||
}
|
||||
}` } )
|
||||
expect( noErrors( res ) )
|
||||
let webhooks = res.body.data.stream.webhooks
|
||||
|
||||
expect( webhooks.totalCount ).to.equal( 1 )
|
||||
expect( webhooks.items[ 0 ].url ).to.equal( webhookTwo.url )
|
||||
expect( webhooks.items[ 0 ].history.totalCount ).to.equal( 1 )
|
||||
expect( JSON.parse( webhooks.items[ 0 ].history.items[ 0 ].payload ).test ).to.equal( 'payload321' )
|
||||
} )
|
||||
|
||||
it( 'Should update a webhook', async () => {
|
||||
const res = await sendRequest( userTwo.token, {
|
||||
query: `mutation { webhookUpdate(webhook: { id: "${webhookTwo.id}", streamId: "${streamTwo.id}", description: "updated webhook", enabled: false })
|
||||
}` } )
|
||||
let webhook = await getWebhook( { id: webhookTwo.id } )
|
||||
expect( noErrors( res ) )
|
||||
expect( res.body.data.webhookUpdate ).to.equal( 'true' )
|
||||
expect( webhook.description ).to.equal( 'updated webhook' )
|
||||
expect( webhook.enabled ).to.equal( false )
|
||||
} )
|
||||
|
||||
it( 'Should *not* update or delete a webhook if the stream id and webhook id do not match', async () => {
|
||||
const res1 = await sendRequest( userOne.token, {
|
||||
query: `mutation { webhookDelete(webhook: { id: "${webhookTwo.id}", streamId: "${streamOne.id}" } ) }`
|
||||
} )
|
||||
expect( res1.body.errors ).to.exist
|
||||
expect( res1.body.errors[ 0 ].message ).to.equal( 'The webhook id and stream id do not match. Please check your inputs.' )
|
||||
expect( res1.body.errors[ 0 ].extensions.code ).to.equal( 'FORBIDDEN' )
|
||||
|
||||
const res2 = await sendRequest( userOne.token, {
|
||||
query: `mutation { webhookUpdate(webhook: { id: "${webhookTwo.id}", streamId: "${streamOne.id}", description: "updated webhook", enabled: false }) }`
|
||||
} )
|
||||
expect( res2.body.errors ).to.exist
|
||||
expect( res2.body.errors[ 0 ].message ).to.equal( 'The webhook id and stream id do not match. Please check your inputs.' )
|
||||
expect( res2.body.errors[ 0 ].extensions.code ).to.equal( 'FORBIDDEN' )
|
||||
} )
|
||||
|
||||
it( 'Should delete a webhook', async () => {
|
||||
const res = await sendRequest( userTwo.token, {
|
||||
query: `mutation { webhookDelete(webhook: { id: "${webhookTwo.id}", streamId: "${streamTwo.id}" } ) }`
|
||||
} )
|
||||
expect( noErrors( res ) )
|
||||
expect( res.body.data.webhookDelete ).to.equal( 'true' )
|
||||
} )
|
||||
|
||||
it( 'Should *not* create a webhook if user is not a stream owner', async () => {
|
||||
delete webhookTwo.id
|
||||
const res = await sendRequest( userOne.token, {
|
||||
query: 'mutation createWebhook($webhook: WebhookCreateInput!) { webhookCreate( webhook: $webhook ) }',
|
||||
variables: { webhook: webhookTwo }
|
||||
} )
|
||||
expect( res.body.errors ).to.exist
|
||||
expect( res.body.errors[ 0 ].extensions.code ).to.equal( 'FORBIDDEN' )
|
||||
} )
|
||||
|
||||
it( 'Should *not* get a webhook if the user is not a stream owner', async () => {
|
||||
const res = await sendRequest( userOne.token, { query: `query {
|
||||
stream(id: "${streamTwo.id}") { webhooks { totalCount items { id url enabled } } }
|
||||
}` } )
|
||||
expect( res.body.errors ).to.exist
|
||||
expect( res.body.errors[ 0 ].extensions.code ).to.equal( 'FORBIDDEN' )
|
||||
} )
|
||||
|
||||
|
||||
it( 'Should have a webhook limit for streams', async ( ) => {
|
||||
let limit = 100
|
||||
for ( let i = 0; i < limit - 1; i++ ) {
|
||||
await createWebhook( webhookOne )
|
||||
}
|
||||
|
||||
try {
|
||||
await createWebhook( webhookOne )
|
||||
} catch ( err ) {
|
||||
if ( err.toString().indexOf( 'Maximum' ) > -1 ) return
|
||||
}
|
||||
|
||||
assert.fail( 'Configured more webhooks than the limit' )
|
||||
} )
|
||||
|
||||
it( 'Should cleanup stream webhooks', async ( ) => {
|
||||
// just cleanup the 99 extra webhooks added before (not a real test)
|
||||
let streamWebhooks = await getStreamWebhooks( { streamId: streamOne.id } )
|
||||
for ( let webhook of streamWebhooks ) {
|
||||
if ( webhook.id != webhookOne.id ) {
|
||||
await deleteWebhook( { id: webhook.id } )
|
||||
}
|
||||
}
|
||||
|
||||
streamWebhooks = await getStreamWebhooks( { streamId: streamOne.id } )
|
||||
expect( streamWebhooks ).to.have.lengthOf( 1 )
|
||||
expect( streamWebhooks[ 0 ] ).to.have.property( 'id' )
|
||||
expect( streamWebhooks[ 0 ].id ).to.equal( webhookOne.id )
|
||||
} )
|
||||
} )
|
||||
} )
|
||||
|
||||
|
||||
/**
|
||||
* Sends a graphql request. Convenience wrapper.
|
||||
* @param {string} auth the user's token
|
||||
* @param {string} obj the query/mutation to send
|
||||
* @return {Promise} the awaitable request
|
||||
*/
|
||||
function sendRequest( auth, obj, address = serverAddress ) {
|
||||
return chai.request( address ).post( '/graphql' ).set( 'Authorization', auth ).send( obj )
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the response body for errors. To be used in expect assertions.
|
||||
* Will throw an error if 'errors' exist.
|
||||
* @param {*} res
|
||||
*/
|
||||
function noErrors( res ) {
|
||||
if ( 'errors' in res.body ) throw new Error( `Failed GraphQL request: ${res.body.errors[ 0 ].message}` )
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"commonjs": true,
|
||||
"es2020": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 11
|
||||
},
|
||||
"ignorePatterns": ["node_modules/*"],
|
||||
"rules": {
|
||||
"arrow-spacing": [
|
||||
2,
|
||||
{
|
||||
"before": true,
|
||||
"after": true
|
||||
}
|
||||
],
|
||||
"array-bracket-spacing": [2, "always"],
|
||||
"object-curly-spacing": [1, "always"],
|
||||
"block-spacing": [2, "always"],
|
||||
"camelcase": [
|
||||
1,
|
||||
{
|
||||
"properties": "always"
|
||||
}
|
||||
],
|
||||
"space-in-parens": [2, "always"],
|
||||
"keyword-spacing": 2,
|
||||
"semi": [1, "never"],
|
||||
"quotes": [1, "single"],
|
||||
"indent": ["error", 2],
|
||||
"space-unary-ops": [
|
||||
2,
|
||||
{
|
||||
"words": true,
|
||||
"nonwords": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM node:14.16.0-buster-slim as node
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.8.0/wait /wait
|
||||
RUN chmod +x /wait
|
||||
|
||||
ARG NODE_ENV=production
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY packages/webhook-service/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY packages/webhook-service/src .
|
||||
|
||||
ENTRYPOINT [ "tini", "--" ]
|
||||
CMD ["node", "main.js"]
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
{
|
||||
"name": "@speckle/webhook-service",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
"buffer-writer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz",
|
||||
"integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw=="
|
||||
},
|
||||
"colorette": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz",
|
||||
"integrity": "sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw=="
|
||||
},
|
||||
"commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
|
||||
"integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
|
||||
"requires": {
|
||||
"ms": "2.1.2"
|
||||
}
|
||||
},
|
||||
"escalade": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
|
||||
"integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw=="
|
||||
},
|
||||
"esm": {
|
||||
"version": "3.2.25",
|
||||
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
|
||||
"integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA=="
|
||||
},
|
||||
"function-bind": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
||||
},
|
||||
"getopts": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/getopts/-/getopts-2.2.5.tgz",
|
||||
"integrity": "sha512-9jb7AW5p3in+IiJWhQiZmmwkpLaR/ccTWdWQCtZM66HJcHHLegowh4q4tSD7gouUyeNvFWRavfK9GXosQHDpFA=="
|
||||
},
|
||||
"has": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
|
||||
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
|
||||
"requires": {
|
||||
"function-bind": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"interpret": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
|
||||
"integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw=="
|
||||
},
|
||||
"is-core-module": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz",
|
||||
"integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==",
|
||||
"requires": {
|
||||
"has": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"knex": {
|
||||
"version": "0.95.7",
|
||||
"resolved": "https://registry.npmjs.org/knex/-/knex-0.95.7.tgz",
|
||||
"integrity": "sha512-J2X79td0NAcreTyWVmmHHretz5Ox705FHywddjkT3esTtmggphjcfDoaXym18xtsLdjzOvEb53WB/58lqcF14w==",
|
||||
"requires": {
|
||||
"colorette": "1.2.1",
|
||||
"commander": "^7.1.0",
|
||||
"debug": "4.3.2",
|
||||
"escalade": "^3.1.1",
|
||||
"esm": "^3.2.25",
|
||||
"getopts": "2.2.5",
|
||||
"interpret": "^2.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"pg-connection-string": "2.5.0",
|
||||
"rechoir": "^0.7.0",
|
||||
"resolve-from": "^5.0.0",
|
||||
"tarn": "^3.0.1",
|
||||
"tildify": "2.0.0"
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
},
|
||||
"node-fetch": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
|
||||
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw=="
|
||||
},
|
||||
"packet-reader": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
|
||||
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
|
||||
},
|
||||
"path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
|
||||
},
|
||||
"pg": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.6.0.tgz",
|
||||
"integrity": "sha512-qNS9u61lqljTDFvmk/N66EeGq3n6Ujzj0FFyNMGQr6XuEv4tgNTXvJQTfJdcvGit5p5/DWPu+wj920hAJFI+QQ==",
|
||||
"requires": {
|
||||
"buffer-writer": "2.0.0",
|
||||
"packet-reader": "1.0.0",
|
||||
"pg-connection-string": "^2.5.0",
|
||||
"pg-pool": "^3.3.0",
|
||||
"pg-protocol": "^1.5.0",
|
||||
"pg-types": "^2.1.0",
|
||||
"pgpass": "1.x"
|
||||
}
|
||||
},
|
||||
"pg-connection-string": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz",
|
||||
"integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ=="
|
||||
},
|
||||
"pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="
|
||||
},
|
||||
"pg-pool": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.3.0.tgz",
|
||||
"integrity": "sha512-0O5huCql8/D6PIRFAlmccjphLYWC+JIzvUhSzXSpGaf+tjTZc4nn+Lr7mLXBbFJfvwbP0ywDv73EiaBsxn7zdg=="
|
||||
},
|
||||
"pg-protocol": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.5.0.tgz",
|
||||
"integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ=="
|
||||
},
|
||||
"pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"requires": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"pgpass": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.4.tgz",
|
||||
"integrity": "sha512-YmuA56alyBq7M59vxVBfPJrGSozru8QAdoNlWuW3cz8l+UX3cWge0vTvjKhsSHSJpo3Bom8/Mm6hf0TR5GY0+w==",
|
||||
"requires": {
|
||||
"split2": "^3.1.1"
|
||||
}
|
||||
},
|
||||
"postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="
|
||||
},
|
||||
"postgres-bytea": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||
"integrity": "sha1-AntTPAqokOJtFy1Hz5zOzFIazTU="
|
||||
},
|
||||
"postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="
|
||||
},
|
||||
"postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"requires": {
|
||||
"xtend": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"readable-stream": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
|
||||
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
|
||||
"requires": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"rechoir": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.0.tgz",
|
||||
"integrity": "sha512-ADsDEH2bvbjltXEP+hTIAmeFekTFK0V2BTxMkok6qILyAJEXV0AFfoWcAq4yfll5VdIMd/RVXq0lR+wQi5ZU3Q==",
|
||||
"requires": {
|
||||
"resolve": "^1.9.0"
|
||||
}
|
||||
},
|
||||
"resolve": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
|
||||
"integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
|
||||
"requires": {
|
||||
"is-core-module": "^2.2.0",
|
||||
"path-parse": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"resolve-from": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
|
||||
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="
|
||||
},
|
||||
"safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
|
||||
},
|
||||
"split2": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz",
|
||||
"integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==",
|
||||
"requires": {
|
||||
"readable-stream": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"requires": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"tarn": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.1.tgz",
|
||||
"integrity": "sha512-6usSlV9KyHsspvwu2duKH+FMUhqJnAh6J5J/4MITl8s94iSUQTLkJggdiewKv4RyARQccnigV48Z+khiuVZDJw=="
|
||||
},
|
||||
"tildify": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz",
|
||||
"integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw=="
|
||||
},
|
||||
"util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
|
||||
},
|
||||
"xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@speckle/webhook-service",
|
||||
"version": "2.0.0",
|
||||
"description": "Component to handle calling external webhooks",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/specklesystems/speckle-server.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/specklesystems/speckle-server/issues"
|
||||
},
|
||||
"homepage": "https://github.com/specklesystems/speckle-server#readme",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "node src/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"knex": "^0.95.7",
|
||||
"node-fetch": "^2.6.1",
|
||||
"pg": "^8.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
'use strict'
|
||||
|
||||
module.exports = require( 'knex' )( {
|
||||
client: 'pg',
|
||||
connection: process.env.PG_CONNECTION_STRING || 'postgres://speckle:speckle@localhost/speckle',
|
||||
pool: { min: 1, max: 1 }
|
||||
// migrations are in managed in the server package
|
||||
} )
|
||||
@@ -0,0 +1,103 @@
|
||||
'use strict'
|
||||
|
||||
const crypto = require( 'crypto' )
|
||||
const knex = require( './knex' )
|
||||
|
||||
const { makeNetworkRequest } = require( './webhookCaller' )
|
||||
|
||||
async function startTask() {
|
||||
let { rows } = await knex.raw( `
|
||||
UPDATE webhooks_events
|
||||
SET
|
||||
"status" = 1,
|
||||
"lastUpdate" = NOW()
|
||||
FROM (
|
||||
SELECT "id" FROM webhooks_events
|
||||
WHERE "status" = 0
|
||||
ORDER BY "lastUpdate" ASC
|
||||
LIMIT 1
|
||||
) as task
|
||||
WHERE webhooks_events."id" = task."id"
|
||||
RETURNING webhooks_events."id"
|
||||
` )
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
async function doTask( task ) {
|
||||
try {
|
||||
let { rows } = await knex.raw( `
|
||||
SELECT
|
||||
ev.payload as evt,
|
||||
cnf.id as wh_id, cnf.url as wh_url, cnf.secret as wh_secret, cnf.enabled as wh_enabled
|
||||
FROM webhooks_events ev
|
||||
INNER JOIN webhooks_config cnf ON ev."webhookId" = cnf.id
|
||||
WHERE ev.id = ?
|
||||
LIMIT 1
|
||||
`, [ task.id ] )
|
||||
let info = rows[0]
|
||||
if ( !info ) {
|
||||
throw new Error( 'Internal error: DB inconsistent' )
|
||||
}
|
||||
|
||||
let fullPayload = JSON.parse( info.evt )
|
||||
|
||||
let postData = { payload: info.evt }
|
||||
|
||||
let signature = crypto.createHmac( 'sha256', info.wh_secret || '' ).update( postData.payload ).digest( 'hex' )
|
||||
let postHeaders = { 'X-WEBHOOK-SIGNATURE': signature }
|
||||
|
||||
console.log( `Callin webhook ${fullPayload.streamId} : ${fullPayload.event.event_name} at ${fullPayload.webhook.url}...` )
|
||||
let result = await makeNetworkRequest( { url: info.wh_url, data: postData, headersData: postHeaders } )
|
||||
|
||||
console.log( ` Result: ${JSON.stringify( result )}` )
|
||||
|
||||
if ( !result.success ) {
|
||||
throw new Error( result.error )
|
||||
}
|
||||
|
||||
await knex.raw( `
|
||||
UPDATE webhooks_events
|
||||
SET
|
||||
"status" = 2,
|
||||
"lastUpdate" = NOW(),
|
||||
"statusInfo" = 'Webhook called'
|
||||
WHERE "id" = ?
|
||||
`, [ task.id ] )
|
||||
} catch ( err ) {
|
||||
await knex.raw( `
|
||||
UPDATE webhooks_events
|
||||
SET
|
||||
"status" = 3,
|
||||
"lastUpdate" = NOW(),
|
||||
"statusInfo" = ?
|
||||
WHERE "id" = ?
|
||||
`, [ err.toString(), task.id ] )
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
let task = await startTask()
|
||||
if ( !task ) {
|
||||
setTimeout( tick, 1000 )
|
||||
return
|
||||
}
|
||||
|
||||
await doTask( task )
|
||||
|
||||
// Check for another task very soon
|
||||
setTimeout( tick, 10 )
|
||||
} catch ( err ) {
|
||||
console.log( 'Error executing task: ', err )
|
||||
setTimeout( tick, 5000 )
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function main() {
|
||||
console.log( 'Starting Webhook Service...' )
|
||||
tick()
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
'use strict'
|
||||
|
||||
// Ignore invalid/self-signed https certificate errors for the entire process
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
||||
|
||||
const fetch = require( 'node-fetch' )
|
||||
var debug = require( 'debug' )( 'speckle' )
|
||||
|
||||
async function makeNetworkRequest( { url, data, headersData } ) {
|
||||
let httpSuccessCodes = [ 200 ]
|
||||
let headers = { 'Content-Type': 'application/json' }
|
||||
for ( let k in headersData ) headers[ k ] = headersData[ k ]
|
||||
|
||||
debug( 'POST request to:', url )
|
||||
let t0 = Date.now()
|
||||
|
||||
try {
|
||||
let response = await fetch( url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify( data ),
|
||||
headers: headers,
|
||||
follow: 2, // follow max 2 redirects (fetch defaults to 20)
|
||||
timeout: 10 * 1000, // timeout after 10sec (defauls to no timeout)
|
||||
size: 500 * 1000, // 500kb max response size, to accomodate various error responses (defaults to no limit)
|
||||
} ).then( async res => ( { status: res.status, body: await res.text() } ) )
|
||||
|
||||
//console.log( 'Server response:', response )
|
||||
let error = httpSuccessCodes.indexOf( response.status ) === -1 ? `HTTP response code: ${response.status}` : ''
|
||||
let success = httpSuccessCodes.indexOf( response.status ) !== -1
|
||||
return {
|
||||
success: success,
|
||||
error: error,
|
||||
duration: ( Date.now() - t0 ) / 1000,
|
||||
responseCode: response.status,
|
||||
responseBody: response.body
|
||||
}
|
||||
} catch ( e ) {
|
||||
return {
|
||||
success: false,
|
||||
error: e.toString(),
|
||||
duration: ( Date.now() - t0 ) / 1000,
|
||||
responseCode: null,
|
||||
responseBody: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { makeNetworkRequest }
|
||||
|
||||
Reference in New Issue
Block a user