Merge pull request #343 from specklesystems/webhooks

Webhooks
This commit is contained in:
Cristian Balas
2021-07-23 12:57:30 +03:00
committed by GitHub
25 changed files with 1905 additions and 12 deletions
+9
View File
@@ -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>
+21
View File
@@ -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
}
}
}
}
}
}
+45 -7
View File
@@ -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>![image title](image url)</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}` )
}
+41
View File
@@ -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
}
]
}
}
+21
View File
@@ -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
View File
@@ -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=="
}
}
}
+23
View File
@@ -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"
}
}
+8
View File
@@ -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
} )
+103
View File
@@ -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 }