From e42c2b925df6538c7a5d5f1378b68c4e97f0ec41 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 18 Feb 2020 18:25:48 +0000 Subject: [PATCH] [CORL-687] Webhooks (#2738) * feat: initial webhook impl * feat: added support for key rotation * feat: harmonized fetcher * feat: added expired secrets cleaning * feat: event system refactor * feat: added story event * feat: simplfiied webhook handler * feat: added ref's to locations where user events can be added * feat: added UI to support webhooks * fix: renaming some Webhook -> WebhookEndpoint * fix: review comments to adjuist flow * feat: added localizations * fix: linting, updated snapshots * fix: adapted for new fluent * fix: rearranged folders * fix: linting * feat: added webhooks documentation * feat: improved toc generation * feat: added some tests to webhooks * fix: chain transition hooks * feat: added tests around webhook ui * fix: renamed events * fix: adjusted circle markdown linting * fix: adjusted doctoc script call * review: review fixes * review: review comments * review: adjusted signing secret confirmation * review: adjusted styles to harmonize button usage * fix: updated snapshots and tests * review: move form out of webhooks Moved the form out of the webhooks by relocating the layout used for the route associated with the configure routes. * fix: fixed bugs and snapshots with tests * feat: revised slack message format to use block api * fix: fixed a small text bug Co-authored-by: Vinh Co-authored-by: Kim Gardner --- .circleci/config.yml | 5 +- events.md => CLIENT_EVENTS.md | 42 +- CONTRIBUTING.md | 12 + WEBHOOKS.md | 205 +++++++ package.json | 10 +- scripts/generateEventDocs.ts | 4 +- .../client/admin/helpers/getEndpointLink.ts | 5 + src/core/client/admin/routeConfig.tsx | 12 + .../admin/routes/Configure/ConfigureLinks.tsx | 3 + .../Configure/NavigationWarningContainer.tsx | 2 +- .../sections/Organization/SitesConfig.tsx | 6 +- .../sections/Slack/SlackConfigContainer.tsx | 2 +- .../AddWebhookEndpointContainer.tsx | 58 ++ .../AddWebhookEndpointRoute.tsx | 38 ++ .../AddWebhookEndpoint/index.ts | 4 + .../ConfigureWebhookEndpointContainer.tsx | 62 +++ .../ConfigureWebhookEndpointRoute.tsx | 62 +++ .../DeleteWebhookEndpointMutation.ts | 38 ++ .../DisableWebhookEndpointMutation.ts | 38 ++ .../EnableWebhookEndpointMutation.ts | 38 ++ .../EndpointDangerZone.tsx | 181 ++++++ .../EndpointDetails.tsx | 42 ++ .../EndpointStatus.tsx | 85 +++ .../RotateSigningSecretModal.css | 10 + .../RotateSigningSecretModal.tsx | 165 ++++++ .../RotateWebhookEndpointSecretMutation.ts | 38 ++ .../ConfigureWebhookEndpoint/index.ts | 4 + .../ConfigureWebhookEndpointForm.tsx | 162 ++++++ .../CreateWebhookEndpointMutation.ts | 41 ++ .../EventsSelectField.css | 7 + .../EventsSelectField.tsx | 152 +++++ .../UpdateWebhookEndpointMutation.ts | 38 ++ .../ConfigureWebhookEndpointForm/index.ts | 4 + .../WebhookEndpoints/StatusMarker.css | 12 + .../WebhookEndpoints/StatusMarker.tsx | 23 + .../WebhookEndpoints/WebhookEndpointRow.css | 10 + .../WebhookEndpoints/WebhookEndpointRow.tsx | 56 ++ .../WebhookEndpointsConfigContainer.tsx | 115 ++++ .../WebhookEndpointsConfigRoute.tsx | 37 ++ .../WebhookEndpointsLayout.tsx | 27 + .../sections/WebhookEndpoints/index.ts | 8 + .../admin/routes/Configure/sections/index.ts | 5 + .../__snapshots__/advanced.spec.tsx.snap | 9 + .../__snapshots__/auth.spec.tsx.snap | 9 + .../__snapshots__/general.spec.tsx.snap | 9 + .../__snapshots__/moderation.spec.tsx.snap | 9 + .../__snapshots__/organization.spec.tsx.snap | 11 +- .../__snapshots__/webhooks.spec.tsx.snap | 525 ++++++++++++++++++ .../__snapshots__/wordList.spec.tsx.snap | 9 + .../admin/test/configure/webhooks.spec.tsx | 182 ++++++ src/core/client/admin/test/fixtures.ts | 5 + src/core/client/framework/helpers/urls.tsx | 4 + src/core/client/framework/lib/messages.tsx | 16 +- src/core/client/framework/lib/validation.tsx | 10 + .../ui/components/v2/ListGroup/ListGroup.css | 5 + .../ui/components/v2/ListGroup/ListGroup.tsx | 20 + .../components/v2/ListGroup/ListGroupRow.css | 8 + .../components/v2/ListGroup/ListGroupRow.tsx | 14 + .../ui/components/v2/ListGroup/index.ts | 2 + src/core/client/ui/components/v2/index.ts | 1 + src/core/server/app/handlers/api/graphql.ts | 2 +- src/core/server/app/index.ts | 4 +- .../passport/strategies/oidc/discover.ts | 8 +- .../passport/strategies/verifiers/sso.ts | 4 +- src/core/server/events/README.md | 15 + src/core/server/events/event.ts | 51 ++ src/core/server/events/events.ts | 87 +++ src/core/server/events/index.ts | 2 + src/core/server/events/listeners/notifier.ts | 49 ++ src/core/server/events/listeners/slack.ts | 210 +++++++ .../server/events/listeners/subscription.ts | 74 +++ src/core/server/events/listeners/webhook.ts | 85 +++ src/core/server/events/publisher.ts | 125 +++++ src/core/server/events/types.ts | 10 + src/core/server/graph/context.ts | 26 +- src/core/server/graph/loaders/Stories.ts | 9 +- src/core/server/graph/mutators/Actions.ts | 4 +- src/core/server/graph/mutators/Comments.ts | 27 +- src/core/server/graph/mutators/Settings.ts | 56 +- src/core/server/graph/mutators/Stories.ts | 1 + .../graph/resolvers/ModerationQueues.ts | 4 +- src/core/server/graph/resolvers/Mutation.ts | 56 +- src/core/server/graph/resolvers/Query.ts | 3 + .../graph/resolvers/SSOAuthIntegration.ts | 2 +- src/core/server/graph/resolvers/Settings.ts | 2 + .../graph/resolvers/Subscription/index.ts | 10 + .../server/graph/resolvers/WebhookEndpoint.ts | 10 + src/core/server/graph/resolvers/index.ts | 2 + src/core/server/graph/schema/schema.graphql | 339 ++++++++++- .../server/graph/subscriptions/publisher.ts | 57 -- src/core/server/index.ts | 22 +- src/core/server/models/settings/index.ts | 2 + src/core/server/models/settings/secret.ts | 44 ++ .../server/models/{ => settings}/settings.ts | 33 +- src/core/server/models/story/index.ts | 60 +- src/core/server/models/tenant/helpers.ts | 13 +- src/core/server/models/tenant/tenant.ts | 315 ++++++++++- src/core/server/models/user/user.ts | 16 +- src/core/server/queue/Task.ts | 6 +- src/core/server/queue/index.ts | 6 + src/core/server/queue/tasks/notifier/index.ts | 34 +- .../server/queue/tasks/notifier/messages.ts | 9 +- .../server/queue/tasks/notifier/processor.ts | 12 +- src/core/server/queue/tasks/webhook/index.ts | 22 + .../server/queue/tasks/webhook/processor.ts | 262 +++++++++ src/core/server/services/comments/actions.ts | 30 +- .../comments/pipeline/phases/toxic.ts | 7 +- src/core/server/services/events/comments.ts | 158 +++--- .../{stories/scraper => fetch}/abortAfter.ts | 0 src/core/server/services/fetch/fetch.ts | 84 +++ src/core/server/services/fetch/index.ts | 1 + .../migrations/1573073491825_sso_tokens.ts | 8 +- .../migrations/1573841155297_webhooks.ts | 49 ++ .../1573858750460_sso_token_refactor.ts | 14 +- .../notifications/categories/categories.ts | 8 +- .../notifications/categories/category.ts | 13 +- .../notifications/categories/featured.ts | 97 ++-- .../notifications/categories/moderation.ts | 142 +++-- .../notifications/categories/reply.ts | 175 +++--- .../notifications/categories/staffReply.ts | 182 +++--- src/core/server/services/slack/context.ts | 55 -- src/core/server/services/slack/index.ts | 3 - src/core/server/services/slack/publisher.ts | 207 ------- src/core/server/services/stories/index.ts | 30 +- .../services/stories/scraper/scraper.ts | 38 +- src/core/server/services/tenant/index.ts | 260 +++++++++ src/core/server/services/users/auth/invite.ts | 19 +- .../services/users/download/download.ts | 16 +- src/core/server/services/users/users.ts | 27 +- src/core/server/stacks/approveComment.ts | 6 +- src/core/server/stacks/createComment.ts | 10 +- src/core/server/stacks/editComment.ts | 6 +- .../server/stacks/helpers/publishChanges.ts | 10 +- src/core/server/stacks/rejectComment.ts | 6 +- src/locales/da/admin.ftl | 2 +- src/locales/en-US/admin.ftl | 83 +++ src/locales/en-US/framework.ftl | 1 + 137 files changed, 5633 insertions(+), 1020 deletions(-) rename events.md => CLIENT_EVENTS.md (95%) create mode 100644 WEBHOOKS.md create mode 100644 src/core/client/admin/helpers/getEndpointLink.ts create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/AddWebhookEndpoint/AddWebhookEndpointContainer.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/AddWebhookEndpoint/AddWebhookEndpointRoute.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/AddWebhookEndpoint/index.ts create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/ConfigureWebhookEndpointContainer.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/ConfigureWebhookEndpointRoute.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/DeleteWebhookEndpointMutation.ts create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/DisableWebhookEndpointMutation.ts create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/EnableWebhookEndpointMutation.ts create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/EndpointDangerZone.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/EndpointDetails.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/EndpointStatus.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateSigningSecretModal.css create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateSigningSecretModal.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateWebhookEndpointSecretMutation.ts create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/index.ts create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/ConfigureWebhookEndpointForm.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/CreateWebhookEndpointMutation.ts create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/EventsSelectField.css create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/EventsSelectField.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/UpdateWebhookEndpointMutation.ts create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/index.ts create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/StatusMarker.css create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/StatusMarker.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointRow.css create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointRow.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointsConfigContainer.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointsConfigRoute.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointsLayout.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/WebhookEndpoints/index.ts create mode 100644 src/core/client/admin/test/configure/__snapshots__/webhooks.spec.tsx.snap create mode 100644 src/core/client/admin/test/configure/webhooks.spec.tsx create mode 100644 src/core/client/ui/components/v2/ListGroup/ListGroup.css create mode 100644 src/core/client/ui/components/v2/ListGroup/ListGroup.tsx create mode 100644 src/core/client/ui/components/v2/ListGroup/ListGroupRow.css create mode 100644 src/core/client/ui/components/v2/ListGroup/ListGroupRow.tsx create mode 100644 src/core/client/ui/components/v2/ListGroup/index.ts create mode 100644 src/core/server/events/README.md create mode 100644 src/core/server/events/event.ts create mode 100644 src/core/server/events/events.ts create mode 100644 src/core/server/events/index.ts create mode 100644 src/core/server/events/listeners/notifier.ts create mode 100644 src/core/server/events/listeners/slack.ts create mode 100644 src/core/server/events/listeners/subscription.ts create mode 100644 src/core/server/events/listeners/webhook.ts create mode 100644 src/core/server/events/publisher.ts create mode 100644 src/core/server/events/types.ts create mode 100644 src/core/server/graph/resolvers/WebhookEndpoint.ts delete mode 100644 src/core/server/graph/subscriptions/publisher.ts create mode 100644 src/core/server/models/settings/index.ts create mode 100644 src/core/server/models/settings/secret.ts rename src/core/server/models/{ => settings}/settings.ts (86%) create mode 100644 src/core/server/queue/tasks/webhook/index.ts create mode 100644 src/core/server/queue/tasks/webhook/processor.ts rename src/core/server/services/{stories/scraper => fetch}/abortAfter.ts (100%) create mode 100644 src/core/server/services/fetch/fetch.ts create mode 100644 src/core/server/services/fetch/index.ts create mode 100644 src/core/server/services/migrate/migrations/1573841155297_webhooks.ts delete mode 100644 src/core/server/services/slack/context.ts delete mode 100644 src/core/server/services/slack/index.ts delete mode 100644 src/core/server/services/slack/publisher.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index 3fc80b2e0..f284de0d4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,11 +65,10 @@ jobs: name: Lint Source Code command: npm run lint - run: - name: Lint README.md + name: Lint Markdown command: | - cp README.md README.md.orig npm run doctoc - diff -q README.md README.md.orig + git diff --exit-code # unit_tests will run the unit tests. unit_tests: diff --git a/events.md b/CLIENT_EVENTS.md similarity index 95% rename from events.md rename to CLIENT_EVENTS.md index fc2087437..c45e60824 100644 --- a/events.md +++ b/CLIENT_EVENTS.md @@ -1,31 +1,52 @@ +# Client Events Guide + +This serves as a guide to events emitted by the javascript via the embed events +hook, as described below in [Viewer Events](#viewer-events). + + + +## Table of Contents + +- [Viewer Events](#viewer-events) + - [Viewer Network Events](#viewer-network-events) +- [Event List](#event-list) + - [Index](#index) + - [Events](#events) + + + ## Viewer Events + _Viewer Events_ are emitted when the viewer performs certain actions. They can be subscribed to using the `events` parameter in `Coral.createStreamEmbed`. + ```html - + ``` Example events: + - `setMainTab {tab: "PROFILE"}` - `showFeaturedCommentTooltip` - `viewConversation {from: "FEATURED_COMMENTS", commentID: "c45fb5f5-03f9-49a3-a755-488c698ca0df"}` ### Viewer Network Events -_Viewer Network Events_ are events that involves a network request and thus can succeed or fail. Succeeding events will have a `.success` appended to the event name while failing events have an `.error` appended to the event name. +_Viewer Network Events_ are events that involves a network request and thus can succeed or fail. Succeeding events will have a `.success` appended to the event name while failing events have an `.error` appended to the event name. Moreover _Viewer Network Events_ contains the `rtt` field which indicates the time it needed from initiating the request until the _UI_ has been updated with the response data. Example events: + ``` createComment.success { @@ -53,6 +74,7 @@ createComment.error ``` ## Event List + ### Index diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc30b7cd2..efaba19ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,18 @@ do so, please [let us know how we can improve it](https://github.com/coralprojec By contributing to this project you agree to the [Code of Conduct](CODE_OF_CONDUCT.md). + + +## Table of Contents + +- [What should I Contribute?](#what-should-i-contribute) +- [Writing Code](#writing-code) + - [When should I create an issue?](#when-should-i-create-an-issue) + - [What should I include?](#what-should-i-include) +- [Localization](#localization) + + + ## What should I Contribute? There are at least three ways to contribute to Coral: diff --git a/WEBHOOKS.md b/WEBHOOKS.md new file mode 100644 index 000000000..be39682f2 --- /dev/null +++ b/WEBHOOKS.md @@ -0,0 +1,205 @@ +# Webhooks Guide + +This document is in reference to webhooks emitted by Coral. You can configure +webhooks on your installation of Coral by visiting `/admin/configure/webhooks`. + +Once you've configured a webhook endpoint in Coral, you will receive updates +from Coral when those events occur. These will be in the form of `POST` requests +with a `JSON` payload consisting of the schema represented below. + + + +## Table of Contents + +- [Webhook Signing](#webhook-signing) + - [How to verify the signature(s)](#how-to-verify-the-signatures) +- [Schema](#schema) +- [Events Listing](#events-listing) +- [Events](#events) + + + +## Webhook Signing + +Each webhook sent by Coral is signed by your webhook endpoint signing secret. +The signature method closely resembles the signing method used by Stripe for +their `v1` signing method. The `X-Coral-Signature` header contains one or more +signatures prefixed by `sha256=`. + +If you receive a signature containing multiple signatures, it is typically when +you have rolled the signing secret from the administrative panel, and chosen to +keep the previous secret active for a duration of time. + +### How to verify the signature(s) + +```js +// Set your signing secret here from the administration panel. +const SIGNING_SECRET = "< YOUR SIGNING SECRET HERE >"; + +// We're using crypto to verify the signatures. +const crypto = require("crypto"); + +// We're using express to receive webhooks here. +const app = require("express")(); + +// Use the body-parser to get the raw body as a buffer so we can use it with the +// hashing functions. +const parser = require("body-parser"); + +function extractEvent(body, sig) { + // Step 1: Extract signatures from the header. + const signatures = sig + // Split the header by `,` to get a list of elements. + .split(",") + // Split each element by `=` to get a prefix and value pair. + .map(element => element.split("=")) + // Grab all the elements with the prefix of `sha256`. + .filter(([prefix]) => prefix === "sha256") + // Grab the value from the prefix and value pair. + .map(([, value]) => value); + + // Step 2: Prepare the `signed_payload`. + const signed_payload = body; + + // Step 3: Calculate the expected signature. + const expected = crypto + .createHmac("sha256", SIGNING_SECRET) + .update(signed_payload) + .digest() + .toString("hex"); + + // Step 4: Compare signatures. + if ( + // For each of the signatures on the request... + !signatures.some(signature => + // Compare the expected signature to the signature on in the header. If at + // least one of the match, we should continue to process the event. + crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) + ) + ) { + throw new Error("Invalid signature"); + } + + // Parse the JSON for the event. + return JSON.parse(body.toString()); +} + +app.post("/webhook", parser.raw({ type: "application/json" }), (req, res) => { + const sig = req.headers["x-coral-signature"]; + + let event; + + try { + // Parse the JSON for the event. + event = extractEvent(req.body, sig); + } catch (err) { + return res.status(400).send(`Webhook Error: ${err.message}`); + } + + // Handle the event. + switch (event.type) { + case "STORY_CREATED": + const data = event.data; + console.log( + `A Story with ID ${data.storyID} and URL ${data.storyURL} was created!` + ); + break; + // ... handle other event types. + default: + // Unexpected event type + return response.status(400).end(); + } + + // Return a response to acknowledge receipt of the event + res.json({ received: true }); +}); + +app.listen(4242, () => console.log("Running on port 4242")); +``` + +The procedure of how to verify the signatures follows. + +#### **Step 1**: Extract signatures from the header + +Split the header using `,` as the separator, to get a list of elements. Then +split each of these elements using `=` as the separator, to get a prefix and +value pair. The value for the prefix `sha256` corresponds to the signature(s). + +#### **Step 2**: Prepare the `signed_payload` string + +You can do this by taking the string contents of the body (before parsing or the +request body). + +#### **Step 3**: Calculate the expected signature + +Compute an HMAC signature using the SHA256 hash function. You can use the +webhook endpoint's signing secret as the key, and the above calculated +`signed_payload` as the message. + +#### **Step 4**: Compare signatures + +Compare the signature(s) in the header to the expected signature. To protect +against timing attacks, ensure you use a constant-time string comparison +function when comparing signatures. + +## Schema + +```ts +{ + /** + * id is the identifier for this event, each event + * will have a unique id. + */ + id: string; + + /** + * type is the name of this event, this indicates + * what is stored in the following `data` property. + * Refer to the `Events List` below to see what the + * type is for each event. + */ + type: string; + + /** + * data is the object representing this particular + * event. Each type of event has a different shape + * to the data property. Refer to the `Events List` + * below to see what the data looks like for each + * event. + */ + data: object; + + /** + * createdAt is the ISO 8601 representation of the + * date when this event was created. + */ + createdAt: string; +} +``` + +## Events Listing + +- [`STORY_CREATED`](#story-created-event) + +## Events + +- **STORY_CREATED** + +```ts +{ + id: string; + type: "STORY_CREATED"; + data: { + /** + * storyID is the ID of the newly created Story. + */ + storyID: string; + + /** + * storyURL is the URL of the newly created Story. + */ + storyURL: string; + } + createdAt: string; +} +``` diff --git a/package.json b/package.json index 30474ce52..5e4215d48 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,8 @@ "build:client": "ts-node --transpile-only ./scripts/build.ts", "build:server": "gulp server", "migration:create": "ts-node --transpile-only ./scripts/migration/create.ts", - "doctoc": "doctoc --title='## Table of Contents' --github README.md", - "docs:events": "ts-node ./scripts/generateEventDocs.ts ./src/core/client/stream/events.ts ./events.md", + "docs:events": "ts-node ./scripts/generateEventDocs.ts ./src/core/client/stream/events.ts ./CLIENT_EVENTS.md", + "doctoc": "doctoc --maxlevel=3 --title '## Table of Contents' README.md CLIENT_EVENTS.md CONTRIBUTING.md WEBHOOKS.md", "generate": "npm-run-all generate:css-types generate:schema generate:relay", "generate-persist": "npm-run-all generate:css-types generate:schema generate:relay-persist", "generate:css-types": "tcm src/core/client/", @@ -400,8 +400,12 @@ "src/core/server/graph/schema/schema.graphql": [ "graphql-schema-linter" ], - "{src/core/client/stream/events.ts,scripts/generateEventDocs.ts,events.md}": [ + "{src/core/client/stream/events.ts,scripts/generateEventDocs.ts,CLIENT_EVENTS.md}": [ "npm run docs:events -- --verify" + ], + "{README,CLIENT_EVENTS,CONTRIBUTING,WEBHOOKS}.md": [ + "npm run doctoc", + "git add" ] }, "bundlesize": [ diff --git a/scripts/generateEventDocs.ts b/scripts/generateEventDocs.ts index 34019928a..80d43b32a 100644 --- a/scripts/generateEventDocs.ts +++ b/scripts/generateEventDocs.ts @@ -1,8 +1,8 @@ /* eslint-disable no-bitwise */ import { codeBlock, stripIndent } from "common-tags"; -import * as fs from "fs"; -import * as path from "path"; +import fs from "fs"; +import path from "path"; import ts from "typescript"; interface DocEntry { diff --git a/src/core/client/admin/helpers/getEndpointLink.ts b/src/core/client/admin/helpers/getEndpointLink.ts new file mode 100644 index 000000000..81123a9ab --- /dev/null +++ b/src/core/client/admin/helpers/getEndpointLink.ts @@ -0,0 +1,5 @@ +import { urls } from "coral-framework/helpers"; + +export default function getEndpointLink(endpointID: string) { + return `${urls.admin.configureWebhookEndpoint}/${endpointID}`; +} diff --git a/src/core/client/admin/routeConfig.tsx b/src/core/client/admin/routeConfig.tsx index bcc148404..48e6ef332 100644 --- a/src/core/client/admin/routeConfig.tsx +++ b/src/core/client/admin/routeConfig.tsx @@ -9,18 +9,22 @@ import { createAuthCheckRoute } from "./routes/AuthCheck"; import CommunityRoute from "./routes/Community"; import ConfigureRoute from "./routes/Configure"; import { + AddWebhookEndpointRoute, AdvancedConfigRoute, AuthConfigRoute, + ConfigureWebhookEndpointRoute, EmailConfigRoute, GeneralConfigRoute, ModerationConfigRoute, OrganizationConfigRoute, SlackConfigRoute, + WebhookEndpointsConfigRoute, WordListConfigRoute, } from "./routes/Configure/sections"; import { Sites } from "./routes/Configure/sections/Sites"; import AddSiteRoute from "./routes/Configure/sections/Sites/AddSiteRoute"; import SiteRoute from "./routes/Configure/sections/Sites/SiteRoute"; +import WebhookEndpointsLayout from "./routes/Configure/sections/WebhookEndpoints/WebhookEndpointsLayout"; import ForgotPasswordRoute from "./routes/ForgotPassword"; import InviteRoute from "./routes/Invite"; import LoginRoute from "./routes/Login"; @@ -113,6 +117,14 @@ export default makeRouteConfig( + + + + + diff --git a/src/core/client/admin/routes/Configure/ConfigureLinks.tsx b/src/core/client/admin/routes/Configure/ConfigureLinks.tsx index 64c0531dd..c24cb225a 100644 --- a/src/core/client/admin/routes/Configure/ConfigureLinks.tsx +++ b/src/core/client/admin/routes/Configure/ConfigureLinks.tsx @@ -28,6 +28,9 @@ const ConfigureLinks: FunctionComponent<{}> = () => { Slack + + Webhooks + Advanced diff --git a/src/core/client/admin/routes/Configure/NavigationWarningContainer.tsx b/src/core/client/admin/routes/Configure/NavigationWarningContainer.tsx index 717893fcd..a05388800 100644 --- a/src/core/client/admin/routes/Configure/NavigationWarningContainer.tsx +++ b/src/core/client/admin/routes/Configure/NavigationWarningContainer.tsx @@ -24,7 +24,7 @@ class NavigationWarningContainer extends React.Component { ); this.removeTransitionHook = props.router.addTransitionHook(() => - this.props.active ? warningMessage : true + this.props.active ? warningMessage : undefined ); } diff --git a/src/core/client/admin/routes/Configure/sections/Organization/SitesConfig.tsx b/src/core/client/admin/routes/Configure/sections/Organization/SitesConfig.tsx index 694faf4b1..3b6742760 100644 --- a/src/core/client/admin/routes/Configure/sections/Organization/SitesConfig.tsx +++ b/src/core/client/admin/routes/Configure/sections/Organization/SitesConfig.tsx @@ -44,11 +44,7 @@ const SitesConfig: FunctionComponent = ({ id="configure-organization-sites-add-site" icon={add} > - diff --git a/src/core/client/admin/routes/Configure/sections/Slack/SlackConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/Slack/SlackConfigContainer.tsx index 1a7e04686..4b895bb5e 100644 --- a/src/core/client/admin/routes/Configure/sections/Slack/SlackConfigContainer.tsx +++ b/src/core/client/admin/routes/Configure/sections/Slack/SlackConfigContainer.tsx @@ -107,7 +107,7 @@ const SlackConfigContainer: FunctionComponent = ({ form, settings }) => { on how to create a Slack App see our documentation. - + + + + {webhookEndpoint.enabled ? ( + + + + + + + This endpoint is current enabled. By disabling this endpoint no + new events will be sent to the URL provided. + + + + + + + ) : ( + + + + + + + This endpoint is current disabled. By enabling this endpoint new + events will be sent to the URL provided. + + + + + + + )} + + + + + + + Deleting the endpoint will prevent any new events from being sent to + the URL provided. + + + + + + + + ); +}; + +const enhanced = withRouter( + withFragmentContainer({ + webhookEndpoint: graphql` + fragment EndpointDangerZone_webhookEndpoint on WebhookEndpoint { + id + enabled + } + `, + })(EndpointDangerZone) +); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/EndpointDetails.tsx b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/EndpointDetails.tsx new file mode 100644 index 000000000..49e6abe91 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/EndpointDetails.tsx @@ -0,0 +1,42 @@ +import React, { FunctionComponent } from "react"; + +import Subheader from "coral-admin/routes/Configure/Subheader"; +import { graphql, withFragmentContainer } from "coral-framework/lib/relay"; + +import { EndpointDetails_settings } from "coral-admin/__generated__/EndpointDetails_settings.graphql"; +import { EndpointDetails_webhookEndpoint } from "coral-admin/__generated__/EndpointDetails_webhookEndpoint.graphql"; + +import ConfigureWebhookEndpointForm from "../ConfigureWebhookEndpointForm"; + +interface Props { + webhookEndpoint: EndpointDetails_webhookEndpoint; + settings: EndpointDetails_settings; +} + +const EndpointDetails: FunctionComponent = ({ + webhookEndpoint, + settings, +}) => ( + <> + Endpoint details + + +); + +const enhanced = withFragmentContainer({ + webhookEndpoint: graphql` + fragment EndpointDetails_webhookEndpoint on WebhookEndpoint { + ...ConfigureWebhookEndpointForm_webhookEndpoint + } + `, + settings: graphql` + fragment EndpointDetails_settings on Settings { + ...ConfigureWebhookEndpointForm_settings + } + `, +})(EndpointDetails); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/EndpointStatus.tsx b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/EndpointStatus.tsx new file mode 100644 index 000000000..fb3732588 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/EndpointStatus.tsx @@ -0,0 +1,85 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +import Subheader from "coral-admin/routes/Configure/Subheader"; +import { CopyButton } from "coral-framework/components"; +import { ExternalLink } from "coral-framework/lib/i18n/components"; +import { graphql, withFragmentContainer } from "coral-framework/lib/relay"; +import { + Flex, + FormField, + FormFieldDescription, + HelperText, + Label, + PasswordField, +} from "coral-ui/components/v2"; + +import { EndpointStatus_webhookEndpoint } from "coral-admin/__generated__/EndpointStatus_webhookEndpoint.graphql"; + +import StatusMarker from "../StatusMarker"; + +interface Props { + webhookEndpoint: EndpointStatus_webhookEndpoint; +} + +const EndpointStatus: FunctionComponent = ({ webhookEndpoint }) => { + return ( + <> + + Endpoint status + + + + + + + + + + + + + } + > + + The following signing secret is used to sign request payloads sent + to the URL. To learn more about webhook signing, visit our{" "} + + Webhook Guide + + . + + + + + + + + KEY GENERATED AT: {webhookEndpoint.signingSecret.createdAt} + + + + ); +}; + +const enhanced = withFragmentContainer({ + webhookEndpoint: graphql` + fragment EndpointStatus_webhookEndpoint on WebhookEndpoint { + id + enabled + signingSecret { + secret + createdAt + } + } + `, +})(EndpointStatus); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateSigningSecretModal.css b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateSigningSecretModal.css new file mode 100644 index 000000000..09aae758a --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateSigningSecretModal.css @@ -0,0 +1,10 @@ +.root { + width: 500px; +} + +.title { + font-size: var(--v2-font-size-5); + font-family: var(--v2-font-family-primary); + font-weight: var(--v2-font-weight-primary-semi-bold); + line-height: var(--v2-line-height-title); +} diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateSigningSecretModal.tsx b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateSigningSecretModal.tsx new file mode 100644 index 000000000..697fec947 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateSigningSecretModal.tsx @@ -0,0 +1,165 @@ +import { Localized } from "@fluent/react/compat"; +import { FORM_ERROR } from "final-form"; +import React, { FunctionComponent, useCallback } from "react"; +import { Field, Form } from "react-final-form"; + +import { useNotification } from "coral-admin/App/GlobalNotification"; +import { InvalidRequestError } from "coral-framework/lib/errors"; +import { useMutation } from "coral-framework/lib/relay"; +import { + Button, + CallOut, + Card, + CardCloseButton, + Flex, + FormField, + HelperText, + HorizontalGutter, + Label, + Modal, + Option, + SelectField, +} from "coral-ui/components/v2"; +import AppNotification from "coral-ui/components/v2/AppNotification"; + +import RotateWebhookEndpointSecretMutation from "./RotateWebhookEndpointSecretMutation"; + +import styles from "./RotateSigningSecretModal.css"; + +interface Props { + endpointID: string; + onHide: () => void; + open: boolean; +} + +const RotateWebhookEndpointSecretModal: FunctionComponent = ({ + onHide, + open, + endpointID, +}) => { + const rotateWebhookEndpointSecret = useMutation( + RotateWebhookEndpointSecretMutation + ); + const { setMessage, clearMessage } = useNotification(); + const onRotateSecret = useCallback( + async ({ inactiveIn: inactiveInString }) => { + try { + const inactiveIn = parseInt(inactiveInString, 10); + await rotateWebhookEndpointSecret({ id: endpointID, inactiveIn }); + + // Post a notification about the successful change. + setMessage( + + + Webhook endpoint signing secret has been rotated. Please ensure + you update your integrations to use the new secret below. + + + ); + window.scroll(0, 0); + } catch (err) { + if (err instanceof InvalidRequestError) { + return err.invalidArgs; + } + return { [FORM_ERROR]: err.message }; + } + + // Dismiss the modal. + onHide(); + + return; + }, + [endpointID, rotateWebhookEndpointSecret] + ); + + return ( + + {({ firstFocusableRef, lastFocusableRef }) => ( + + + + +
+ {({ handleSubmit, submitting, submitError }) => ( + + + +

Rotate signing secret

+
+ {submitError && ( + + {submitError} + + )} + + + After it expires, signatures will no longer be generated + with the old secret. + + + + {({ input }) => ( + + + + + + + + + + + + + + + + + + + + + + + )} + + + + + + + + + +
+
+ )} + +
+ )} +
+ ); +}; + +export default RotateWebhookEndpointSecretModal; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateWebhookEndpointSecretMutation.ts b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateWebhookEndpointSecretMutation.ts new file mode 100644 index 000000000..8d5719b84 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateWebhookEndpointSecretMutation.ts @@ -0,0 +1,38 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { RotateWebhookEndpointSecretMutation as MutationTypes } from "coral-admin/__generated__/RotateWebhookEndpointSecretMutation.graphql"; + +let clientMutationId = 0; + +const RotateWebhookEndpointSecretMutation = createMutation( + "rotateWebhookEndpointSecret", + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation RotateWebhookEndpointSecretMutation( + $input: RotateWebhookEndpointSecretInput! + ) { + rotateWebhookEndpointSecret(input: $input) { + endpoint { + ...ConfigureWebhookEndpointContainer_webhookEndpoint + } + } + } + `, + variables: { + input: { + ...input, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }) +); + +export default RotateWebhookEndpointSecretMutation; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/index.ts b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/index.ts new file mode 100644 index 000000000..bc040a0c0 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/index.ts @@ -0,0 +1,4 @@ +export { + default, + default as ConfigureWebhookEndpointRoute, +} from "./ConfigureWebhookEndpointRoute"; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/ConfigureWebhookEndpointForm.tsx b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/ConfigureWebhookEndpointForm.tsx new file mode 100644 index 000000000..f8d294c91 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/ConfigureWebhookEndpointForm.tsx @@ -0,0 +1,162 @@ +import { Localized } from "@fluent/react/compat"; +import { FORM_ERROR } from "final-form"; +import { Match, Router, withRouter } from "found"; +import React, { FunctionComponent, useCallback } from "react"; +import { Field, Form } from "react-final-form"; + +import getEndpointLink from "coral-admin/helpers/getEndpointLink"; +import { InvalidRequestError } from "coral-framework/lib/errors"; +import { colorFromMeta, ValidationMessage } from "coral-framework/lib/form"; +import { + graphql, + useMutation, + withFragmentContainer, +} from "coral-framework/lib/relay"; +import { + composeValidators, + required, + validateURL, +} from "coral-framework/lib/validation"; +import { + Button, + CallOut, + Flex, + FormField, + HorizontalGutter, + Label, + TextField, +} from "coral-ui/components/v2"; + +import { ConfigureWebhookEndpointForm_settings } from "coral-admin/__generated__/ConfigureWebhookEndpointForm_settings.graphql"; +import { ConfigureWebhookEndpointForm_webhookEndpoint } from "coral-admin/__generated__/ConfigureWebhookEndpointForm_webhookEndpoint.graphql"; + +import CreateWebhookEndpointMutation from "./CreateWebhookEndpointMutation"; +import EventsSelectField from "./EventsSelectField"; +import UpdateWebhookEndpointMutation from "./UpdateWebhookEndpointMutation"; + +interface Props { + onCancel?: () => void; + router: Router; + match: Match; + webhookEndpoint: ConfigureWebhookEndpointForm_webhookEndpoint | null; + settings: ConfigureWebhookEndpointForm_settings; +} + +const ConfigureWebhookEndpointForm: FunctionComponent = ({ + onCancel, + settings, + webhookEndpoint, + router, +}) => { + const create = useMutation(CreateWebhookEndpointMutation); + const update = useMutation(UpdateWebhookEndpointMutation); + const onSubmit = useCallback( + async values => { + try { + if (webhookEndpoint) { + // The webhook endpoint was defined, update it. + await update(values); + } else { + // The webhook endpoint wasn't defined, created it. + const result = await create(values); + + // Redirect the user to the new webhook endpoint page. + router.push(getEndpointLink(result.endpoint.id)); + + // We don't need to close this modal because we are navigating... + } + + return; + } catch (err) { + if (err instanceof InvalidRequestError) { + return err.invalidArgs; + } + return { [FORM_ERROR]: err.message }; + } + }, + [webhookEndpoint, create, update, router] + ); + + return ( +
+ {({ handleSubmit, submitting, submitError, pristine }) => ( + + + {submitError && ( + + {submitError} + + )} + + {({ input, meta }) => ( + + + + + + + + )} + + + + {onCancel && ( + + + + )} + {webhookEndpoint ? ( + + + + ) : ( + + + + )} + + +
+ )} + + ); +}; + +const enhanced = withRouter( + withFragmentContainer({ + webhookEndpoint: graphql` + fragment ConfigureWebhookEndpointForm_webhookEndpoint on WebhookEndpoint { + id + url + events + all + } + `, + settings: graphql` + fragment ConfigureWebhookEndpointForm_settings on Settings { + ...EventsSelectField_settings + } + `, + })(ConfigureWebhookEndpointForm) +); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/CreateWebhookEndpointMutation.ts b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/CreateWebhookEndpointMutation.ts new file mode 100644 index 000000000..398a6c673 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/CreateWebhookEndpointMutation.ts @@ -0,0 +1,41 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { CreateWebhookEndpointMutation as MutationTypes } from "coral-admin/__generated__/CreateWebhookEndpointMutation.graphql"; + +let clientMutationId = 0; + +const CreateWebhookEndpointMutation = createMutation( + "createWebhookEndpoint", + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation CreateWebhookEndpointMutation( + $input: CreateWebhookEndpointInput! + ) { + createWebhookEndpoint(input: $input) { + endpoint { + id + } + settings { + ...WebhookEndpointsConfigContainer_settings + } + } + } + `, + variables: { + input: { + ...input, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }) +); + +export default CreateWebhookEndpointMutation; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/EventsSelectField.css b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/EventsSelectField.css new file mode 100644 index 000000000..66c537a2c --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/EventsSelectField.css @@ -0,0 +1,7 @@ +.list { + max-height: 295px; +} + +.event { + font-family: monospace; +} diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/EventsSelectField.tsx b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/EventsSelectField.tsx new file mode 100644 index 000000000..fd5121a38 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/EventsSelectField.tsx @@ -0,0 +1,152 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useCallback } from "react"; +import { useField } from "react-final-form"; + +import { ValidationMessage } from "coral-framework/lib/form"; +import { ExternalLink } from "coral-framework/lib/i18n/components"; +import { graphql, withFragmentContainer } from "coral-framework/lib/relay"; +import { validateWebhookEventSelection } from "coral-framework/lib/validation"; +import { Typography } from "coral-ui/components"; +import { + Button, + CheckBox, + Flex, + FormField, + FormFieldDescription, + HelperText, + Label, + ListGroup, + ListGroupRow, +} from "coral-ui/components/v2"; + +import { + EventsSelectField_settings, + WEBHOOK_EVENT_NAME, +} from "coral-admin/__generated__/EventsSelectField_settings.graphql"; + +import styles from "./EventsSelectField.css"; + +interface Props { + settings: EventsSelectField_settings; +} + +const EventsSelectField: FunctionComponent = ({ settings }) => { + const { input: all } = useField("all"); + const { input: events, meta } = useField("events", { + validate: validateWebhookEventSelection, + }); + + const onClear = useCallback(() => { + if (all.value) { + all.onChange(false); + } else { + events.onChange([]); + } + }, [all, events]); + + const onCheckChange = useCallback( + (event: WEBHOOK_EVENT_NAME, selectedIndex: number) => () => { + const changed = [...events.value]; + if (selectedIndex >= 0) { + changed.splice(selectedIndex, 1); + } else { + changed.push(event); + } + + events.onChange(changed); + }, + [events] + ); + + const onRecieveAll = useCallback(() => { + all.onChange(true); + }, [all]); + + return ( + + + + + + {(all.value || events.value.length > 0) && ( + + + + )} + + + } + > + + These are the events that are registered to this particular endpoint. + Visit our{" "} + + Webhook Guide + {" "} + for the schema of these events. Any event matching the following will + be sent to the endpoint if it is enabled: + + + + {settings.webhookEvents.map(event => { + const selectedIndex = events.value.indexOf(event); + return ( + + = 0} + onChange={onCheckChange(event, selectedIndex)} + > + {event} + + + ); + })} + + {all.value ? ( + + + The endpoint will receive all events, including any added in the + future. + + + ) : events.value.length > 0 ? ( + + {events.value.length} event selected. + + ) : ( + } + > + + Select events above or{" "} + + . + + + )} + + + ); +}; + +const enhanced = withFragmentContainer({ + settings: graphql` + fragment EventsSelectField_settings on Settings { + webhookEvents + } + `, +})(EventsSelectField); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/UpdateWebhookEndpointMutation.ts b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/UpdateWebhookEndpointMutation.ts new file mode 100644 index 000000000..58bbe7ee6 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/UpdateWebhookEndpointMutation.ts @@ -0,0 +1,38 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { UpdateWebhookEndpointMutation as MutationTypes } from "coral-admin/__generated__/UpdateWebhookEndpointMutation.graphql"; + +let clientMutationId = 0; + +const UpdateWebhookEndpointMutation = createMutation( + "updateWebhookEndpoint", + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation UpdateWebhookEndpointMutation( + $input: UpdateWebhookEndpointInput! + ) { + updateWebhookEndpoint(input: $input) { + endpoint { + ...ConfigureWebhookEndpointContainer_webhookEndpoint + } + } + } + `, + variables: { + input: { + ...input, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }) +); + +export default UpdateWebhookEndpointMutation; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/index.ts b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/index.ts new file mode 100644 index 000000000..90d30541c --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpointForm/index.ts @@ -0,0 +1,4 @@ +export { + default, + default as ConfigureWebhookEndpointForm, +} from "./ConfigureWebhookEndpointForm"; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/StatusMarker.css b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/StatusMarker.css new file mode 100644 index 000000000..3d54c73b6 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/StatusMarker.css @@ -0,0 +1,12 @@ +.success { + background-color: var(--v2-palette-success-main); + border-color: var(--v2-palette-success-main); + color: var(--v2-colors-pure-white); +} + +.error { + background-color: var(--v2-palette-error-darkest); + border-color: var(--v2-palette-error-darkest); + color: var(--v2-colors-pure-white); + font-weight: var(--v2-font-weight-primary-semi-bold); +} diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/StatusMarker.tsx b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/StatusMarker.tsx new file mode 100644 index 000000000..5ba47f004 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/StatusMarker.tsx @@ -0,0 +1,23 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +import { Marker } from "coral-ui/components/v2"; + +import styles from "./StatusMarker.css"; + +interface Props { + enabled: boolean; +} + +const StatusMarker: FunctionComponent = ({ enabled }) => + enabled ? ( + + Enabled + + ) : ( + + Disabled + + ); + +export default StatusMarker; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointRow.css b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointRow.css new file mode 100644 index 000000000..1b5b25d5a --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointRow.css @@ -0,0 +1,10 @@ +.urlColumn { + width: 100%; +} + +.detailsButton { + font-family: var(--v2-font-family-primary); + font-weight: var(--v2-font-weight-primary-semi-bold); + line-height: var(--v2-line-height-reset); + font-size: var(--v2-font-size-2); +} diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointRow.tsx b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointRow.tsx new file mode 100644 index 000000000..ed63b6205 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointRow.tsx @@ -0,0 +1,56 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +import getEndpointLink from "coral-admin/helpers/getEndpointLink"; +import { graphql, withFragmentContainer } from "coral-framework/lib/relay"; +import { + Button, + Flex, + Icon, + TableCell, + TableRow, +} from "coral-ui/components/v2"; + +import { WebhookEndpointRow_webhookEndpoint } from "coral-admin/__generated__/WebhookEndpointRow_webhookEndpoint.graphql"; + +import StatusMarker from "./StatusMarker"; + +import styles from "./WebhookEndpointRow.css"; + +interface Props { + endpoint: WebhookEndpointRow_webhookEndpoint; +} + +const WebhookEndpointRow: FunctionComponent = ({ endpoint }) => ( + + {endpoint.url} + + + + + + keyboard_arrow_right} + > + + + + + +); + +const enhanced = withFragmentContainer({ + endpoint: graphql` + fragment WebhookEndpointRow_webhookEndpoint on WebhookEndpoint { + id + enabled + url + } + `, +})(WebhookEndpointRow); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointsConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointsConfigContainer.tsx new file mode 100644 index 000000000..8b718dbc1 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointsConfigContainer.tsx @@ -0,0 +1,115 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +import { urls } from "coral-framework/helpers"; +import { ExternalLink } from "coral-framework/lib/i18n/components"; +import { graphql, withFragmentContainer } from "coral-framework/lib/relay"; +import { + Button, + CallOut, + FormFieldDescription, + HorizontalGutter, + Icon, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "coral-ui/components/v2"; + +import { WebhookEndpointsConfigContainer_settings } from "coral-admin/__generated__/WebhookEndpointsConfigContainer_settings.graphql"; + +import ConfigBox from "../../ConfigBox"; +import Header from "../../Header"; +import Subheader from "../../Subheader"; +import WebhookEndpointRow from "./WebhookEndpointRow"; + +interface Props { + settings: WebhookEndpointsConfigContainer_settings; +} + +const WebhookEndpointsConfigContainer: FunctionComponent = ({ + settings, +}) => { + return ( + + +
Webhooks
+ + } + > + + } + > + + Configure an endpoint to send events to when events occur within + Coral. These events will be JSON encoded and signed. To learn more + about webhook signing, visit our{" "} + + our docs + + . + + + + + Endpoints + + {settings.webhooks.endpoints.length > 0 ? ( + + + + + URL + + + Status + + + + + + {settings.webhooks.endpoints.map((endpoint, idx) => ( + + ))} + +
+ ) : ( + + + There are no webhook endpoints configured, add one above. + + + )} +
+
+ ); +}; + +const enhanced = withFragmentContainer({ + settings: graphql` + fragment WebhookEndpointsConfigContainer_settings on Settings { + webhooks { + endpoints { + ...WebhookEndpointRow_webhookEndpoint + } + } + } + `, +})(WebhookEndpointsConfigContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointsConfigRoute.tsx b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointsConfigRoute.tsx new file mode 100644 index 000000000..d4ffd72f5 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointsConfigRoute.tsx @@ -0,0 +1,37 @@ +import React, { FunctionComponent } from "react"; +import { graphql } from "react-relay"; + +import { withRouteConfig } from "coral-framework/lib/router"; +import { Delay, Spinner } from "coral-ui/components/v2"; + +import { WebhookEndpointsConfigRouteQueryResponse } from "coral-admin/__generated__/WebhookEndpointsConfigRouteQuery.graphql"; + +import WebhookEndpointsConfigContainer from "./WebhookEndpointsConfigContainer"; + +interface Props { + data: WebhookEndpointsConfigRouteQueryResponse | null; +} + +const WebhookEndpointsConfigRoute: FunctionComponent = ({ data }) => { + if (!data) { + return ( + + + + ); + } + + return ; +}; + +const enhanced = withRouteConfig({ + query: graphql` + query WebhookEndpointsConfigRouteQuery { + settings { + ...WebhookEndpointsConfigContainer_settings + } + } + `, +})(WebhookEndpointsConfigRoute); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointsLayout.tsx b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointsLayout.tsx new file mode 100644 index 000000000..fcfdf143c --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/WebhookEndpointsLayout.tsx @@ -0,0 +1,27 @@ +import React, { FunctionComponent } from "react"; + +import MainLayout from "coral-admin/components/MainLayout"; + +import ConfigureLinks from "../../ConfigureLinks"; +import Layout from "../../Layout"; +import Main from "../../Main"; +import SideBar from "../../SideBar"; + +interface Props { + children: React.ReactElement; +} + +const WebhookEndpointsLayout: FunctionComponent = props => { + return ( + + + + + +
{props.children}
+
+
+ ); +}; + +export default WebhookEndpointsLayout; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/index.ts b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/index.ts new file mode 100644 index 000000000..715c90381 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/index.ts @@ -0,0 +1,8 @@ +export { + default, + default as WebhookEndpointsConfigRoute, +} from "./WebhookEndpointsConfigRoute"; +export { default as AddWebhookEndpointRoute } from "./AddWebhookEndpoint"; +export { + default as ConfigureWebhookEndpointRoute, +} from "./ConfigureWebhookEndpoint"; diff --git a/src/core/client/admin/routes/Configure/sections/index.ts b/src/core/client/admin/routes/Configure/sections/index.ts index 54d744d01..cff9bfbb7 100644 --- a/src/core/client/admin/routes/Configure/sections/index.ts +++ b/src/core/client/admin/routes/Configure/sections/index.ts @@ -6,3 +6,8 @@ export { ModerationConfigRoute } from "./Moderation"; export { OrganizationConfigRoute } from "./Organization"; export { WordListConfigRoute } from "./WordList"; export { SlackConfigRoute } from "./Slack"; +export { + WebhookEndpointsConfigRoute, + ConfigureWebhookEndpointRoute, + AddWebhookEndpointRoute, +} from "./WebhookEndpoints"; diff --git a/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap b/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap index 2e7d8cbe5..fcf9f9386 100644 --- a/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap +++ b/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap @@ -88,6 +88,15 @@ exports[`renders configure advanced 1`] = ` Slack +
  • + + Webhooks + +
  • +
  • + + Webhooks + +
  • +
  • + + Webhooks + +
  • +
  • + + Webhooks + +
  • +
  • + + Webhooks + +
  • + +`; + +exports[`goes to add new webhook endpoint when clicking add 1`] = ` +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    + Configure an endpoint to send events to when events occur within +Coral. These events will be JSON encoded and signed. To learn more +about webhook signing, visit our + + Webhook Guide + + . +

    + + + Add webhook endpoint + +

    + Endpoints +

    +
    +
    + There are no webhook endpoints configured, add one above. +
    +
    +
    +
    +
    +
    +`; + +exports[`goes to the webhook endpoint configuration page when selected 1`] = ` +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    + Configure an endpoint to send events to when events occur within +Coral. These events will be JSON encoded and signed. To learn more +about webhook signing, visit our + + Webhook Guide + + . +

    + + + Add webhook endpoint + +

    + Endpoints +

    + + + + + + + + + + + + + + +
    + URL + + Status + +
    + http://example.com/webhook-endpoint-1 + + + Enabled + + + +
    +
    +
    +
    +
    +`; + +exports[`renders webhooks 1`] = ` +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    + Configure an endpoint to send events to when events occur within +Coral. These events will be JSON encoded and signed. To learn more +about webhook signing, visit our + + Webhook Guide + + . +

    + + + Add webhook endpoint + +

    + Endpoints +

    +
    +
    + There are no webhook endpoints configured, add one above. +
    +
    +
    +
    +
    +
    +`; diff --git a/src/core/client/admin/test/configure/__snapshots__/wordList.spec.tsx.snap b/src/core/client/admin/test/configure/__snapshots__/wordList.spec.tsx.snap index 20e7e8510..009bfc474 100644 --- a/src/core/client/admin/test/configure/__snapshots__/wordList.spec.tsx.snap +++ b/src/core/client/admin/test/configure/__snapshots__/wordList.spec.tsx.snap @@ -88,6 +88,15 @@ exports[`renders configure wordList 1`] = ` Slack
  • +
  • + + Webhooks + +
  • { + replaceHistoryLocation("http://localhost/admin/configure/webhooks"); +}); + +const viewer = users.admins[0]; + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + const { testRenderer, context } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + viewer: () => viewer, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + + return await act(async () => { + const container = await waitForElement(() => + within(testRenderer.root).getByTestID("webhooks-container") + ); + + return { testRenderer, container, context }; + }); +} + +it("renders webhooks", async () => { + const { container } = await createTestRenderer(); + await act(async () => { + await wait(() => { + expect(within(container).toJSON()).toMatchSnapshot(); + }); + }); +}); + +it("goes to add new webhook endpoint when clicking add", async () => { + const { + container, + context: { transitionControl }, + } = await createTestRenderer(); + + // Prevent router transitions. + transitionControl.allowTransition = false; + + act(() => { + within(container) + .getByText(/Add webhook endpoint/) + .props.onClick({ button: 0, preventDefault: noop }); + }); + + // Expect a routing request was made to the right url. + await act(async () => { + await wait(() => { + expect(transitionControl.history[0].pathname).toBe( + "/admin/configure/webhooks/add" + ); + }); + }); + + await act(async () => { + await wait(() => { + expect(within(container).toJSON()).toMatchSnapshot(); + }); + }); +}); + +it("displays a list of webhook endpoints that have been configured", async () => { + const resolvers = createResolversStub({ + Query: { + settings: () => + pureMerge(settings, { + webhooks: { + endpoints: [ + { + id: "webhook-endpoint-1", + enabled: true, + url: "http://example.com/webhook-endpoint-1", + all: true, + events: [], + }, + { + id: "webhook-endpoint-2", + enabled: false, + url: "http://example.com/webhook-endpoint-2", + all: true, + events: [], + }, + ], + }, + }), + }, + }); + const { container } = await createTestRenderer({ resolvers }); + + await act(async () => { + await wait(() => { + expect(within(container).toJSON()).toMatchSnapshot(); + }); + }); +}); + +it("goes to the webhook endpoint configuration page when selected", async () => { + const resolvers = createResolversStub({ + Query: { + settings: () => + pureMerge(settings, { + webhooks: { + endpoints: [ + { + id: "webhook-endpoint-1", + enabled: true, + url: "http://example.com/webhook-endpoint-1", + all: true, + events: [], + }, + ], + }, + }), + }, + }); + const { + container, + context: { transitionControl }, + } = await createTestRenderer({ resolvers }); + + // Prevent router transitions. + transitionControl.allowTransition = false; + + act(() => { + const row = within(container).getByTestID( + "webhook-endpoint-webhook-endpoint-1" + ); + + within(row) + .getByText(/Details/, { + selector: "a", + }) + .props.onClick({ button: 0, preventDefault: noop }); + }); + + // Expect a routing request was made to the right url. + await act(async () => { + await wait(() => { + expect(transitionControl.history[0].pathname).toBe( + "/admin/configure/webhooks/endpoint/webhook-endpoint-1" + ); + }); + }); + + await act(async () => { + await wait(() => { + expect(within(container).toJSON()).toMatchSnapshot(); + }); + }); +}); diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index 38c8f579f..2c74572fc 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -23,6 +23,7 @@ import { GQLUSER_ROLE, GQLUSER_STATUS, GQLUsersConnection, + GQLWEBHOOK_EVENT_NAME, } from "coral-framework/schema"; import { createFixture, createFixtures } from "coral-framework/testHelpers"; @@ -152,6 +153,10 @@ export const settings = createFixture({ }, }, }, + webhooks: { + endpoints: [], + }, + webhookEvents: [GQLWEBHOOK_EVENT_NAME.STORY_CREATED], stories: { scraping: { enabled: true, diff --git a/src/core/client/framework/helpers/urls.tsx b/src/core/client/framework/helpers/urls.tsx index 05d6f188d..7447841f8 100644 --- a/src/core/client/framework/helpers/urls.tsx +++ b/src/core/client/framework/helpers/urls.tsx @@ -1,6 +1,10 @@ export default { admin: { moderate: "/admin/moderate", + configureWebhooks: "/admin/configure/webhooks", + webhooks: "/admin/configure/webhooks", + addWebhookEndpoint: "/admin/configure/webhooks/add", + configureWebhookEndpoint: "/admin/configure/webhooks/endpoint", }, embed: { stream: "/embed/stream", diff --git a/src/core/client/framework/lib/messages.tsx b/src/core/client/framework/lib/messages.tsx index 138b94341..3a7b68293 100644 --- a/src/core/client/framework/lib/messages.tsx +++ b/src/core/client/framework/lib/messages.tsx @@ -14,13 +14,13 @@ export const VALIDATION_REQUIRED = () => ( export const VALIDATION_TOO_SHORT = (minLength: number) => ( - {"Please enter at least {$minLength} characters."} + Please enter at least {minLength} characters. ); export const VALIDATION_TOO_LONG = (maxLength: number) => ( - {"Please enter at max {$maxLength} characters."} + Please enter at max {maxLength} characters. ); @@ -38,19 +38,19 @@ export const INVALID_CHARACTERS = () => ( export const USERNAME_TOO_SHORT = (minLength: number) => ( - {"Usernames must contain at least {$minLength} characters."} + Usernames must contain at least {minLength} characters. ); export const USERNAME_TOO_LONG = (maxLength: number) => ( - {"Usernames cannot be longer than {$maxLength} characters."} + Usernames cannot be longer than {maxLength} characters. ); export const PASSWORD_TOO_SHORT = (minLength: number) => ( - {"Password must contain at least {$minLength} characters."} + Password must contain at least {minLength} characters. ); @@ -60,6 +60,12 @@ export const PASSWORDS_DO_NOT_MATCH = () => ( ); +export const INVALID_WEBHOOK_ENDPOINT_EVENT_SELECTION = () => ( + + Select at least one event to receive. + +); + export const USERNAMES_DO_NOT_MATCH = () => ( Usernames do not match. Try again. diff --git a/src/core/client/framework/lib/validation.tsx b/src/core/client/framework/lib/validation.tsx index 3c58c71b5..44f0b8215 100644 --- a/src/core/client/framework/lib/validation.tsx +++ b/src/core/client/framework/lib/validation.tsx @@ -17,6 +17,7 @@ import { INVALID_CHARACTERS, INVALID_EMAIL, INVALID_URL, + INVALID_WEBHOOK_ENDPOINT_EVENT_SELECTION, NOT_A_WHOLE_NUMBER, NOT_A_WHOLE_NUMBER_BETWEEN, NOT_A_WHOLE_NUMBER_GREATER_THAN, @@ -155,6 +156,15 @@ export const validateEqualPasswords = createValidator( PASSWORDS_DO_NOT_MATCH() ); +/** + * validateWebhookEventSelection is a Validator that checks for a valid + * combination of event selections for webhook endpoints. + */ +export const validateWebhookEventSelection = createValidator( + (v, values) => values.all || (values.events && values.events.length > 0), + INVALID_WEBHOOK_ENDPOINT_EVENT_SELECTION() +); + /** * validateEqualEmails is a Validator that checks for correct email confirmation. */ diff --git a/src/core/client/ui/components/v2/ListGroup/ListGroup.css b/src/core/client/ui/components/v2/ListGroup/ListGroup.css new file mode 100644 index 000000000..c868d5411 --- /dev/null +++ b/src/core/client/ui/components/v2/ListGroup/ListGroup.css @@ -0,0 +1,5 @@ +.root { + border: 1px solid var(--v2-colors-grey-300); + border-radius: var(--round-corners); + overflow-y: auto; +} diff --git a/src/core/client/ui/components/v2/ListGroup/ListGroup.tsx b/src/core/client/ui/components/v2/ListGroup/ListGroup.tsx new file mode 100644 index 000000000..c9b19cf88 --- /dev/null +++ b/src/core/client/ui/components/v2/ListGroup/ListGroup.tsx @@ -0,0 +1,20 @@ +import cn from "classnames"; +import React, { FunctionComponent } from "react"; + +import { Flex } from "coral-ui/components/v2"; + +import styles from "./ListGroup.css"; + +interface Props { + className?: string; +} + +const ListGroup: FunctionComponent = ({ className, children }) => { + return ( + + {children} + + ); +}; + +export default ListGroup; diff --git a/src/core/client/ui/components/v2/ListGroup/ListGroupRow.css b/src/core/client/ui/components/v2/ListGroup/ListGroupRow.css new file mode 100644 index 000000000..f8e8901de --- /dev/null +++ b/src/core/client/ui/components/v2/ListGroup/ListGroupRow.css @@ -0,0 +1,8 @@ +.root { + border-bottom: 1px solid var(--v2-colors-grey-200); + padding: var(--v2-spacing-2); + + &:last-child { + border-bottom: none; + } +} diff --git a/src/core/client/ui/components/v2/ListGroup/ListGroupRow.tsx b/src/core/client/ui/components/v2/ListGroup/ListGroupRow.tsx new file mode 100644 index 000000000..328cb83c6 --- /dev/null +++ b/src/core/client/ui/components/v2/ListGroup/ListGroupRow.tsx @@ -0,0 +1,14 @@ +import cn from "classnames"; +import React, { FunctionComponent } from "react"; + +import styles from "./ListGroupRow.css"; + +interface Props { + className?: string; +} + +const ListGroupRow: FunctionComponent = ({ className, children }) => { + return
    {children}
    ; +}; + +export default ListGroupRow; diff --git a/src/core/client/ui/components/v2/ListGroup/index.ts b/src/core/client/ui/components/v2/ListGroup/index.ts new file mode 100644 index 000000000..a16279c66 --- /dev/null +++ b/src/core/client/ui/components/v2/ListGroup/index.ts @@ -0,0 +1,2 @@ +export { default as ListGroup } from "./ListGroup"; +export { default as ListGroupRow } from "./ListGroupRow"; diff --git a/src/core/client/ui/components/v2/index.ts b/src/core/client/ui/components/v2/index.ts index e3cb88fca..08fb69730 100644 --- a/src/core/client/ui/components/v2/index.ts +++ b/src/core/client/ui/components/v2/index.ts @@ -36,6 +36,7 @@ export { default as HelperText } from "./HelperText"; export { default as HorizontalGutter } from "./HorizontalGutter"; export { default as Icon } from "./Icon"; export { default as Label } from "./Label"; +export { ListGroup, ListGroupRow } from "./ListGroup"; export { Marker, Count as MarkerCount } from "./Marker"; export { default as Message, MessageIcon } from "./Message"; export { default as Modal, ModalProps } from "./Modal"; diff --git a/src/core/server/app/handlers/api/graphql.ts b/src/core/server/app/handlers/api/graphql.ts index 3efb12ed8..513d6dc3a 100644 --- a/src/core/server/app/handlers/api/graphql.ts +++ b/src/core/server/app/handlers/api/graphql.ts @@ -17,7 +17,7 @@ export type GraphMiddlewareOptions = Pick< | "pubsub" | "tenantCache" | "metrics" - | "notifierQueue" + | "broker" >; export const graphQLHandler = ({ diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 153588ef2..652314d8b 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -15,9 +15,9 @@ import { HTMLErrorHandler } from "coral-server/app/middleware/error"; import { notFoundMiddleware } from "coral-server/app/middleware/notFound"; import { createPassport } from "coral-server/app/middleware/passport"; import { Config } from "coral-server/config"; +import CoralEventListenerBroker from "coral-server/events/publisher"; import logger from "coral-server/logger"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; -import { NotifierQueue } from "coral-server/queue/tasks/notifier"; import { ScraperQueue } from "coral-server/queue/tasks/scraper"; import { I18n } from "coral-server/services/i18n"; import { JWTSigningConfig } from "coral-server/services/jwt"; @@ -41,7 +41,6 @@ export interface AppOptions { mailerQueue: MailerQueue; metrics?: Metrics; mongo: Db; - notifierQueue: NotifierQueue; parent: Express; persistedQueriesRequired: boolean; persistedQueryCache: PersistedQueryCache; @@ -52,6 +51,7 @@ export interface AppOptions { signingConfig: JWTSigningConfig; tenantCache: TenantCache; migrationManager: MigrationManager; + broker: CoralEventListenerBroker; } /** diff --git a/src/core/server/app/middleware/passport/strategies/oidc/discover.ts b/src/core/server/app/middleware/passport/strategies/oidc/discover.ts index bd9625ada..2a53b3b7d 100644 --- a/src/core/server/app/middleware/passport/strategies/oidc/discover.ts +++ b/src/core/server/app/middleware/passport/strategies/oidc/discover.ts @@ -1,7 +1,7 @@ -import fetch from "node-fetch"; import { URL } from "url"; import { ensureNoEndSlash } from "coral-common/utils"; +import { createFetch } from "coral-server/services/fetch"; /** * Configuration that Coral is expecting. @@ -25,6 +25,12 @@ interface DiscoveryRawConfiguration { jwks_uri: string; } +/** + * fetch provides a single source for managing the fetching operations for + * discovery. + */ +const fetch = createFetch({ name: "OIDC" }); + /** * discover will discover the configuration for the issuer. * diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts index 6aca55b69..3a2306e7f 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts @@ -5,7 +5,7 @@ import { Db } from "mongodb"; import { validate } from "coral-server/app/request/body"; import { IntegrationDisabled, TokenInvalidError } from "coral-server/errors"; -import { SSOAuthIntegration, SSOKey } from "coral-server/models/settings"; +import { Secret, SSOAuthIntegration } from "coral-server/models/settings"; import { Tenant } from "coral-server/models/tenant"; import { retrieveUserWithProfile, @@ -169,7 +169,7 @@ export function getRelevantSSOKeys( tokenString: string, now: Date, kid?: string -): SSOKey[] { +): Secret[] { // Collect all the current valid keys. const keys = integration.keys.filter(k => { if (k.inactiveAt && now >= k.inactiveAt) { diff --git a/src/core/server/events/README.md b/src/core/server/events/README.md new file mode 100644 index 000000000..16da3e6e1 --- /dev/null +++ b/src/core/server/events/README.md @@ -0,0 +1,15 @@ +# events + +This is the events package for Coral. + +## Adding new events + +You can add new events by adding to the `events.ts` file. Each event must export +a `{ eventName }Payload` type and a `{ eventName }` Coral Event. + +## Adding new event listeners + +You can add a new event listener by adding to the `listeners/` folder. These +events must implement the `CoralEventListener` abstract class. You can then +register this listener in the `src/core/server/index.ts` file by registering +it on the broker. diff --git a/src/core/server/events/event.ts b/src/core/server/events/event.ts new file mode 100644 index 000000000..d1881496d --- /dev/null +++ b/src/core/server/events/event.ts @@ -0,0 +1,51 @@ +import uuid from "uuid/v4"; + +import logger from "coral-server/logger"; + +import { CoralEventPublisherBroker } from "./publisher"; +import { CoralEventType } from "./types"; + +export interface CoralEventPayload< + T extends CoralEventType = CoralEventType, + U extends {} = {} +> { + /** + * id is the identifier for this specific event. Every copy of this unique + * event will share the same identifier. + */ + readonly id: string; + + /** + * type identifies this particular event. + */ + readonly type: T; + + /** + * data stores the underlying content of the event. + */ + readonly data: Readonly; + + /** + * createdAt is the date that this event was published at. + */ + readonly createdAt: Date; +} + +export function createCoralEvent(type: T["type"]) { + return { + publish: async (broker: CoralEventPublisherBroker, data: T["data"]) => { + const event: CoralEventPayload = { + id: uuid(), + createdAt: new Date(), + data, + type, + }; + + logger.trace( + { eventType: event.type, eventID: event.id }, + "publishing event" + ); + await broker.emit(event); + }, + }; +} diff --git a/src/core/server/events/events.ts b/src/core/server/events/events.ts new file mode 100644 index 000000000..3eadd300d --- /dev/null +++ b/src/core/server/events/events.ts @@ -0,0 +1,87 @@ +import { + CommentCreatedInput, + CommentEnteredModerationQueueInput, + CommentFeaturedInput, + CommentLeftModerationQueueInput, + CommentReleasedInput, + CommentReplyCreatedInput, + CommentStatusUpdatedInput, +} from "coral-server/graph/resolvers/Subscription"; + +import { CoralEventPayload, createCoralEvent } from "./event"; +import { CoralEventType } from "./types"; + +export type CommentEnteredModerationQueueCoralEventPayload = CoralEventPayload< + CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE, + CommentEnteredModerationQueueInput +>; + +export const CommentEnteredModerationQueueCoralEvent = createCoralEvent< + CommentEnteredModerationQueueCoralEventPayload +>(CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE); + +export type CommentLeftModerationQueueCoralEventPayload = CoralEventPayload< + CoralEventType.COMMENT_LEFT_MODERATION_QUEUE, + CommentLeftModerationQueueInput +>; + +export const CommentLeftModerationQueueCoralEvent = createCoralEvent< + CommentLeftModerationQueueCoralEventPayload +>(CoralEventType.COMMENT_LEFT_MODERATION_QUEUE); + +export type CommentStatusUpdatedCoralEventPayload = CoralEventPayload< + CoralEventType.COMMENT_STATUS_UPDATED, + CommentStatusUpdatedInput +>; + +export const CommentStatusUpdatedCoralEvent = createCoralEvent< + CommentStatusUpdatedCoralEventPayload +>(CoralEventType.COMMENT_STATUS_UPDATED); + +export type CommentReplyCreatedCoralEventPayload = CoralEventPayload< + CoralEventType.COMMENT_REPLY_CREATED, + CommentReplyCreatedInput +>; + +export const CommentReplyCreatedCoralEvent = createCoralEvent< + CommentReplyCreatedCoralEventPayload +>(CoralEventType.COMMENT_REPLY_CREATED); + +export type CommentCreatedCoralEventPayload = CoralEventPayload< + CoralEventType.COMMENT_CREATED, + CommentCreatedInput +>; + +export const CommentCreatedCoralEvent = createCoralEvent< + CommentCreatedCoralEventPayload +>(CoralEventType.COMMENT_CREATED); + +export type CommentFeaturedCoralEventPayload = CoralEventPayload< + CoralEventType.COMMENT_FEATURED, + CommentFeaturedInput +>; + +export const CommentFeaturedCoralEvent = createCoralEvent< + CommentFeaturedCoralEventPayload +>(CoralEventType.COMMENT_FEATURED); + +export type CommentReleasedCoralEventPayload = CoralEventPayload< + CoralEventType.COMMENT_RELEASED, + CommentReleasedInput +>; + +export const CommentReleasedCoralEvent = createCoralEvent< + CommentReleasedCoralEventPayload +>(CoralEventType.COMMENT_RELEASED); + +export type StoryCreatedCoralEventPayload = CoralEventPayload< + CoralEventType.STORY_CREATED, + { + storyID: string; + storyURL: string; + } +>; + +export const StoryCreatedCoralEvent = createCoralEvent< + StoryCreatedCoralEventPayload +>(CoralEventType.STORY_CREATED); diff --git a/src/core/server/events/index.ts b/src/core/server/events/index.ts new file mode 100644 index 000000000..01c57e6b2 --- /dev/null +++ b/src/core/server/events/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./events"; diff --git a/src/core/server/events/listeners/notifier.ts b/src/core/server/events/listeners/notifier.ts new file mode 100644 index 000000000..2c9d431f1 --- /dev/null +++ b/src/core/server/events/listeners/notifier.ts @@ -0,0 +1,49 @@ +import { NotifierQueue } from "coral-server/queue/tasks/notifier"; +import { categories } from "coral-server/services/notifications/categories"; + +import { + CommentFeaturedCoralEventPayload, + CommentReplyCreatedCoralEventPayload, + CommentStatusUpdatedCoralEventPayload, +} from "../events"; +import { CoralEventListener, CoralEventPublisherFactory } from "../publisher"; +import { CoralEventType } from "../types"; + +export type NotifierCoralEventListenerPayloads = + | CommentFeaturedCoralEventPayload + | CommentStatusUpdatedCoralEventPayload + | CommentReplyCreatedCoralEventPayload; + +export class NotifierCoralEventListener + implements CoralEventListener { + public readonly name = "notifier"; + + private readonly queue: NotifierQueue; + + constructor(queue: NotifierQueue) { + this.queue = queue; + } + + /** + * events are the events that this listener handles. These are parsed from the + * notification categories. + */ + public readonly events = categories.reduce( + (events, category) => { + for (const event of category.events) { + if (!events.includes(event)) { + events.push(event); + } + } + + return events; + }, + [] as CoralEventType[] + ); + + public initialize: CoralEventPublisherFactory< + NotifierCoralEventListenerPayloads + > = ({ tenant: { id } }) => async input => { + await this.queue.add({ tenantID: id, input }); + }; +} diff --git a/src/core/server/events/listeners/slack.ts b/src/core/server/events/listeners/slack.ts new file mode 100644 index 000000000..546285536 --- /dev/null +++ b/src/core/server/events/listeners/slack.ts @@ -0,0 +1,210 @@ +import striptags from "striptags"; + +import { reconstructTenantURL } from "coral-server/app/url"; +import GraphContext from "coral-server/graph/context"; +import logger from "coral-server/logger"; +import { getLatestRevision } from "coral-server/models/comment"; +import { getStoryTitle, getURLWithCommentID } from "coral-server/models/story"; +import { createFetch } from "coral-server/services/fetch"; + +import { GQLMODERATION_QUEUE } from "coral-server/graph/schema/__generated__/types"; + +import { + CommentEnteredModerationQueueCoralEventPayload, + CommentFeaturedCoralEventPayload, +} from "../events"; +import { CoralEventListener, CoralEventPublisherFactory } from "../publisher"; +import { CoralEventType } from "../types"; + +type SlackCoralEventListenerPayloads = + | CommentFeaturedCoralEventPayload + | CommentEnteredModerationQueueCoralEventPayload; + +type Trigger = "reported" | "pending" | "featured"; + +export class SlackCoralEventListener + implements CoralEventListener { + public readonly name = "slack"; + public readonly events = [ + CoralEventType.COMMENT_FEATURED, + CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE, + ]; + private readonly fetch = createFetch({ name: "slack" }); + + private payloadTrigger( + payload: SlackCoralEventListenerPayloads + ): Trigger | null { + switch (payload.type) { + case CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE: + if (payload.data.queue === GQLMODERATION_QUEUE.REPORTED) { + return "reported"; + } else if (payload.data.queue === GQLMODERATION_QUEUE.PENDING) { + return "pending"; + } + break; + case CoralEventType.COMMENT_FEATURED: + return "featured"; + } + + return null; + } + + /** + * postMessage will prepare and send the incoming Slack webhook. + * + * @param ctx context of the request + * @param message the message prefix for the request + * @param payload payload for the event that occurred + * @param hookURL url to the Slack webhook that we should send the message to + */ + private async postMessage( + { loaders, config, tenant, req }: GraphContext, + message: string, + payload: SlackCoralEventListenerPayloads, + hookURL: string + ) { + // Get the comment. + const comment = await loaders.Comments.comment.load(payload.data.commentID); + if (!comment || !comment.authorID) { + return; + } + + // Get the story. + const story = await loaders.Stories.story.load(payload.data.storyID); + if (!story) { + return; + } + + // Get the author. + const author = await loaders.Users.user.load(comment.authorID); + if (!author) { + return; + } + + // Get some properties about the event. + const storyTitle = getStoryTitle(story); + const moderateLink = reconstructTenantURL( + config, + tenant, + req, + `/admin/moderate/comment/${comment.id}` + ); + const commentLink = getURLWithCommentID(story.url, comment.id); + + // Replace HTML link breaks with newlines. + const body = striptags(getLatestRevision(comment).body); + + // Send the post to the Slack URL. We don't wrap this in a try/catch because + // it's handled in the calling function. + const res = await this.fetch(hookURL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `${message} on *<${story.url}|${storyTitle}>*`, + }, + }, + { type: "divider" }, + { + type: "section", + text: { + type: "plain_text", + text: body, + }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `Authored by *${author.username}* | <${moderateLink}|Go to Moderation> | <${commentLink}|See Comment>`, + }, + ], + }, + { type: "divider" }, + ], + }), + }); + + // Check that the request was completed successfully. + if (!res.ok) { + throw new Error(`slack returned non-200 status code: ${res.status}`); + } + } + + private getMessage(trigger: Trigger): string { + switch (trigger) { + case "featured": + return "This comment has been featured"; + case "pending": + return "This comment is pending"; + case "reported": + return "This comment has been reported"; + default: + throw new Error("invalid trigger"); + } + } + + public initialize: CoralEventPublisherFactory< + SlackCoralEventListenerPayloads + > = ctx => async payload => { + const { + tenant: { id: tenantID, slack }, + } = ctx; + + if ( + // If slack is not defined, + !slack || + // Or there are no slack channels, + slack.channels.length === 0 || + // Or each channel isn't enabled or configured right. + slack.channels.every(c => !c.enabled || !c.hookURL) + ) { + // Exit out then. + return; + } + + // Get the trigger that is associated with this payload. + const trigger = this.payloadTrigger(payload); + if (!trigger) { + return; + } + + // For each channel that is enabled with configuration. + for (const channel of slack.channels) { + if (!channel.enabled || !channel.hookURL) { + continue; + } + + if ( + // If featured comments are, and it's a featured comment, + (channel.triggers.featuredComments && trigger === "featured") || + // Or reported comments are, and it's a reported comment, + (channel.triggers.reportedComments && trigger === "reported") || + // Or pending comments are, and it's a pending comment, + (channel.triggers.pendingComments && trigger === "pending") + ) { + try { + // Post the message to slack. + await this.postMessage( + ctx, + this.getMessage(trigger), + payload, + channel.hookURL + ); + } catch (err) { + logger.error( + { err, tenantID, payload, channel }, + "could not post the comment to slack" + ); + } + } + } + }; +} diff --git a/src/core/server/events/listeners/subscription.ts b/src/core/server/events/listeners/subscription.ts new file mode 100644 index 000000000..e7724623d --- /dev/null +++ b/src/core/server/events/listeners/subscription.ts @@ -0,0 +1,74 @@ +import { createSubscriptionChannelName } from "coral-server/graph/resolvers/Subscription/helpers"; +import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types"; + +import { + CommentCreatedCoralEventPayload, + CommentEnteredModerationQueueCoralEventPayload, + CommentFeaturedCoralEventPayload, + CommentLeftModerationQueueCoralEventPayload, + CommentReleasedCoralEventPayload, + CommentReplyCreatedCoralEventPayload, + CommentStatusUpdatedCoralEventPayload, +} from "../events"; +import { CoralEventListener, CoralEventPublisherFactory } from "../publisher"; +import { CoralEventType } from "../types"; + +type SubscriptionCoralEventListenerPayloads = + | CommentEnteredModerationQueueCoralEventPayload + | CommentLeftModerationQueueCoralEventPayload + | CommentStatusUpdatedCoralEventPayload + | CommentReplyCreatedCoralEventPayload + | CommentCreatedCoralEventPayload + | CommentFeaturedCoralEventPayload + | CommentReleasedCoralEventPayload; + +export class SubscriptionCoralEventListener + implements CoralEventListener { + public readonly name = "subscription"; + public readonly events = [ + CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE, + CoralEventType.COMMENT_LEFT_MODERATION_QUEUE, + CoralEventType.COMMENT_STATUS_UPDATED, + CoralEventType.COMMENT_REPLY_CREATED, + CoralEventType.COMMENT_CREATED, + CoralEventType.COMMENT_FEATURED, + CoralEventType.COMMENT_RELEASED, + ]; + + private translate( + type: SubscriptionCoralEventListenerPayloads["type"] + ): SUBSCRIPTION_CHANNELS { + switch (type) { + case CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE: + return SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE; + case CoralEventType.COMMENT_LEFT_MODERATION_QUEUE: + return SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE; + case CoralEventType.COMMENT_STATUS_UPDATED: + return SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED; + case CoralEventType.COMMENT_REPLY_CREATED: + return SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED; + case CoralEventType.COMMENT_CREATED: + return SUBSCRIPTION_CHANNELS.COMMENT_CREATED; + case CoralEventType.COMMENT_FEATURED: + return SUBSCRIPTION_CHANNELS.COMMENT_FEATURED; + case CoralEventType.COMMENT_RELEASED: + return SUBSCRIPTION_CHANNELS.COMMENT_RELEASED; + } + } + + private trigger( + tenantID: string, + type: SubscriptionCoralEventListenerPayloads["type"] + ) { + return createSubscriptionChannelName(tenantID, this.translate(type)); + } + + public initialize: CoralEventPublisherFactory< + SubscriptionCoralEventListenerPayloads + > = ({ clientID, pubsub, tenant: { id } }) => async ({ type, data }) => { + await pubsub.publish(this.trigger(id, type), { + ...data, + clientID, + }); + }; +} diff --git a/src/core/server/events/listeners/webhook.ts b/src/core/server/events/listeners/webhook.ts new file mode 100644 index 000000000..fed52ec6f --- /dev/null +++ b/src/core/server/events/listeners/webhook.ts @@ -0,0 +1,85 @@ +import logger from "coral-server/logger"; +import { WebhookQueue } from "coral-server/queue/tasks/webhook"; + +import { GQLWEBHOOK_EVENT_NAME } from "coral-server/graph/schema/__generated__/types"; + +import { StoryCreatedCoralEventPayload } from "../events"; +import { CoralEventListener, CoralEventPublisherFactory } from "../publisher"; +import { CoralEventType } from "../types"; + +export type WebhookCoralEventListenerPayloads = StoryCreatedCoralEventPayload; + +export class WebhookCoralEventListener + implements CoralEventListener { + public readonly name = "webhook"; + public readonly events = [CoralEventType.STORY_CREATED]; + + private readonly queue: WebhookQueue; + + constructor(queue: WebhookQueue) { + this.queue = queue; + } + + public initialize: CoralEventPublisherFactory< + WebhookCoralEventListenerPayloads + > = ({ id: contextID, tenant }) => async event => { + const log = logger.child( + { + tenantID: tenant.id, + contextID, + eventType: event.type, + }, + true + ); + + // Based on the incoming event, determine which endpoints we should send. + const endpoints = tenant.webhooks.endpoints.filter(endpoint => { + // If the endpoint is disabled, don't include it. + if (!endpoint.enabled) { + return false; + } + + // If all notifications have been enabled for this endpoint, include it. + if (endpoint.all) { + return true; + } + + // If this event name is specifically listed, include it. We have to do + // some nasty casting here to address the fact that the types don't + // technically overlap. + if ( + endpoint.events.includes( + (event.type as unknown) as GQLWEBHOOK_EVENT_NAME + ) + ) { + return true; + } + + return false; + }); + + // Log some important details. + if (endpoints.length === 0) { + log.debug("no endpoints matched for event"); + return; + } + + log.debug( + { endpoints: endpoints.length }, + "matched endpoints that will receive event" + ); + + // For each of these endpoints that need a delivery of these notifications, + // queue up the job that will send it. + await Promise.all( + endpoints.map(endpoint => + this.queue.add({ + tenantID: tenant.id, + contextID, + endpointID: endpoint.id, + event, + }) + ) + ); + }; +} diff --git a/src/core/server/events/publisher.ts b/src/core/server/events/publisher.ts new file mode 100644 index 000000000..254fa21d7 --- /dev/null +++ b/src/core/server/events/publisher.ts @@ -0,0 +1,125 @@ +/* eslint-disable max-classes-per-file */ + +import GraphContext from "coral-server/graph/context"; +import logger from "coral-server/logger"; + +import { CoralEventPayload } from "./event"; +import { CoralEventType } from "./types"; + +export type CoralEventPublisher = ( + payload: T +) => Promise; + +export type CoralEventPublisherFactory = ( + ctx: GraphContext +) => CoralEventPublisher; + +export abstract class CoralEventListener { + /** + * name is the name of the listener used for identification in logs. + */ + public abstract readonly name: string; + + /** + * events is the array of event types that this listener should listen for. + */ + public abstract readonly events: CoralEventType[]; + + /** + * initialize is a function that when + */ + public abstract initialize: CoralEventPublisherFactory; +} + +export class CoralEventPublisherBroker { + private readonly ctx: GraphContext; + private readonly events: Set; + private readonly listeners: CoralEventListener[]; + private registry?: Map; + + constructor( + ctx: GraphContext, + events: Set, + listeners: CoralEventListener[] + ) { + this.ctx = ctx; + this.events = events; + this.listeners = listeners; + } + + private initialize() { + const registry = new Map(); + + // Iterate over the listeners to initialize them. + for (const listener of this.listeners) { + // Initialize this listener. + const publisher = listener.initialize(this.ctx); + + // Associate the publisher with each of the events. + for (const event of listener.events) { + // Get the current publishers associated with this event. + const publishers = registry.get(event) || []; + + // Add this publisher to the array. + publishers.push(publisher); + + // Update this item in the registry. + registry.set(event, publishers); + } + } + + return registry; + } + + public emit = (payload: CoralEventPayload) => { + // Check to see if this event is even registered. + if (!this.events.has(payload.type)) { + return; + } + + // Lazily create the registry. + if (!this.registry) { + this.registry = this.initialize(); + } + + // Get the current publishers for this event. We can assert that this is + // found because the event was checked in the above events set. If the event + // did not exist in the events set, then it does not have an associated + // registry entry. + const publishers = this.registry.get(payload.type)!; + + // Begin resolving these publishers. + return Promise.all(publishers.map(publisher => publisher(payload))); + }; +} + +export default class CoralEventListenerBroker { + private readonly events = new Set(); + private readonly listeners: CoralEventListener[] = []; + + public instance = (ctx: GraphContext) => + new CoralEventPublisherBroker(ctx, this.events, this.listeners); + + public register(listener: CoralEventListener) { + if (listener.events.length === 0) { + logger.warn( + { listenerName: listener.name }, + "listener was registered without any events" + ); + return; + } + + logger.trace( + { listenerName: listener.name, listenerEvents: listener.events }, + "registering listener for events" + ); + + // Add this listener to this listener set. + this.listeners.push(listener); + + // Add each event to the set of registered events. + for (const event of listener.events) { + this.events.add(event); + } + } +} diff --git a/src/core/server/events/types.ts b/src/core/server/events/types.ts new file mode 100644 index 000000000..9737f1298 --- /dev/null +++ b/src/core/server/events/types.ts @@ -0,0 +1,10 @@ +export enum CoralEventType { + COMMENT_ENTERED_MODERATION_QUEUE = "COMMENT_ENTERED_MODERATION_QUEUE", + COMMENT_LEFT_MODERATION_QUEUE = "COMMENT_LEFT_MODERATION_QUEUE", + COMMENT_STATUS_UPDATED = "COMMENT_STATUS_UPDATED", + COMMENT_REPLY_CREATED = "COMMENT_REPLY_CREATED", + COMMENT_CREATED = "COMMENT_CREATED", + COMMENT_FEATURED = "COMMENT_FEATURED", + COMMENT_RELEASED = "COMMENT_RELEASED", + STORY_CREATED = "STORY_CREATED", +} diff --git a/src/core/server/graph/context.ts b/src/core/server/graph/context.ts index 2521c3802..92a9b5b84 100644 --- a/src/core/server/graph/context.ts +++ b/src/core/server/graph/context.ts @@ -4,21 +4,18 @@ import uuid from "uuid"; import { LanguageCode } from "coral-common/helpers/i18n/locales"; import { Config } from "coral-server/config"; -import { - createPublisher, - Publisher, -} from "coral-server/graph/subscriptions/publisher"; +import CoralEventListenerBroker, { + CoralEventPublisherBroker, +} from "coral-server/events/publisher"; import logger, { Logger } from "coral-server/logger"; import { PersistedQuery } from "coral-server/models/queries"; import { Tenant } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; -import { NotifierQueue } from "coral-server/queue/tasks/notifier"; import { ScraperQueue } from "coral-server/queue/tasks/scraper"; import { I18n } from "coral-server/services/i18n"; import { JWTSigningConfig } from "coral-server/services/jwt"; import { AugmentedRedis } from "coral-server/services/redis"; -import createSlackPublisher from "coral-server/services/slack/publisher"; import TenantCache from "coral-server/services/tenant/cache"; import { Request } from "coral-server/types/express"; @@ -41,16 +38,17 @@ export interface GraphContextOptions { i18n: I18n; mailerQueue: MailerQueue; mongo: Db; - notifierQueue: NotifierQueue; pubsub: RedisPubSub; redis: AugmentedRedis; scraperQueue: ScraperQueue; tenant: Tenant; tenantCache: TenantCache; + broker: CoralEventListenerBroker; } export default class GraphContext { public readonly config: Config; + public readonly broker: CoralEventPublisherBroker; public readonly disableCaching: boolean; public readonly i18n: I18n; public readonly id: string; @@ -61,7 +59,6 @@ export default class GraphContext { public readonly mongo: Db; public readonly mutators: ReturnType; public readonly now: Date; - public readonly publisher: Publisher; public readonly pubsub: RedisPubSub; public readonly redis: AugmentedRedis; public readonly scraperQueue: ScraperQueue; @@ -100,18 +97,7 @@ export default class GraphContext { this.signingConfig = options.signingConfig; this.clientID = options.clientID; - this.publisher = createPublisher({ - pubsub: this.pubsub, - slackPublisher: createSlackPublisher( - this.mongo, - this.config, - this.tenant - ), - notifierQueue: options.notifierQueue, - tenantID: this.tenant.id, - clientID: this.clientID, - }); - + this.broker = options.broker.instance(this); this.loaders = loaders(this); this.mutators = mutators(this); } diff --git a/src/core/server/graph/loaders/Stories.ts b/src/core/server/graph/loaders/Stories.ts index 0a597ee73..48f6725ac 100644 --- a/src/core/server/graph/loaders/Stories.ts +++ b/src/core/server/graph/loaders/Stories.ts @@ -81,7 +81,14 @@ const primeStoriesFromConnection = (ctx: GraphContext) => ( export default (ctx: GraphContext) => ({ findOrCreate: new DataLoader( createManyBatchLoadFn((input: FindOrCreateStory) => - findOrCreate(ctx.mongo, ctx.tenant, input, ctx.scraperQueue, ctx.now) + findOrCreate( + ctx.mongo, + ctx.tenant, + ctx.broker, + input, + ctx.scraperQueue, + ctx.now + ) ), { // TODO: (wyattjoh) see if there's something we can do to improve the cache key diff --git a/src/core/server/graph/mutators/Actions.ts b/src/core/server/graph/mutators/Actions.ts index 7cd3faee6..cf396ffec 100644 --- a/src/core/server/graph/mutators/Actions.ts +++ b/src/core/server/graph/mutators/Actions.ts @@ -12,7 +12,7 @@ export const Actions = (ctx: GraphContext) => ({ ctx.mongo, ctx.redis, ctx.config, - ctx.publisher, + ctx.broker, ctx.tenant, input.commentID, input.commentRevisionID, @@ -24,7 +24,7 @@ export const Actions = (ctx: GraphContext) => ({ ctx.mongo, ctx.redis, ctx.config, - ctx.publisher, + ctx.broker, ctx.tenant, input.commentID, input.commentRevisionID, diff --git a/src/core/server/graph/mutators/Comments.ts b/src/core/server/graph/mutators/Comments.ts index 6b9822cdd..8ed0b3726 100644 --- a/src/core/server/graph/mutators/Comments.ts +++ b/src/core/server/graph/mutators/Comments.ts @@ -45,7 +45,7 @@ export const Comments = (ctx: GraphContext) => ({ ctx.mongo, ctx.redis, ctx.config, - ctx.publisher, + ctx.broker, ctx.tenant, ctx.user!, { authorID: ctx.user!.id, ...comment }, @@ -68,7 +68,7 @@ export const Comments = (ctx: GraphContext) => ({ ctx.mongo, ctx.redis, ctx.config, - ctx.publisher, + ctx.broker, ctx.tenant, ctx.user!, { @@ -92,7 +92,7 @@ export const Comments = (ctx: GraphContext) => ({ createReaction( ctx.mongo, ctx.redis, - ctx.publisher, + ctx.broker, ctx.tenant, ctx.user!, { @@ -102,7 +102,7 @@ export const Comments = (ctx: GraphContext) => ({ ctx.now ), removeReaction: ({ commentID }: GQLRemoveCommentReactionInput) => - removeReaction(ctx.mongo, ctx.redis, ctx.publisher, ctx.tenant, ctx.user!, { + removeReaction(ctx.mongo, ctx.redis, ctx.broker, ctx.tenant, ctx.user!, { commentID, }), createDontAgree: ({ @@ -113,7 +113,7 @@ export const Comments = (ctx: GraphContext) => ({ createDontAgree( ctx.mongo, ctx.redis, - ctx.publisher, + ctx.broker, ctx.tenant, ctx.user!, { @@ -128,14 +128,9 @@ export const Comments = (ctx: GraphContext) => ({ ctx.now ), removeDontAgree: ({ commentID }: GQLRemoveCommentDontAgreeInput) => - removeDontAgree( - ctx.mongo, - ctx.redis, - ctx.publisher, - ctx.tenant, - ctx.user!, - { commentID } - ), + removeDontAgree(ctx.mongo, ctx.redis, ctx.broker, ctx.tenant, ctx.user!, { + commentID, + }), createFlag: ({ commentID, commentRevisionID, @@ -145,7 +140,7 @@ export const Comments = (ctx: GraphContext) => ({ createFlag( ctx.mongo, ctx.redis, - ctx.publisher, + ctx.broker, ctx.tenant, ctx.user!, { @@ -179,7 +174,7 @@ export const Comments = (ctx: GraphContext) => ({ ctx.mongo, ctx.redis, ctx.config, - ctx.publisher, + ctx.broker, ctx.tenant, commentID, commentRevisionID, @@ -190,7 +185,7 @@ export const Comments = (ctx: GraphContext) => ({ ) .then(comment => { // Publish that the comment was featured. - publishCommentFeatured(ctx.publisher, comment); + publishCommentFeatured(ctx.broker, comment); // Return it to the next step. return comment; diff --git a/src/core/server/graph/mutators/Settings.ts b/src/core/server/graph/mutators/Settings.ts index a2918d1e6..2b2908a01 100644 --- a/src/core/server/graph/mutators/Settings.ts +++ b/src/core/server/graph/mutators/Settings.ts @@ -2,19 +2,33 @@ import GraphContext from "coral-server/graph/context"; import { Tenant } from "coral-server/models/tenant"; import { createAnnouncement, + createWebhookEndpoint, deleteAnnouncement, + deleteWebhookEndpoint, disableFeatureFlag, + disableWebhookEndpoint, enableFeatureFlag, + enableWebhookEndpoint, regenerateSSOKey, + rotateWebhookEndpointSecret, update, + updateWebhookEndpoint, } from "coral-server/services/tenant"; import { GQLCreateAnnouncementInput, + GQLCreateWebhookEndpointInput, + GQLDeleteWebhookEndpointInput, + GQLDisableWebhookEndpointInput, + GQLEnableWebhookEndpointInput, GQLFEATURE_FLAG, + GQLRotateWebhookEndpointSecretInput, GQLUpdateSettingsInput, + GQLUpdateWebhookEndpointInput, } from "coral-server/graph/schema/__generated__/types"; +import { WithoutMutationID } from "./util"; + export const Settings = ({ mongo, redis, @@ -23,7 +37,9 @@ export const Settings = ({ config, now, }: GraphContext) => ({ - update: (input: GQLUpdateSettingsInput): Promise => + update: ( + input: WithoutMutationID + ): Promise => update(mongo, redis, tenantCache, config, tenant, input.settings), regenerateSSOKey: (): Promise => regenerateSSOKey(mongo, redis, tenantCache, tenant, now), @@ -35,4 +51,42 @@ export const Settings = ({ createAnnouncement(mongo, redis, tenantCache, tenant, input, now), deleteAnnouncement: () => deleteAnnouncement(mongo, redis, tenantCache, tenant), + createWebhookEndpoint: ( + input: WithoutMutationID + ) => + createWebhookEndpoint( + mongo, + redis, + config, + tenantCache, + tenant, + input, + now + ), + enableWebhookEndpoint: ( + input: WithoutMutationID + ) => enableWebhookEndpoint(mongo, redis, tenantCache, tenant, input.id), + disableWebhookEndpoint: ( + input: WithoutMutationID + ) => disableWebhookEndpoint(mongo, redis, tenantCache, tenant, input.id), + updateWebhookEndpoint: ({ + id, + ...input + }: WithoutMutationID) => + updateWebhookEndpoint(mongo, redis, config, tenantCache, tenant, id, input), + deleteWebhookEndpoint: ( + input: WithoutMutationID + ) => deleteWebhookEndpoint(mongo, redis, tenantCache, tenant, input.id), + rotateWebhookEndpointSecret: ( + input: WithoutMutationID + ) => + rotateWebhookEndpointSecret( + mongo, + redis, + tenantCache, + tenant, + input.id, + input.inactiveIn, + now + ), }); diff --git a/src/core/server/graph/mutators/Stories.ts b/src/core/server/graph/mutators/Stories.ts index da544faaa..aa2611606 100644 --- a/src/core/server/graph/mutators/Stories.ts +++ b/src/core/server/graph/mutators/Stories.ts @@ -32,6 +32,7 @@ export const Stories = (ctx: GraphContext) => ({ create( ctx.mongo, ctx.tenant, + ctx.broker, ctx.config, input.story.id, input.story.url, diff --git a/src/core/server/graph/resolvers/ModerationQueues.ts b/src/core/server/graph/resolvers/ModerationQueues.ts index 658712e8b..1bad2f1ec 100644 --- a/src/core/server/graph/resolvers/ModerationQueues.ts +++ b/src/core/server/graph/resolvers/ModerationQueues.ts @@ -86,7 +86,7 @@ export const storyModerationInputResolver = ( * * @param source the source of the type, not used * @param args the args of the type, not used - * @param ctx the TenantContext that will be used to get the shared counts + * @param ctx the GraphContext that will be used to get the shared counts */ export const sharedModerationInputResolver = async ( source: any, @@ -106,7 +106,7 @@ export const sharedModerationInputResolver = async ( * * @param source the source of the payload, not used * @param args the args of the payload containing potentially a Story ID - * @param ctx the TenantContext for which we can use to retrieve the shared data + * @param ctx the GraphContext for which we can use to retrieve the shared data */ export const moderationQueuesResolver: QueryToModerationQueuesResolver = async ( source, diff --git a/src/core/server/graph/resolvers/Mutation.ts b/src/core/server/graph/resolvers/Mutation.ts index 4a1930f37..c194922b7 100644 --- a/src/core/server/graph/resolvers/Mutation.ts +++ b/src/core/server/graph/resolvers/Mutation.ts @@ -33,9 +33,13 @@ export const Mutation: Required> = { user: await ctx.mutators.Users.updateNotificationSettings(input), clientMutationId, }), - updateSettings: async (source, { input }, ctx) => ({ + updateSettings: async ( + source, + { input: { clientMutationId, ...input } }, + ctx + ) => ({ settings: await ctx.mutators.Settings.update(input), - clientMutationId: input.clientMutationId, + clientMutationId, }), createCommentReaction: async (source, { input }, ctx) => ({ comment: await ctx.mutators.Comments.createReaction(input), @@ -252,4 +256,52 @@ export const Mutation: Required> = { site: await ctx.mutators.Sites.update(input), clientMutationId: input.clientMutationId, }), + createWebhookEndpoint: async ( + source, + { input: { clientMutationId, ...input } }, + ctx + ) => ({ + ...(await ctx.mutators.Settings.createWebhookEndpoint(input)), + clientMutationId, + }), + updateWebhookEndpoint: async ( + source, + { input: { clientMutationId, ...input } }, + ctx + ) => ({ + endpoint: await ctx.mutators.Settings.updateWebhookEndpoint(input), + clientMutationId, + }), + disableWebhookEndpoint: async ( + source, + { input: { clientMutationId, ...input } }, + ctx + ) => ({ + endpoint: await ctx.mutators.Settings.disableWebhookEndpoint(input), + clientMutationId, + }), + enableWebhookEndpoint: async ( + source, + { input: { clientMutationId, ...input } }, + ctx + ) => ({ + endpoint: await ctx.mutators.Settings.enableWebhookEndpoint(input), + clientMutationId, + }), + deleteWebhookEndpoint: async ( + source, + { input: { clientMutationId, ...input } }, + ctx + ) => ({ + endpoint: await ctx.mutators.Settings.deleteWebhookEndpoint(input), + clientMutationId, + }), + rotateWebhookEndpointSecret: async ( + source, + { input: { clientMutationId, ...input } }, + ctx + ) => ({ + endpoint: await ctx.mutators.Settings.rotateWebhookEndpointSecret(input), + clientMutationId, + }), }; diff --git a/src/core/server/graph/resolvers/Query.ts b/src/core/server/graph/resolvers/Query.ts index 82c51ac8d..822eb616a 100644 --- a/src/core/server/graph/resolvers/Query.ts +++ b/src/core/server/graph/resolvers/Query.ts @@ -1,3 +1,5 @@ +import { getWebhookEndpoint } from "coral-server/models/tenant"; + import { GQLQueryTypeResolver } from "coral-server/graph/schema/__generated__/types"; import { moderationQueuesResolver } from "./ModerationQueues"; @@ -25,4 +27,5 @@ export const Query: Required> = { ctx.loaders.Stories.activeStories(limit), sites: (source, args, ctx) => ctx.loaders.Sites.connection(args), site: (source, { id }, ctx) => (id ? ctx.loaders.Sites.site.load(id) : null), + webhookEndpoint: (source, { id }, ctx) => getWebhookEndpoint(ctx.tenant, id), }; diff --git a/src/core/server/graph/resolvers/SSOAuthIntegration.ts b/src/core/server/graph/resolvers/SSOAuthIntegration.ts index 117db08db..978f72e03 100644 --- a/src/core/server/graph/resolvers/SSOAuthIntegration.ts +++ b/src/core/server/graph/resolvers/SSOAuthIntegration.ts @@ -2,7 +2,7 @@ import * as settings from "coral-server/models/settings"; import { GQLSSOAuthIntegrationTypeResolver } from "coral-server/graph/schema/__generated__/types"; -function getActiveSSOKey(keys: settings.SSOKey[]) { +function getActiveSSOKey(keys: settings.Secret[]) { // Any key that has been rotated cannot be the active key. return keys.find(key => !key.rotatedAt); } diff --git a/src/core/server/graph/resolvers/Settings.ts b/src/core/server/graph/resolvers/Settings.ts index fe0939ab8..a4f1549d9 100644 --- a/src/core/server/graph/resolvers/Settings.ts +++ b/src/core/server/graph/resolvers/Settings.ts @@ -6,6 +6,7 @@ import { import { GQLFEATURE_FLAG, GQLSettingsTypeResolver, + GQLWEBHOOK_EVENT_NAME, } from "coral-server/graph/schema/__generated__/types"; const filterValidFeatureFlags = () => { @@ -27,4 +28,5 @@ export const Settings: GQLSettingsTypeResolver = { const sites = await ctx.loaders.Sites.connection({}); return sites.edges.length > 1; }, + webhookEvents: () => Object.values(GQLWEBHOOK_EVENT_NAME), }; diff --git a/src/core/server/graph/resolvers/Subscription/index.ts b/src/core/server/graph/resolvers/Subscription/index.ts index f2bb56c81..754a060fc 100644 --- a/src/core/server/graph/resolvers/Subscription/index.ts +++ b/src/core/server/graph/resolvers/Subscription/index.ts @@ -17,3 +17,13 @@ export const Subscription: GQLSubscriptionTypeResolver = { commentFeatured, commentReleased, }; + +export { CommentFeaturedInput } from "./commentFeatured"; +export { CommentCreatedInput } from "./commentCreated"; +export { + CommentEnteredModerationQueueInput, +} from "./commentEnteredModerationQueue"; +export { CommentLeftModerationQueueInput } from "./commentLeftModerationQueue"; +export { CommentReleasedInput } from "./commentReleased"; +export { CommentReplyCreatedInput } from "./commentReplyCreated"; +export { CommentStatusUpdatedInput } from "./commentStatusUpdated"; diff --git a/src/core/server/graph/resolvers/WebhookEndpoint.ts b/src/core/server/graph/resolvers/WebhookEndpoint.ts new file mode 100644 index 000000000..6b613dcd0 --- /dev/null +++ b/src/core/server/graph/resolvers/WebhookEndpoint.ts @@ -0,0 +1,10 @@ +import * as tenant from "coral-server/models/tenant"; + +import { GQLWebhookEndpointTypeResolver } from "coral-server/graph/schema/__generated__/types"; + +export const WebhookEndpoint: GQLWebhookEndpointTypeResolver< + tenant.Endpoint +> = { + signingSecret: ({ signingSecrets }) => + signingSecrets[signingSecrets.length - 1], +}; diff --git a/src/core/server/graph/resolvers/index.ts b/src/core/server/graph/resolvers/index.ts index 941c1eff6..0ba8f620d 100644 --- a/src/core/server/graph/resolvers/index.ts +++ b/src/core/server/graph/resolvers/index.ts @@ -50,6 +50,7 @@ import { User } from "./User"; import { UsernameHistory } from "./UsernameHistory"; import { UsernameStatus } from "./UsernameStatus"; import { UserStatus } from "./UserStatus"; +import { WebhookEndpoint } from "./WebhookEndpoint"; const Resolvers: GQLResolver = { ApproveCommentPayload, @@ -101,6 +102,7 @@ const Resolvers: GQLResolver = { UserStatus, Settings, SlackConfiguration, + WebhookEndpoint, }; export default Resolvers; diff --git a/src/core/server/graph/schema/schema.graphql b/src/core/server/graph/schema/schema.graphql index d7938bb88..d877ea72b 100644 --- a/src/core/server/graph/schema/schema.graphql +++ b/src/core/server/graph/schema/schema.graphql @@ -1002,7 +1002,7 @@ type SlackChannel { triggers are the filters of types of comments that will be sent to the correlated channel """ - triggers: SlackChannelTriggers + triggers: SlackChannelTriggers! } ################################################################################ @@ -1204,6 +1204,79 @@ type StaffConfiguration { label: String! } +type WebhookDelivery { + success: Boolean! + status: Int! + statusText: String! + request: String! + response: String! + createdAt: Time! +} + +enum WEBHOOK_EVENT_NAME { + STORY_CREATED +} + +""" +TODO: merge with SSOKey with PR #2732 +""" +type Secret { + """ + secret is the actual underlying secret used to verify the tokens with. + """ + secret: String! + + """ + createdAt is the date that the key was created at. + """ + createdAt: Time! +} + +type WebhookEndpoint { + """ + id is the unique identifier for this specific endpoint. + """ + id: ID! + + """ + enabled when true will enable events to be sent to this endpoint. + """ + enabled: Boolean! + + """ + url is the URL that we will POST event data to. + """ + url: String! + + """ + signingSecret is the current secret used to sign the events sent out. + """ + signingSecret: Secret! + + """ + deliveries store the deliveries for each event sent for the last 50 events. + """ + deliveries: [WebhookDelivery!]! + + """ + all is true when all events are subscribed to. + """ + all: Boolean! + + """ + events are the specific event names that this endpoint is configured to send + for. + """ + events: [WEBHOOK_EVENT_NAME!]! +} + +type WebhookConfiguration { + """ + endpoints is all the configured endpoints that should receive events. + """ + endpoints: [WebhookEndpoint!]! +} + """ NewCommenterConfiguration specifies the features that apply to new commenters """ @@ -1266,6 +1339,16 @@ type Settings { """ domain: String! @auth(roles: [ADMIN]) + """ + webhooks store the webhook configuration. + """ + webhooks: WebhookConfiguration! @auth(roles: [ADMIN]) + + """ + webhookEvents returns all the events that can trigger webhooks. + """ + webhookEvents: [WEBHOOK_EVENT_NAME!]! @auth(roles: [ADMIN]) + """ staticURI if configured, is the static URI used to serve static files from. """ @@ -2891,6 +2974,11 @@ type Query { activeStories(limit: Int = 10 @constraint(max: 25)): [Story!]! @auth(roles: [ADMIN]) @rate(max: 2, seconds: 1) + + """ + webhookEndpint will return a specific WebhookEndpoint if it exists. + """ + webhookEndpoint(id: ID!): WebhookEndpoint @auth(roles: [ADMIN]) } ################################################################################ @@ -4768,6 +4856,212 @@ type DeleteModeratorNotePayload { user: User! } +################## +# createWebhookEndpoint +################## + +input CreateWebhookEndpointInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + url is the URL that Coral will POST event data to. + """ + url: String! + + """ + all is true when all events are subscribed to. + """ + all: Boolean! + + """ + events are the specific event names that this endpoint is configured to send + for. + """ + events: [WEBHOOK_EVENT_NAME!]! +} + +type CreateWebhookEndpointPayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + endpoint is the endpoint that we just created. + """ + endpoint: WebhookEndpoint! + + """ + settings is the updated settings also containing the new endpoint. + """ + settings: Settings! +} + +################## +# updateWebhookEndpoint +################## + +input UpdateWebhookEndpointInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + id is the ID of the WebhookEndpoint being updated. + """ + id: ID! + + """ + url is the URL that Coral will POST event data to. + """ + url: String + + """ + all is true when all events are subscribed to. + """ + all: Boolean + + """ + events are the specific event names that this endpoint is configured to send + for. + """ + events: [WEBHOOK_EVENT_NAME!] +} + +type UpdateWebhookEndpointPayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + endpoint is the endpoint that we just created. + """ + endpoint: WebhookEndpoint! +} + +################## +# rotateWebhookEndpointSecret +################## + +input RotateWebhookEndpointSecretInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + id is the ID of the WebhookEndpoint being updated. + """ + id: ID! + + """ + inactiveIn is the number of seconds that the current active Secret should be + kept active. + """ + inactiveIn: Int! +} + +type RotateWebhookEndpointSecretPayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + endpoint is the endpoint that we just updated. + """ + endpoint: WebhookEndpoint +} + +################## +# disableWebhookEndpoint +################## + +input DisableWebhookEndpointInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + id is the ID of the WebhookEndpoint being disabled. + """ + id: ID! +} + +type DisableWebhookEndpointPayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + endpoint is the endpoint that we just disabled. + """ + endpoint: WebhookEndpoint +} + +################## +# enableWebhookEndpoint +################## + +input EnableWebhookEndpointInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + id is the ID of the WebhookEndpoint being enabled. + """ + id: ID! +} + +type EnableWebhookEndpointPayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + endpoint is the endpoint that we just enabled. + """ + endpoint: WebhookEndpoint +} + +################## +# deleteWebhookEndpoint +################## + +input DeleteWebhookEndpointInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + id is the ID of the WebhookEndpoint being deleted. + """ + id: ID! +} + +type DeleteWebhookEndpointPayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + endpoint is the endpoint that we just deleted. + """ + endpoint: WebhookEndpoint +} + ################## # setEmail ################## @@ -5899,6 +6193,49 @@ type Mutation { createSite(input: CreateSiteInput!): CreateSitePayload! @auth(roles: [ADMIN]) updateSite(input: UpdateSiteInput!): UpdateSitePayload! @auth(roles: [ADMIN]) + + """ + createWebhookEndpoint will create a new WebhookEndpoint. + """ + createWebhookEndpoint( + input: CreateWebhookEndpointInput! + ): CreateWebhookEndpointPayload! @auth(roles: [ADMIN]) + + """ + updateWebhookEndpoint will update a WebhookEndpoint. + """ + updateWebhookEndpoint( + input: UpdateWebhookEndpointInput! + ): UpdateWebhookEndpointPayload! @auth(roles: [ADMIN]) + + """ + enableWebhookEndpoint will enable a WebhookEndpoint to recieve new events. + """ + enableWebhookEndpoint( + input: EnableWebhookEndpointInput! + ): EnableWebhookEndpointPayload! @auth(roles: [ADMIN]) + + """ + disableWebhookEndpoint will disable a WebhookEndpoint from recieving new + events. + """ + disableWebhookEndpoint( + input: DisableWebhookEndpointInput! + ): DisableWebhookEndpointPayload! @auth(roles: [ADMIN]) + + """ + deleteWebhookEndpoint will delete a WebhookEndpoint. + """ + deleteWebhookEndpoint( + input: DeleteWebhookEndpointInput! + ): DeleteWebhookEndpointPayload! @auth(roles: [ADMIN]) + + """ + rotateWebhookEndpointSecret will roll the current active secret to a new key. + """ + rotateWebhookEndpointSecret( + input: RotateWebhookEndpointSecretInput! + ): RotateWebhookEndpointSecretPayload! @auth(roles: [ADMIN]) } ################## diff --git a/src/core/server/graph/subscriptions/publisher.ts b/src/core/server/graph/subscriptions/publisher.ts deleted file mode 100644 index 79e46fb27..000000000 --- a/src/core/server/graph/subscriptions/publisher.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { RedisPubSub } from "graphql-redis-subscriptions"; - -import { createSubscriptionChannelName } from "coral-server/graph/resolvers/Subscription/helpers"; -import { SUBSCRIPTION_INPUT } from "coral-server/graph/resolvers/Subscription/types"; -import logger from "coral-server/logger"; -import { NotifierQueue } from "coral-server/queue/tasks/notifier"; -import { SlackPublisher } from "coral-server/services/slack/publisher"; - -export type Publisher = (input: SUBSCRIPTION_INPUT) => Promise; - -export interface PublisherOptions { - pubsub: RedisPubSub; - slackPublisher: SlackPublisher; - notifierQueue: NotifierQueue; - tenantID: string; - clientID?: string; -} - -/** - * createPublisher will create a new Publisher that can be used to send events - * over the pubsub broker to facilitate live updates and notifications. - * - * TODO: Update - * - * @param options options object - * @param options.pubsub the pubsub broker to be used to facilitate the publish action - * @param options.slackPublisher the slack publisher instance - * @param options.notifierQueue the queue - * @param options.tenantID the ID of the Tenant where the event will be published with - * @param options.clientID the ID of the client to de-duplicate mutation responses - */ -export const createPublisher = ({ - pubsub, - slackPublisher, - notifierQueue, - tenantID, - clientID, -}: PublisherOptions): Publisher => async input => { - const { channel, payload } = input; - - logger.trace({ channel, tenantID, clientID }, "publishing event"); - - // Start the publishing operation out to all affected subscribers. - await Promise.all([ - // Publish to the underlying pubsub system for subscriptions. - pubsub.publish(createSubscriptionChannelName(tenantID, channel), { - ...payload, - clientID, - }), - - slackPublisher(channel, payload), - - // Notify the notifications queue so we can offload notification processing - // to it. - notifierQueue.add({ tenantID, input }), - ]); -}; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 0cfad477d..d43de169c 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -37,6 +37,11 @@ import { } from "coral-server/services/redis"; import TenantCache from "coral-server/services/tenant/cache"; +import { NotifierCoralEventListener } from "./events/listeners/notifier"; +import { SlackCoralEventListener } from "./events/listeners/slack"; +import { SubscriptionCoralEventListener } from "./events/listeners/subscription"; +import { WebhookCoralEventListener } from "./events/listeners/webhook"; +import CoralEventListenerBroker from "./events/publisher"; import { isInstalled } from "./services/tenant"; export interface ServerOptions { @@ -108,6 +113,12 @@ class Server { // migrationManager is the manager for performing migrations on Coral. private migrationManager: MigrationManager; + /** + * broker stores a reference to all of the listeners that can be used in + * conjunction with an event to publish activity occurring inside Coral. + */ + private broker: CoralEventListenerBroker; + constructor(options: ServerOptions) { this.parentApp = express(); @@ -190,6 +201,7 @@ class Server { this.tasks = await createQueue({ config: this.config, mongo: this.mongo, + redis: this.redis, tenantCache: this.tenantCache, i18n: this.i18n, signingConfig: this.signingConfig, @@ -201,6 +213,13 @@ class Server { createRedisClient(this.config) ); + // Setup the broker. + this.broker = new CoralEventListenerBroker(); + this.broker.register(new NotifierCoralEventListener(this.tasks.notifier)); + this.broker.register(new SlackCoralEventListener()); + this.broker.register(new SubscriptionCoralEventListener()); + this.broker.register(new WebhookCoralEventListener(this.tasks.webhook)); + // Setup the metrics collectors. collectDefaultMetrics({ timeout: 5000 }); } @@ -233,6 +252,7 @@ class Server { this.tasks.mailer.process(); this.tasks.scraper.process(); this.tasks.notifier.process(); + this.tasks.webhook.process(); // Start up the cron job processors. this.scheduledTasks = startScheduledTasks({ @@ -323,6 +343,7 @@ class Server { const options: AppOptions = { parent, + broker: this.broker, pubsub: this.pubsub, mongo: this.mongo, redis: this.redis, @@ -333,7 +354,6 @@ class Server { i18n: this.i18n, mailerQueue: this.tasks.mailer, scraperQueue: this.tasks.scraper, - notifierQueue: this.tasks.notifier, disableClientRoutes, persistedQueryCache: this.persistedQueryCache, persistedQueriesRequired: diff --git a/src/core/server/models/settings/index.ts b/src/core/server/models/settings/index.ts new file mode 100644 index 000000000..5fd61cce8 --- /dev/null +++ b/src/core/server/models/settings/index.ts @@ -0,0 +1,2 @@ +export * from "./settings"; +export * from "./secret"; diff --git a/src/core/server/models/settings/secret.ts b/src/core/server/models/settings/secret.ts new file mode 100644 index 000000000..e23f6abfe --- /dev/null +++ b/src/core/server/models/settings/secret.ts @@ -0,0 +1,44 @@ +export interface Secret { + /** + * kid is the identifier for the key used when verifying tokens issued by the + * provider. + */ + kid: string; + + /** + * secret is the actual underlying secret used to verify the tokens with. + */ + secret: string; + + /** + * createdAt is the date that the key was created at. + */ + createdAt: Date; + + /** + * rotatedAt is the time that the token was rotated out. + */ + rotatedAt?: Date; + + /** + * inactiveAt is the date that the token can no longer be used to validate + * tokens. + */ + inactiveAt?: Date; +} + +export function isSecretExpired({ inactiveAt }: Secret, now = new Date()) { + if (inactiveAt && inactiveAt <= now) { + return true; + } + + return false; +} + +export function filterExpiredSecrets(now = new Date()) { + return (secret: Secret) => isSecretExpired(secret, now); +} + +export function filterActiveSecrets(now = new Date()) { + return (secret: Secret) => !isSecretExpired(secret, now); +} diff --git a/src/core/server/models/settings.ts b/src/core/server/models/settings/settings.ts similarity index 86% rename from src/core/server/models/settings.ts rename to src/core/server/models/settings/settings.ts index b48cea77b..b4ae22d44 100644 --- a/src/core/server/models/settings.ts +++ b/src/core/server/models/settings/settings.ts @@ -13,6 +13,8 @@ import { GQLSettings, } from "coral-server/graph/schema/__generated__/types"; +import { Secret } from "./secret"; + export type LiveConfiguration = Omit; export type EmailConfiguration = GQLEmailConfiguration; @@ -38,40 +40,11 @@ export type FacebookAuthIntegration = Omit< "callbackURL" | "redirectURL" >; -export interface SSOKey { - /** - * kid is the identifier for the key used when verifying tokens issued by the - * provider. - */ - kid: string; - - /** - * secret is the actual underlying secret used to verify the tokens with. - */ - secret: string; - - /** - * createdAt is the date that the key was created at. - */ - createdAt: Date; - - /** - * rotatedAt is the time that the token was rotated out. - */ - rotatedAt?: Date; - - /** - * inactiveAt is the date that the token can no longer be used to validate - * tokens. - */ - inactiveAt?: Date; -} - export interface SSOAuthIntegration { enabled: boolean; allowRegistration: boolean; targetFilter: GQLAuthenticationTargetFilter; - keys: SSOKey[]; + keys: Secret[]; } /** diff --git a/src/core/server/models/story/index.ts b/src/core/server/models/story/index.ts index fcf5c455c..a2b5235f6 100644 --- a/src/core/server/models/story/index.ts +++ b/src/core/server/models/story/index.ts @@ -93,24 +93,27 @@ export interface UpsertStoryInput { siteID: string; } +export interface UpsertStoryResult { + story: Story; + wasUpserted: boolean; +} + export async function upsertStory( mongo: Db, tenantID: string, { id = uuid.v4(), url, siteID }: UpsertStoryInput, now = new Date() -) { +): Promise { // Create the story, optionally sourcing the id from the input, additionally // porting in the tenantID. - const update: { $setOnInsert: Story } = { - $setOnInsert: { - id, - url, - siteID, - tenantID, - createdAt: now, - commentCounts: createEmptyRelatedCommentCounts(), - settings: {}, - }, + const story: Story = { + id, + url, + tenantID, + siteID, + createdAt: now, + commentCounts: createEmptyRelatedCommentCounts(), + settings: {}, }; try { @@ -121,18 +124,26 @@ export async function upsertStory( url, tenantID, }, - update, + { $setOnInsert: story }, { // Create the object if it doesn't already exist. upsert: true, - // False to return the updated document instead of the original - // document. - returnOriginal: false, + // True to return the original document instead of the updated document. + // This will ensure that when an upsert operation adds a new Story, it + // should return null. + returnOriginal: true, } ); - return result.value || null; + return { + // The story will either be found (via `result.value`) or upserted (via + // `story`). + story: result.value || story, + + // The story was upserted if the value isn't provided. + wasUpserted: !result.value, + }; } catch (err) { // Evaluate the error, if it is in regards to violating the unique index, // then return a duplicate Story error. @@ -172,13 +183,18 @@ export interface FindOrCreateStoryInput { url?: string; } +export interface FindOrCreateStoryResult { + story: Story | null; + wasUpserted: boolean; +} + export async function findOrCreateStory( mongo: Db, tenantID: string, { id, url }: FindOrCreateStoryInput, siteID: string | null, now = new Date() -) { +): Promise { if (id) { if (url && siteID) { // The URL was specified, this is an upsert operation. @@ -194,8 +210,14 @@ export async function findOrCreateStory( ); } - // The URL and siteID were not specified, this is a lookup operation. - return retrieveStory(mongo, tenantID, id); + // The URL was not specified, this is a lookup operation. + const story = await retrieveStory(mongo, tenantID, id); + + // Return the result object. + return { + story, + wasUpserted: false, + }; } // The ID was not specified, this is an upsert operation. Check to see that diff --git a/src/core/server/models/tenant/helpers.ts b/src/core/server/models/tenant/helpers.ts index 35700774e..9087ec616 100644 --- a/src/core/server/models/tenant/helpers.ts +++ b/src/core/server/models/tenant/helpers.ts @@ -9,7 +9,7 @@ import { GQLStaffConfiguration, } from "coral-server/graph/schema/__generated__/types"; -import { SSOKey } from "../settings"; +import { Secret } from "../settings"; import { Tenant } from "./tenant"; export const getDefaultReactionConfiguration = ( @@ -39,12 +39,12 @@ export function generateRandomString(size: number, drift = 5) { .toString("hex"); } -export function generateSSOKey(createdAt: Date): SSOKey { +export function generateSecret(prefix: string, createdAt: Date): Secret { // Generate a new key. We generate a key of minimum length 32 up to 37 bytes, // as 16 was the minimum length recommended. // // Reference: https://security.stackexchange.com/a/96176 - const secret = generateRandomString(32, 5); + const secret = prefix + "_" + generateRandomString(32, 5); const kid = generateRandomString(8, 3); return { kid, secret, createdAt }; @@ -67,3 +67,10 @@ export function hasFeatureFlag( return false; } + +export function getWebhookEndpoint( + tenant: Pick, + endpointID: string +) { + return tenant.webhooks.endpoints.find(e => e.id === endpointID) || null; +} diff --git a/src/core/server/models/tenant/tenant.ts b/src/core/server/models/tenant/tenant.ts index 73ed47cf1..8d40e9877 100644 --- a/src/core/server/models/tenant/tenant.ts +++ b/src/core/server/models/tenant/tenant.ts @@ -9,7 +9,8 @@ import TIME from "coral-common/time"; import { DeepPartial, Omit, Sub } from "coral-common/types"; import { isBeforeDate } from "coral-common/utils"; import { dotize } from "coral-common/utils/dotize"; -import { Settings } from "coral-server/models/settings"; +import logger from "coral-server/logger"; +import { Secret, Settings } from "coral-server/models/settings"; import { I18n } from "coral-server/services/i18n"; import { tenants as collection } from "coral-server/services/mongodb/collections"; @@ -18,12 +19,14 @@ import { GQLFEATURE_FLAG, GQLMODERATION_MODE, GQLSettings, + GQLWEBHOOK_EVENT_NAME, } from "coral-server/graph/schema/__generated__/types"; import { - generateSSOKey, + generateSecret, getDefaultReactionConfiguration, getDefaultStaffConfiguration, + getWebhookEndpoint, } from "./helpers"; /** @@ -38,6 +41,49 @@ export interface TenantResource { readonly tenantID: string; } +export interface Endpoint { + /** + * id is the unique identifier for this specific endpoint. + */ + id: string; + + /** + * enabled when true will enable events to be sent to this endpoint. + */ + enabled: boolean; + + /** + * url is the URL that we will POST event data to. + */ + url: string; + + /** + * signingSecret is the secret used to sign the events sent out. + */ + signingSecrets: Secret[]; + + /** + * all when true indicates that all events should trigger. + */ + all: boolean; + + /** + * events is the array of events that will trigger the delivery of an + * event. + */ + events: GQLWEBHOOK_EVENT_NAME[]; + + /** + * createdAt is the date that this endpoint was created. + */ + createdAt: Date; + + /** + * modifiedAt is the date that this Endpoint was last modified at. + */ + modifiedAt?: Date; +} + export interface TenantSettings extends Pick { readonly id: string; @@ -51,6 +97,16 @@ export interface TenantSettings * featureFlags is the set of flags enabled on this Tenant. */ featureFlags?: GQLFEATURE_FLAG[]; + + /** + * webhooks stores the configurations for this Tenant's webhook rules. + */ + webhooks: { + /** + * endpoints is all the configured endpoints that should receive events. + */ + endpoints: Endpoint[]; + }; } /** @@ -112,6 +168,9 @@ export async function createTenant( enabled: false, }, editCommentWindowLength: 30 * TIME.SECOND, + webhooks: { + endpoints: [], + }, charCount: { enabled: false, }, @@ -138,7 +197,7 @@ export async function createTenant( stream: true, }, // TODO: [CORL-754] (wyattjoh) remove this in favor of generating this when needed - keys: [generateSSOKey(now)], + keys: [generateSecret("ssosec", now)], }, oidc: { enabled: false, @@ -294,9 +353,11 @@ export async function updateTenant( { id }, // Only update fields that have been updated. { $set }, - // False to return the updated document instead of the original - // document. - { returnOriginal: false } + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } ); return result.value || null; @@ -309,7 +370,7 @@ export async function updateTenant( */ export async function createTenantSSOKey(mongo: Db, id: string, now: Date) { // Construct the new key. - const key = generateSSOKey(now); + const key = generateSecret("ssosec", now); // Update the Tenant with this new key. const result = await collection(mongo).findOneAndUpdate( @@ -466,3 +527,243 @@ export function retrieveAnnouncementIfEnabled( } return null; } + +export async function rollTenantWebhookEndpointSecret( + mongo: Db, + id: string, + endpointID: string, + inactiveAt: Date, + now: Date +) { + // Create the new secret. + const secret = generateSecret("whsec", now); + + // Update the Tenant with this new secret. + let result = await collection(mongo).findOneAndUpdate( + { id }, + { + $push: { "webhooks.endpoints.$[endpoint].signingSecrets": secret }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + arrayFilters: [ + // Select the endpoint we're updating. + { "endpoint.id": endpointID }, + ], + } + ); + if (!result.value) { + return null; + } + + // Grab the endpoint we just modified. + const endpoint = getWebhookEndpoint(result.value, endpointID); + if (!endpoint) { + return null; + } + + // Get the secrets we need to deactivate... + const secretKIDsToDeprecate = endpoint.signingSecrets + // By excluding the last one (the one we just pushed)... + .splice(0, endpoint.signingSecrets.length - 1) + // And only finding keys that have not been rotated yet. + .filter(s => !s.rotatedAt) + // And get their kid's. + .map(s => s.kid); + if (secretKIDsToDeprecate.length > 0) { + logger.trace( + { kids: secretKIDsToDeprecate }, + "deprecating old signingSecrets" + ); + + // Deactivate the old keys. + result = await collection(mongo).findOneAndUpdate( + { id }, + { + $set: { + "webhooks.endpoints.$[endpoint].signingSecrets.$[signingSecret].inactiveAt": inactiveAt, + "webhooks.endpoints.$[endpoint].signingSecrets.$[signingSecret].rotatedAt": now, + }, + }, + { + arrayFilters: [ + // Select the endpoint we're updating. + { "endpoint.id": endpointID }, + // Select any signing secrets with the given ids. + { "signingSecret.kid": { $in: secretKIDsToDeprecate } }, + ], + } + ); + } + + return result.value; +} + +export interface CreateTenantWebhookEndpointInput { + url: string; + all: boolean; + events: GQLWEBHOOK_EVENT_NAME[]; +} + +export async function createTenantWebhookEndpoint( + mongo: Db, + id: string, + input: CreateTenantWebhookEndpointInput, + now: Date +) { + // Create the new endpoint. + const endpoint: Endpoint = { + ...input, + id: uuid(), + enabled: true, + signingSecrets: [generateSecret("whsec", now)], + createdAt: now, + }; + + // Update the Tenant with this new endpoint. + const result = await collection(mongo).findOneAndUpdate( + { id }, + { $push: { "webhooks.endpoints": endpoint } }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + const tenant = await retrieveTenant(mongo, id); + if (!tenant) { + return { + endpoint: null, + tenant: null, + }; + } + + throw new Error("update failed for an unexpected reason"); + } + + return { + endpoint, + tenant: result.value, + }; +} + +export interface UpdateTenantWebhookEndpointInput { + enabled?: boolean; + url?: string; + all?: boolean; + events?: GQLWEBHOOK_EVENT_NAME[]; +} + +export async function updateTenantWebhookEndpoint( + mongo: Db, + id: string, + endpointID: string, + update: UpdateTenantWebhookEndpointInput +) { + const $set = dotize( + { "webhooks.endpoints.$[endpoint]": update }, + { embedArrays: true } + ); + + // Check to see if there is any updates that will be made. + if (isEmpty($set)) { + // No updates need to be made, abort here and just return the tenant. + return retrieveTenant(mongo, id); + } + + // Perform the actual update operation. + const result = await collection(mongo).findOneAndUpdate( + { id }, + { $set }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + arrayFilters: [{ "endpoint.id": endpointID }], + } + ); + if (!result.value) { + const tenant = await retrieveTenant(mongo, id); + if (!tenant) { + return null; + } + + const endpoint = getWebhookEndpoint(tenant, endpointID); + if (!endpoint) { + throw new Error( + `endpoint not found with id: ${endpointID} on tenant: ${id}` + ); + } + + throw new Error("update failed for an unexpected reason"); + } + + return result.value; +} + +export async function deleteEndpointSecrets( + mongo: Db, + id: string, + endpointID: string, + kids: string[] +) { + const result = await collection(mongo).findOneAndUpdate( + { id }, + { + $pull: { + "webhooks.endpoints.$[endpoint].signingSecrets": { kid: { $in: kids } }, + }, + }, + { returnOriginal: false, arrayFilters: [{ "endpoint.id": endpointID }] } + ); + if (!result.value) { + const tenant = await retrieveTenant(mongo, id); + if (!tenant) { + return null; + } + + const endpoint = getWebhookEndpoint(tenant, endpointID); + if (!endpoint) { + throw new Error( + `endpoint not found with id: ${endpointID} on tenant: ${id}` + ); + } + + throw new Error("update failed for an unexpected reason"); + } + + return result.value; +} + +export async function deleteTenantWebhookEndpoint( + mongo: Db, + id: string, + endpointID: string +) { + const result = await collection(mongo).findOneAndUpdate( + { id }, + { + $pull: { + "webhooks.endpoints": { id: endpointID }, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + const tenant = await retrieveTenant(mongo, id); + if (!tenant) { + return null; + } + + throw new Error("update failed for an unexpected reason"); + } + + return result.value; +} diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index dca2d99aa..f3d206aeb 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -576,7 +576,7 @@ export async function findOrCreateUser( const user = await findOrCreateUserInput(tenantID, input, now); try { - await collection(mongo).findOneAndUpdate( + const result = await collection(mongo).findOneAndUpdate( { tenantID, profiles: { @@ -588,12 +588,18 @@ export async function findOrCreateUser( }, { $setOnInsert: user }, { - // False to return the updated document instead of the original - // document. - returnOriginal: false, + // True to return the original document instead of the updated document. + // This will ensure that when an upsert operation adds a new User, it + // should return null. + returnOriginal: true, upsert: true, } ); + + return { + user: result.value || user, + wasUpserted: !result.value, + }; } catch (err) { // Evaluate the error, if it is in regards to violating the unique index, // then return a duplicate User error. @@ -607,8 +613,6 @@ export async function findOrCreateUser( throw err; } - - return user; } export type CreateUserInput = FindOrCreateUserInput; diff --git a/src/core/server/queue/Task.ts b/src/core/server/queue/Task.ts index d7fd76c9d..405f98075 100644 --- a/src/core/server/queue/Task.ts +++ b/src/core/server/queue/Task.ts @@ -4,9 +4,11 @@ import Logger from "bunyan"; import TIME from "coral-common/time"; import logger from "coral-server/logger"; -export interface TaskOptions { +export type JobProcessor = (job: Job) => Promise; + +export interface TaskOptions { jobName: string; - jobProcessor: (job: Job) => Promise; + jobProcessor: JobProcessor; jobOptions?: Queue.JobOptions; queue: Queue.QueueOptions; } diff --git a/src/core/server/queue/index.ts b/src/core/server/queue/index.ts index 3561ad5e1..f64f9bd6d 100644 --- a/src/core/server/queue/index.ts +++ b/src/core/server/queue/index.ts @@ -1,4 +1,5 @@ import Queue from "bull"; +import { Redis } from "ioredis"; import { Db } from "mongodb"; import { Config } from "coral-server/config"; @@ -10,6 +11,7 @@ import TenantCache from "coral-server/services/tenant/cache"; import { createMailerTask, MailerQueue } from "./tasks/mailer"; import { createNotifierTask, NotifierQueue } from "./tasks/notifier"; import { createScraperTask, ScraperQueue } from "./tasks/scraper"; +import { createWebhookTask, WebhookQueue } from "./tasks/webhook"; const createQueueOptions = async ( config: Config @@ -47,12 +49,14 @@ export interface QueueOptions { tenantCache: TenantCache; i18n: I18n; signingConfig: JWTSigningConfig; + redis: Redis; } export interface TaskQueue { mailer: MailerQueue; scraper: ScraperQueue; notifier: NotifierQueue; + webhook: WebhookQueue; } export async function createQueue(options: QueueOptions): Promise { @@ -67,11 +71,13 @@ export async function createQueue(options: QueueOptions): Promise { mailerQueue: mailer, ...options, }); + const webhook = createWebhookTask(queueOptions, options); // Return the tasks + client. return { mailer, scraper, notifier, + webhook, }; } diff --git a/src/core/server/queue/tasks/notifier/index.ts b/src/core/server/queue/tasks/notifier/index.ts index 8e384a6c2..2ba223cf5 100644 --- a/src/core/server/queue/tasks/notifier/index.ts +++ b/src/core/server/queue/tasks/notifier/index.ts @@ -1,10 +1,8 @@ import Queue from "bull"; -import { groupBy } from "lodash"; import { Db } from "mongodb"; import { Config } from "coral-server/config"; -import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types"; -import logger from "coral-server/logger"; +import { CoralEventType } from "coral-server/events"; import Task from "coral-server/queue/Task"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { JWTSigningConfig } from "coral-server/services/jwt"; @@ -29,34 +27,32 @@ interface Options { * that could be sent to users. */ export class NotifierQueue { - private registry: Record; private task: Task; constructor(queue: Queue.QueueOptions, options: Options) { + const registry = new Map(); + // Notification categories have been grouped by their event name so that // each event emitted need only access the associated notification once. - this.registry = groupBy(categories, "event") as Record< - SUBSCRIPTION_CHANNELS, - NotificationCategory[] - >; + for (const category of categories) { + for (const event of category.events as CoralEventType[]) { + let handlers = registry.get(event); + if (!handlers) { + handlers = []; + } + handlers.push(category); + registry.set(event, handlers); + } + } + this.task = new Task({ jobName: JOB_NAME, - jobProcessor: createJobProcessor({ registry: this.registry, ...options }), + jobProcessor: createJobProcessor({ registry, ...options }), queue, }); } public async add(data: NotifierData) { - // Get all the handlers that are active for this channel. - const c = this.registry[data.input.channel]; - if (!c || c.length === 0) { - logger.debug( - { channel: data.input.channel }, - "no notifications registered on this channel" - ); - return; - } - return this.task.add(data); } diff --git a/src/core/server/queue/tasks/notifier/messages.ts b/src/core/server/queue/tasks/notifier/messages.ts index f11ca75c3..bb4706227 100644 --- a/src/core/server/queue/tasks/notifier/messages.ts +++ b/src/core/server/queue/tasks/notifier/messages.ts @@ -1,10 +1,11 @@ -import { SUBSCRIPTION_INPUT } from "coral-server/graph/resolvers/Subscription/types"; -import { GQLDIGEST_FREQUENCY } from "coral-server/graph/schema/__generated__/types"; +import { CoralEventPayload } from "coral-server/events/event"; import logger from "coral-server/logger"; import { NotificationCategory } from "coral-server/services/notifications/categories"; import NotificationContext from "coral-server/services/notifications/context"; import { Notification } from "coral-server/services/notifications/notification"; +import { GQLDIGEST_FREQUENCY } from "coral-server/graph/schema/__generated__/types"; + import { MailerQueue } from "../mailer"; import { DigestibleTemplate } from "../mailer/templates"; import { CategoryNotification } from "./processor"; @@ -52,11 +53,11 @@ export const filterSuperseded = ( export const handleHandlers = async ( ctx: NotificationContext, categories: NotificationCategory[], - input: SUBSCRIPTION_INPUT + payload: CoralEventPayload ): Promise => { const notifications: Array = await Promise.all( categories.map(async category => { - const notification = await category.process(ctx, input.payload); + const notification = await category.process(ctx, payload); if (!notification) { return null; } diff --git a/src/core/server/queue/tasks/notifier/processor.ts b/src/core/server/queue/tasks/notifier/processor.ts index 9e7b4f5f8..4af95f810 100644 --- a/src/core/server/queue/tasks/notifier/processor.ts +++ b/src/core/server/queue/tasks/notifier/processor.ts @@ -2,10 +2,8 @@ import { Job } from "bull"; import { Db } from "mongodb"; import { Config } from "coral-server/config"; -import { - SUBSCRIPTION_CHANNELS, - SUBSCRIPTION_INPUT, -} from "coral-server/graph/resolvers/Subscription/types"; +import { CoralEventType } from "coral-server/events"; +import { NotifierCoralEventListenerPayloads } from "coral-server/events/listeners/notifier"; import logger from "coral-server/logger"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { JWTSigningConfig } from "coral-server/services/jwt"; @@ -27,14 +25,14 @@ export const JOB_NAME = "notifications"; */ export interface NotifierData { tenantID: string; - input: SUBSCRIPTION_INPUT; + input: NotifierCoralEventListenerPayloads; } interface Options { mailerQueue: MailerQueue; mongo: Db; config: Config; - registry: Record; + registry: Map; tenantCache: TenantCache; signingConfig: JWTSigningConfig; } @@ -83,7 +81,7 @@ export const createJobProcessor = ({ try { // Get all the handlers that are active for this channel. - const categories = registry[input.channel]; + const categories = registry.get(input.type); if (!categories || categories.length === 0) { return; } diff --git a/src/core/server/queue/tasks/webhook/index.ts b/src/core/server/queue/tasks/webhook/index.ts new file mode 100644 index 000000000..e301f118c --- /dev/null +++ b/src/core/server/queue/tasks/webhook/index.ts @@ -0,0 +1,22 @@ +import Queue from "bull"; + +import Task from "coral-server/queue/Task"; + +import { + createJobProcessor, + JOB_NAME, + WebhookData, + WebhookProcessorOptions, +} from "./processor"; + +export type WebhookQueue = Task; + +export const createWebhookTask = ( + queue: Queue.QueueOptions, + options: WebhookProcessorOptions +) => + new Task({ + jobName: JOB_NAME, + jobProcessor: createJobProcessor(options), + queue, + }); diff --git a/src/core/server/queue/tasks/webhook/processor.ts b/src/core/server/queue/tasks/webhook/processor.ts new file mode 100644 index 000000000..dc4d4d71f --- /dev/null +++ b/src/core/server/queue/tasks/webhook/processor.ts @@ -0,0 +1,262 @@ +import crypto from "crypto"; +import { Redis } from "ioredis"; +import { Db } from "mongodb"; +import getNow from "performance-now"; + +import { Config } from "coral-server/config"; +import { CoralEventPayload } from "coral-server/events/event"; +import logger from "coral-server/logger"; +import { + filterActiveSecrets, + filterExpiredSecrets, +} from "coral-server/models/settings"; +import { + deleteEndpointSecrets, + Endpoint, + getWebhookEndpoint, +} from "coral-server/models/tenant"; +import { JobProcessor } from "coral-server/queue/Task"; +import { createFetch, FetchOptions } from "coral-server/services/fetch"; +import { disableWebhookEndpoint } from "coral-server/services/tenant"; +import TenantCache from "coral-server/services/tenant/cache"; + +export const JOB_NAME = "webhook"; + +// The count of failures on a webhook delivery before we disable the endpoint. +const MAXIMUM_FAILURE_COUNT = 10; + +// The number of webhook attempts that should be retained for debugging. +const MAXIMUM_EVENT_ATTEMPTS_LOG_SIZE = 50; + +export interface WebhookProcessorOptions { + config: Config; + mongo: Db; + redis: Redis; + tenantCache: TenantCache; +} + +export interface WebhookData { + contextID: string; + endpointID: string; + tenantID: string; + event: CoralEventPayload; +} + +export interface WebhookDelivery { + id: string; + name: string; + success: boolean; + status: number; + statusText: string; + request: string; + response: string; + createdAt: Date; +} + +/** + * generateSignature will generate a signature used to assist clients to + * validate that the request came from Coral. + * + * @param secret the secret used to sign the body with + * @param body the body to use when signing + */ +export function generateSignature(secret: string, body: string) { + return crypto + .createHmac("sha256", secret) + .update(body) + .digest() + .toString("hex"); +} + +export function generateSignatures( + endpoint: Pick, + body: string, + now: Date +) { + // For each of the signatures, we only want to sign the body with secrets that + // are still active. + return endpoint.signingSecrets + .filter(filterActiveSecrets(now)) + .map(({ secret }) => generateSignature(secret, body)) + .map(signature => `sha256=${signature}`) + .join(","); +} + +export function generateFetchOptions( + endpoint: Pick, + data: CoralEventPayload, + now: Date +): FetchOptions { + // Serialize the body and signature to include in the request. + const body = JSON.stringify(data, null, 2); + const signature = generateSignatures(endpoint, body, now); + + const headers: Record = { + "Content-Type": "application/json", + "X-Coral-Event": data.type, + "X-Coral-Signature": signature, + }; + + return { + method: "POST", + headers, + body, + }; +} + +export function createJobProcessor({ + mongo, + tenantCache, + redis, +}: WebhookProcessorOptions): JobProcessor { + // Create the fetcher that will orchestrate sending the actual webhooks. + const fetch = createFetch({ name: "Webhook" }); + + return async job => { + const { tenantID, endpointID, contextID, event } = job.data; + + const log = logger.child( + { + eventID: event.id, + contextID, + jobID: job.id, + jobName: JOB_NAME, + tenantID, + endpointID, + }, + true + ); + + // Get the referenced tenant so we can get the endpoint details. + const tenant = await tenantCache.retrieveByID(tenantID); + if (!tenant) { + log.error("referenced tenant was not found"); + return; + } + + // Get the referenced endpoint. + const endpoint = getWebhookEndpoint(tenant, endpointID); + if (!endpoint) { + log.error("referenced endpoint was not found"); + return; + } + + // If the endpoint is disabled, don't bother processing it. + if (!endpoint.enabled) { + log.warn("endpoint was disabled, skipping sending"); + return; + } + + // Get the current date. + const now = new Date(); + + // Get the fetch options. + const options = generateFetchOptions(endpoint, event, now); + + // Send the request. + const startedSendingAt = getNow(); + const res = await fetch(endpoint.url, options); + const took = getNow() - startedSendingAt; + if (res.ok) { + log.info( + { took, responseStatus: res.status }, + "finished sending webhook" + ); + } else { + log.warn( + { took, responseStatus: res.status }, + "failed to deliver webhook" + ); + } + + // Grab the response from the webhook, we'll want to save this in the recent + // attempts. + const response = await res.text(); + + // Collect the delivery information. + const delivery: WebhookDelivery = { + id: event.id, + name: event.type, + success: res.ok, + status: res.status, + statusText: res.statusText, + // We only serialize the body as a string. + request: options.body as string, + response, + createdAt: new Date(), + }; + + // Record the delivery. + const endpointDeliveriesKey = `${tenantID}:endpointDeliveries:${endpointID}`; + const endpointFailuresKey = `${tenantID}:endpointFailures:${endpointID}`; + let [, , [, failuresString]] = await redis + .multi() + // Push the attempt into the list. + .rpush(endpointDeliveriesKey, JSON.stringify(delivery)) + // Trim the list to the 50 most recent attempts. + .ltrim(endpointDeliveriesKey, 0, MAXIMUM_EVENT_ATTEMPTS_LOG_SIZE - 1) + // Get the current failure count. + .get(endpointFailuresKey) + // Execute the queued operations. + .exec(); + + let failures = failuresString ? parseInt(failuresString, 10) : null; + if (res.ok && failures && failures > 0) { + // The webhook delivery was a success, and there were previous failures. + // Remove the failures record. + await redis.del(endpointFailuresKey); + } else if (!res.ok) { + // Record the failed attempt. + failuresString = await redis.incr(endpointFailuresKey); + + // If the failure count is higher than the allowed maximum, disable the + // endpoint. + failures = failuresString ? parseInt(failuresString, 10) : null; + if (failures && failures >= MAXIMUM_FAILURE_COUNT) { + log.warn( + { failures, maxFailures: MAXIMUM_FAILURE_COUNT }, + "maximum failures reached, disabling endpoint" + ); + + await disableWebhookEndpoint( + mongo, + redis, + tenantCache, + tenant, + endpointID + ); + } else { + // TODO: (wyattjoh) maybe schedule a retry? + } + } + + // Remove the expired secrets in the next tick so that it does not affect + // the sending performance of this job, and errors do not impact the + // sending. + const expiredSigningSecrets = endpoint.signingSecrets.filter( + filterExpiredSecrets(now) + ); + if (expiredSigningSecrets.length > 0) { + process.nextTick(() => { + deleteEndpointSecrets( + mongo, + tenantID, + endpoint.id, + expiredSigningSecrets.map(s => s.kid) + ) + .then(() => { + log.info( + { secrets: expiredSigningSecrets.length }, + "removed expired secrets from endpoint" + ); + }) + .catch(err => { + log.error( + { err }, + "an error occurred when trying to remove expired secrets" + ); + }); + }); + } + }; +} diff --git a/src/core/server/services/comments/actions.ts b/src/core/server/services/comments/actions.ts index c9e71bddf..4a57aabe0 100644 --- a/src/core/server/services/comments/actions.ts +++ b/src/core/server/services/comments/actions.ts @@ -2,7 +2,7 @@ import { Db } from "mongodb"; import { Omit } from "coral-common/types"; import { CommentNotFoundError } from "coral-server/errors"; -import { Publisher } from "coral-server/graph/subscriptions/publisher"; +import { CoralEventPublisherBroker } from "coral-server/events/publisher"; import { ACTION_TYPE, CreateActionInput, @@ -76,7 +76,7 @@ export async function addCommentActionCounts( async function addCommentAction( mongo: Db, redis: AugmentedRedis, - publisher: Publisher, + broker: CoralEventPublisherBroker, tenant: Tenant, input: Omit, now = new Date() @@ -116,7 +116,7 @@ async function addCommentAction( }); // Publish changes to the event publisher. - await publishChanges(publisher, { + await publishChanges(broker, { ...counts, before: oldComment, after: updatedComment, @@ -131,7 +131,7 @@ async function addCommentAction( export async function removeCommentAction( mongo: Db, redis: AugmentedRedis, - publisher: Publisher, + broker: CoralEventPublisherBroker, tenant: Tenant, input: Omit ): Promise> { @@ -191,7 +191,7 @@ export async function removeCommentAction( }); // Publish changes to the event publisher. - await publishChanges(publisher, { + await publishChanges(broker, { ...counts, before: oldComment, after: updatedComment, @@ -211,7 +211,7 @@ export type CreateCommentReaction = Pick< export async function createReaction( mongo: Db, redis: AugmentedRedis, - publish: Publisher, + broker: CoralEventPublisherBroker, tenant: Tenant, author: User, input: CreateCommentReaction, @@ -220,7 +220,7 @@ export async function createReaction( return addCommentAction( mongo, redis, - publish, + broker, tenant, { actionType: ACTION_TYPE.REACTION, @@ -237,12 +237,12 @@ export type RemoveCommentReaction = Pick; export async function removeReaction( mongo: Db, redis: AugmentedRedis, - publisher: Publisher, + broker: CoralEventPublisherBroker, tenant: Tenant, author: User, input: RemoveCommentReaction ) { - return removeCommentAction(mongo, redis, publisher, tenant, { + return removeCommentAction(mongo, redis, broker, tenant, { actionType: ACTION_TYPE.REACTION, commentID: input.commentID, userID: author.id, @@ -257,7 +257,7 @@ export type CreateCommentDontAgree = Pick< export async function createDontAgree( mongo: Db, redis: AugmentedRedis, - publish: Publisher, + broker: CoralEventPublisherBroker, tenant: Tenant, author: User, input: CreateCommentDontAgree, @@ -266,7 +266,7 @@ export async function createDontAgree( return addCommentAction( mongo, redis, - publish, + broker, tenant, { actionType: ACTION_TYPE.DONT_AGREE, @@ -284,12 +284,12 @@ export type RemoveCommentDontAgree = Pick; export async function removeDontAgree( mongo: Db, redis: AugmentedRedis, - publisher: Publisher, + broker: CoralEventPublisherBroker, tenant: Tenant, author: User, input: RemoveCommentDontAgree ) { - return removeCommentAction(mongo, redis, publisher, tenant, { + return removeCommentAction(mongo, redis, broker, tenant, { actionType: ACTION_TYPE.DONT_AGREE, commentID: input.commentID, userID: author.id, @@ -306,7 +306,7 @@ export type CreateCommentFlag = Pick< export async function createFlag( mongo: Db, redis: AugmentedRedis, - publish: Publisher, + broker: CoralEventPublisherBroker, tenant: Tenant, author: User, input: CreateCommentFlag, @@ -315,7 +315,7 @@ export async function createFlag( return addCommentAction( mongo, redis, - publish, + broker, tenant, { actionType: ACTION_TYPE.FLAG, diff --git a/src/core/server/services/comments/pipeline/phases/toxic.ts b/src/core/server/services/comments/pipeline/phases/toxic.ts index 9b81fdd5f..3aed4d252 100644 --- a/src/core/server/services/comments/pipeline/phases/toxic.ts +++ b/src/core/server/services/comments/pipeline/phases/toxic.ts @@ -1,5 +1,4 @@ import { isNil } from "lodash"; -import fetch from "node-fetch"; import path from "path"; import { URL } from "url"; @@ -18,6 +17,7 @@ import { IntermediatePhaseResult, ModerationPhaseContext, } from "coral-server/services/comments/pipeline"; +import { createFetch } from "coral-server/services/fetch"; import { GQLCOMMENT_FLAG_REASON, @@ -26,6 +26,11 @@ import { GQLPerspectiveExternalIntegration, } from "coral-server/graph/schema/__generated__/types"; +/** + * fetch is the phase hook fetcher used to communicate with the Perspective API. + */ +const fetch = createFetch({ name: "Hooks" }); + export const toxic: IntermediateModerationPhase = async ({ tenant, nudge, diff --git a/src/core/server/services/events/comments.ts b/src/core/server/services/events/comments.ts index 46b46f3a6..3c11dd052 100644 --- a/src/core/server/services/events/comments.ts +++ b/src/core/server/services/events/comments.ts @@ -1,153 +1,131 @@ -import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types"; -import { Publisher } from "coral-server/graph/subscriptions/publisher"; -import { Comment, hasPublishedStatus } from "coral-server/models/comment"; -import { CommentModerationQueueCounts } from "coral-server/models/comment/counts"; +import { + CommentCreatedCoralEvent, + CommentEnteredModerationQueueCoralEvent, + CommentFeaturedCoralEvent, + CommentLeftModerationQueueCoralEvent, + CommentReleasedCoralEvent, + CommentReplyCreatedCoralEvent, + CommentStatusUpdatedCoralEvent, +} from "coral-server/events"; +import { CoralEventPublisherBroker } from "coral-server/events/publisher"; +import { + Comment, + CommentModerationQueueCounts, + hasPublishedStatus, +} from "coral-server/models/comment"; import { GQLCOMMENT_STATUS, GQLMODERATION_QUEUE, } from "coral-server/graph/schema/__generated__/types"; -export function publishCommentStatusChanges( - publish: Publisher, +export async function publishCommentStatusChanges( + broker: CoralEventPublisherBroker, oldStatus: GQLCOMMENT_STATUS, newStatus: GQLCOMMENT_STATUS, commentID: string, moderatorID: string | null ) { if (oldStatus !== newStatus) { - publish({ - channel: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED, - payload: { - newStatus, - oldStatus, - commentID, - moderatorID, - }, + await CommentStatusUpdatedCoralEvent.publish(broker, { + newStatus, + oldStatus, + commentID, + moderatorID, }); } } -export function publishCommentReplyCreated( - publish: Publisher, +export async function publishCommentReplyCreated( + broker: CoralEventPublisherBroker, comment: Pick ) { if (comment.ancestorIDs.length > 0 && hasPublishedStatus(comment)) { - publish({ - channel: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED, - payload: { - ancestorIDs: comment.ancestorIDs, - commentID: comment.id, - }, + await CommentReplyCreatedCoralEvent.publish(broker, { + ancestorIDs: comment.ancestorIDs, + commentID: comment.id, }); } } -export function publishCommentCreated( - publish: Publisher, +export async function publishCommentCreated( + broker: CoralEventPublisherBroker, comment: Pick ) { if (!comment.parentID && hasPublishedStatus(comment)) { - publish({ - channel: SUBSCRIPTION_CHANNELS.COMMENT_CREATED, - payload: { - commentID: comment.id, - storyID: comment.storyID, - }, + await CommentCreatedCoralEvent.publish(broker, { + commentID: comment.id, + storyID: comment.storyID, }); } } -export function publishCommentReleased( - publish: Publisher, +export async function publishCommentReleased( + broker: CoralEventPublisherBroker, comment: Pick ) { if (!comment.parentID && hasPublishedStatus(comment)) { - publish({ - channel: SUBSCRIPTION_CHANNELS.COMMENT_RELEASED, - payload: { - commentID: comment.id, - storyID: comment.storyID, - }, + await CommentReleasedCoralEvent.publish(broker, { + commentID: comment.id, + storyID: comment.storyID, }); } } -export function publishCommentFeatured( - publish: Publisher, +export async function publishCommentFeatured( + broker: CoralEventPublisherBroker, comment: Pick ) { if (hasPublishedStatus(comment)) { - publish({ - channel: SUBSCRIPTION_CHANNELS.COMMENT_FEATURED, - payload: { - commentID: comment.id, - storyID: comment.storyID, - }, + await CommentFeaturedCoralEvent.publish(broker, { + commentID: comment.id, + storyID: comment.storyID, }); } } -export function publishModerationQueueChanges( - publish: Publisher, +export async function publishModerationQueueChanges( + broker: CoralEventPublisherBroker, moderationQueue: Pick, comment: Pick ) { if (moderationQueue.queues.pending === 1) { - publish({ - channel: SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE, - payload: { - queue: GQLMODERATION_QUEUE.PENDING, - commentID: comment.id, - storyID: comment.storyID, - }, + await CommentEnteredModerationQueueCoralEvent.publish(broker, { + queue: GQLMODERATION_QUEUE.PENDING, + commentID: comment.id, + storyID: comment.storyID, }); } else if (moderationQueue.queues.pending === -1) { - publish({ - channel: SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE, - payload: { - queue: GQLMODERATION_QUEUE.PENDING, - commentID: comment.id, - storyID: comment.storyID, - }, + await CommentLeftModerationQueueCoralEvent.publish(broker, { + queue: GQLMODERATION_QUEUE.PENDING, + commentID: comment.id, + storyID: comment.storyID, }); } if (moderationQueue.queues.reported === 1) { - publish({ - channel: SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE, - payload: { - queue: GQLMODERATION_QUEUE.REPORTED, - commentID: comment.id, - storyID: comment.storyID, - }, + await CommentEnteredModerationQueueCoralEvent.publish(broker, { + queue: GQLMODERATION_QUEUE.REPORTED, + commentID: comment.id, + storyID: comment.storyID, }); } else if (moderationQueue.queues.reported === -1) { - publish({ - channel: SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE, - payload: { - queue: GQLMODERATION_QUEUE.REPORTED, - commentID: comment.id, - storyID: comment.storyID, - }, + await CommentLeftModerationQueueCoralEvent.publish(broker, { + queue: GQLMODERATION_QUEUE.REPORTED, + commentID: comment.id, + storyID: comment.storyID, }); } if (moderationQueue.queues.unmoderated === 1) { - publish({ - channel: SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE, - payload: { - queue: GQLMODERATION_QUEUE.UNMODERATED, - commentID: comment.id, - storyID: comment.storyID, - }, + await CommentEnteredModerationQueueCoralEvent.publish(broker, { + queue: GQLMODERATION_QUEUE.UNMODERATED, + commentID: comment.id, + storyID: comment.storyID, }); } else if (moderationQueue.queues.unmoderated === -1) { - publish({ - channel: SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE, - payload: { - queue: GQLMODERATION_QUEUE.UNMODERATED, - commentID: comment.id, - storyID: comment.storyID, - }, + await CommentLeftModerationQueueCoralEvent.publish(broker, { + queue: GQLMODERATION_QUEUE.UNMODERATED, + commentID: comment.id, + storyID: comment.storyID, }); } } diff --git a/src/core/server/services/stories/scraper/abortAfter.ts b/src/core/server/services/fetch/abortAfter.ts similarity index 100% rename from src/core/server/services/stories/scraper/abortAfter.ts rename to src/core/server/services/fetch/abortAfter.ts diff --git a/src/core/server/services/fetch/fetch.ts b/src/core/server/services/fetch/fetch.ts new file mode 100644 index 000000000..005839111 --- /dev/null +++ b/src/core/server/services/fetch/fetch.ts @@ -0,0 +1,84 @@ +import http from "http"; +import https from "https"; +import { capitalize } from "lodash"; +import fetch, { RequestInit, Response } from "node-fetch"; +import { URL } from "url"; + +import { version } from "coral-common/version"; + +import abortAfter from "./abortAfter"; + +export type Fetch = (url: string, options?: FetchOptions) => Promise; + +export interface CreateFetchOptions { + /** + * name is the string that is attached to the `User-Agent` header as: + * + * `Coral ${name}/${version}` + */ + name: string; +} + +export type FetchOptions = RequestInit & { + /** + * timeout is the number of seconds that the request will wait for a response + * before timing out. + */ + timeout?: number; +}; + +export const createFetch = ({ name }: CreateFetchOptions): Fetch => { + // Create HTTP agents to improve connection performance. + const agents = { + https: new https.Agent({ + keepAlive: true, + }), + http: new http.Agent({ + keepAlive: true, + }), + }; + + // agent will select the correct agent to use for reusing the agent. + const agent = (url: URL) => + url.protocol === "http:" ? agents.http : agents.https; + + // defaultHeaders are the headers attached to each request (unless they are + // overridden). + const defaultHeaders = { + "User-Agent": `Coral ${capitalize(name)}/${version}`, + }; + + // Return the actual fetcher that just uses fetch under the hood. + return async ( + url: string, + { + headers = {}, + // Default to 10 seconds for the timeout. + timeout = 10000, + ...options + }: FetchOptions = {} + ) => { + // Abort the scrape request after the timeout is reached. + const abort = abortAfter(timeout); + + try { + // Perform the actual fetch operation. + const res = await fetch(url, { + agent, + headers: { + ...defaultHeaders, + ...headers, + }, + // Attach the controller signal to abort the request after the timeout + // is reached. + signal: abort.controller.signal, + // Merge in the passed options. + ...options, + }); + + return res; + } finally { + clearTimeout(abort.timeout); + } + }; +}; diff --git a/src/core/server/services/fetch/index.ts b/src/core/server/services/fetch/index.ts new file mode 100644 index 000000000..9898400f4 --- /dev/null +++ b/src/core/server/services/fetch/index.ts @@ -0,0 +1 @@ +export * from "./fetch"; diff --git a/src/core/server/services/migrate/migrations/1573073491825_sso_tokens.ts b/src/core/server/services/migrate/migrations/1573073491825_sso_tokens.ts index 071b8034b..2308912ce 100644 --- a/src/core/server/services/migrate/migrations/1573073491825_sso_tokens.ts +++ b/src/core/server/services/migrate/migrations/1573073491825_sso_tokens.ts @@ -1,7 +1,7 @@ import { Db } from "mongodb"; -import { SSOKey } from "coral-server/models/settings"; -import { generateSSOKey, Tenant } from "coral-server/models/tenant"; +import { Secret } from "coral-server/models/settings"; +import { generateSecret, Tenant } from "coral-server/models/tenant"; import Migration from "coral-server/services/migrate/migration"; import collections from "coral-server/services/mongodb/collections"; @@ -48,14 +48,14 @@ export default class extends Migration { } // Store the keys in an array. - const keys: SSOKey[] = []; + const keys: Secret[] = []; // Check to see if a key is set. const sso = tenant.auth.integrations.sso; if (sso.key && sso.keyGeneratedAt) { // Create the new SSOKey based on this data. - const key = generateSSOKey(sso.keyGeneratedAt); + const key = generateSecret("ssosec", sso.keyGeneratedAt); // Set the secret of the sso key to the secret of the current set key. key.secret = sso.key; diff --git a/src/core/server/services/migrate/migrations/1573841155297_webhooks.ts b/src/core/server/services/migrate/migrations/1573841155297_webhooks.ts new file mode 100644 index 000000000..a48f438fc --- /dev/null +++ b/src/core/server/services/migrate/migrations/1573841155297_webhooks.ts @@ -0,0 +1,49 @@ +import { Db } from "mongodb"; + +import Migration from "coral-server/services/migrate/migration"; +import collections from "coral-server/services/mongodb/collections"; + +import { MigrationError } from "../error"; + +export default class extends Migration { + public async up(mongo: Db, id: string) { + await collections.tenants(mongo).updateOne( + { id, webhooks: null }, + { + $set: { + webhooks: { + endpoints: [], + }, + }, + } + ); + } + + public async test(mongo: Db, id: string) { + // Ensure that the tenant has the webhooks set. + const tenant = await collections.tenants(mongo).findOne({ id }); + if (!tenant) { + throw new MigrationError( + id, + "could not find the specified tenant", + "tenants", + [id] + ); + } + + if (!tenant.webhooks) { + throw new MigrationError( + id, + "tenant did not have webhooks set", + "tenants", + [id] + ); + } + } + + public async down(mongo: Db, id: string) { + await collections + .tenants(mongo) + .updateOne({ id }, { $unset: { webhooks: "" } }); + } +} diff --git a/src/core/server/services/migrate/migrations/1573858750460_sso_token_refactor.ts b/src/core/server/services/migrate/migrations/1573858750460_sso_token_refactor.ts index 7dbbde912..344e36851 100644 --- a/src/core/server/services/migrate/migrations/1573858750460_sso_token_refactor.ts +++ b/src/core/server/services/migrate/migrations/1573858750460_sso_token_refactor.ts @@ -1,7 +1,7 @@ import { DateTime } from "luxon"; import { Db } from "mongodb"; -import { SSOKey } from "coral-server/models/settings"; +import { Secret } from "coral-server/models/settings"; import Migration from "coral-server/services/migrate/migration"; import collections from "coral-server/services/mongodb/collections"; @@ -15,16 +15,16 @@ interface OldSSOKey { deletedAt?: Date; } -function isOldSSOKey(key: SSOKey | OldSSOKey): key is OldSSOKey { +function isOldSSOKey(key: Secret | OldSSOKey): key is OldSSOKey { if (!key) { return true; } - if ((key as SSOKey).inactiveAt) { + if ((key as Secret).inactiveAt) { return false; } - if ((key as SSOKey).rotatedAt) { + if ((key as Secret).rotatedAt) { return false; } @@ -63,7 +63,7 @@ export default class extends Migration { // Transform the keys into the new format. const keys: OldSSOKey[] = tenant.auth.integrations.sso.keys.map( - (key: OldSSOKey | SSOKey): OldSSOKey => + (key: OldSSOKey | Secret): OldSSOKey => !isOldSSOKey(key) ? { kid: key.kid, @@ -100,8 +100,8 @@ export default class extends Migration { } // Transform the keys into the new format. - const keys: SSOKey[] = tenant.auth.integrations.sso.keys.map( - (key): SSOKey => ({ + const keys: Secret[] = tenant.auth.integrations.sso.keys.map( + (key): Secret => ({ kid: key.kid, secret: key.secret || "", createdAt: key.createdAt, diff --git a/src/core/server/services/notifications/categories/categories.ts b/src/core/server/services/notifications/categories/categories.ts index a61bd0bdb..0d7298451 100644 --- a/src/core/server/services/notifications/categories/categories.ts +++ b/src/core/server/services/notifications/categories/categories.ts @@ -8,10 +8,10 @@ import { staffReply } from "./staffReply"; * categories stores all the notification categories in a flat list. */ const categories: NotificationCategory[] = [ - ...reply, - ...staffReply, - ...featured, - ...moderation, + reply, + staffReply, + moderation, + featured, ]; export default categories; diff --git a/src/core/server/services/notifications/categories/category.ts b/src/core/server/services/notifications/categories/category.ts index 81026f908..0b6e23fd0 100644 --- a/src/core/server/services/notifications/categories/category.ts +++ b/src/core/server/services/notifications/categories/category.ts @@ -1,7 +1,4 @@ -import { - SUBSCRIPTION_CHANNELS, - SUBSCRIPTION_INPUT, -} from "coral-server/graph/resolvers/Subscription/types"; +import { CoralEventPayload } from "coral-server/events/event"; import NotificationContext from "../context"; import { Notification } from "../notification"; @@ -10,7 +7,7 @@ import { Notification } from "../notification"; * NotificationCategory define the Category that is used to define a * Notification type. */ -export interface NotificationCategory { +export interface NotificationCategory { /** * name is the actual name of the notification that can be used to define the * other category names that are superseded by this one. @@ -23,14 +20,14 @@ export interface NotificationCategory { */ process: ( ctx: NotificationContext, - input: SUBSCRIPTION_INPUT["payload"] + payload: T ) => Promise; /** - * event is the subscription event that when fired, will trigger this + * events is the subscription event that when fired, will trigger this * notification processor to be called. */ - event: SUBSCRIPTION_CHANNELS; + events: T["type"][]; /** * digestOrder, when provided, allows the custom ordering of notifications in diff --git a/src/core/server/services/notifications/categories/featured.ts b/src/core/server/services/notifications/categories/featured.ts index 844359dd1..dd59d66bf 100644 --- a/src/core/server/services/notifications/categories/featured.ts +++ b/src/core/server/services/notifications/categories/featured.ts @@ -1,63 +1,58 @@ -import { CommentFeaturedInput } from "coral-server/graph/resolvers/Subscription/commentFeatured"; -import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types"; +import { + CommentFeaturedCoralEventPayload, + CoralEventType, +} from "coral-server/events"; import { hasPublishedStatus } from "coral-server/models/comment"; import { getStoryTitle, getURLWithCommentID } from "coral-server/models/story"; -import NotificationContext from "../context"; -import { Notification } from "../notification"; import { NotificationCategory } from "./category"; -async function processor( - ctx: NotificationContext, - input: CommentFeaturedInput -): Promise { - // Get the comment that was featured. - const comment = await ctx.comments.load(input.commentID); - if (!comment || (!hasPublishedStatus(comment) || !comment.authorID)) { - return null; - } +export const featured: NotificationCategory< + CommentFeaturedCoralEventPayload +> = { + name: "featured", + process: async (ctx, input) => { + // Get the comment that was featured. + const comment = await ctx.comments.load(input.data.commentID); + if (!comment || !hasPublishedStatus(comment) || !comment.authorID) { + return null; + } - // Get the comment's author. - const author = await ctx.users.load(comment.authorID); - if (!author) { - return null; - } + // Get the comment's author. + const author = await ctx.users.load(comment.authorID); + if (!author) { + return null; + } - // Check to see if the user has this notification type enabled. - if (!author.notifications.onFeatured) { - return null; - } + // Check to see if the user has this notification type enabled. + if (!author.notifications.onFeatured) { + return null; + } - // Get the story that this was written on. - const story = await ctx.stories.load(comment.storyID); - if (!story) { - return null; - } + // Get the story that this was written on. + const story = await ctx.stories.load(comment.storyID); + if (!story) { + return null; + } - // Generate the unsubscribe URL. - const unsubscribeURL = await ctx.generateUnsubscribeURL(author); + // Generate the unsubscribe URL. + const unsubscribeURL = await ctx.generateUnsubscribeURL(author); - return { - userID: author.id, - template: { - name: "notification/on-featured", - context: { - commentPermalink: getURLWithCommentID(story.url, comment.id), - storyTitle: getStoryTitle(story), - storyURL: story.url, - organizationName: ctx.tenant.organization.name, - organizationURL: ctx.tenant.organization.url, - unsubscribeURL, + return { + userID: author.id, + template: { + name: "notification/on-featured", + context: { + commentPermalink: getURLWithCommentID(story.url, comment.id), + storyTitle: getStoryTitle(story), + storyURL: story.url, + organizationName: ctx.tenant.organization.name, + organizationURL: ctx.tenant.organization.url, + unsubscribeURL, + }, }, - }, - }; -} - -export const featured: NotificationCategory[] = [ - { - name: "featured", - process: processor, - event: SUBSCRIPTION_CHANNELS.COMMENT_FEATURED, - digestOrder: 30, + }; }, -]; + events: [CoralEventType.COMMENT_FEATURED], + digestOrder: 30, +}; diff --git a/src/core/server/services/notifications/categories/moderation.ts b/src/core/server/services/notifications/categories/moderation.ts index 1ee4b6909..e5a0c6ec1 100644 --- a/src/core/server/services/notifications/categories/moderation.ts +++ b/src/core/server/services/notifications/categories/moderation.ts @@ -1,84 +1,80 @@ -import { CommentStatusUpdatedInput } from "coral-server/graph/resolvers/Subscription/commentStatusUpdated"; -import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types"; +import { + CommentStatusUpdatedCoralEventPayload, + CoralEventType, +} from "coral-server/events"; import { hasModeratorStatus } from "coral-server/models/comment"; +import { getURLWithCommentID } from "coral-server/models/story"; import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types"; -import { getURLWithCommentID } from "coral-server/models/story"; -import NotificationContext from "../context"; -import { Notification } from "../notification"; + import { NotificationCategory } from "./category"; -async function processor( - ctx: NotificationContext, - input: CommentStatusUpdatedInput -): Promise { - // Check to see if this comment was previously in a moderation status. - if (!hasModeratorStatus({ status: input.oldStatus })) { - return null; - } - - // Load the comment in question. - const comment = await ctx.comments.load(input.commentID); - if (!comment || !comment.authorID) { - return null; - } - - // Get the comment author. - const author = await ctx.users.load(comment.authorID); - if (!author) { - return null; - } - - // Check to see if this user has notifications enabled. - if (!author.notifications.onModeration) { - return null; - } - - // Generate the unsubscribe URL. - const unsubscribeURL = await ctx.generateUnsubscribeURL(author); - - // Check to see which template we should use. - if (comment.status === GQLCOMMENT_STATUS.APPROVED) { - // Get the story that this was written on. - const story = await ctx.stories.load(comment.storyID); - if (!story) { +export const moderation: NotificationCategory< + CommentStatusUpdatedCoralEventPayload +> = { + name: "moderation", + process: async (ctx, input) => { + // Check to see if this comment was previously in a moderation status. + if (!hasModeratorStatus({ status: input.data.oldStatus })) { return null; } - return { - userID: author.id, - template: { - name: "notification/on-comment-approved", - context: { - commentPermalink: getURLWithCommentID(story.url, comment.id), - organizationName: ctx.tenant.organization.name, - organizationURL: ctx.tenant.organization.url, - unsubscribeURL, - }, - }, - }; - } else if (comment.status === GQLCOMMENT_STATUS.REJECTED) { - return { - userID: author.id, - template: { - name: "notification/on-comment-rejected", - context: { - organizationName: ctx.tenant.organization.name, - organizationURL: ctx.tenant.organization.url, - unsubscribeURL, - }, - }, - }; - } + // Load the comment in question. + const comment = await ctx.comments.load(input.data.commentID); + if (!comment || !comment.authorID) { + return null; + } - return null; -} + // Get the comment author. + const author = await ctx.users.load(comment.authorID); + if (!author) { + return null; + } -export const moderation: NotificationCategory[] = [ - { - name: "moderation", - process: processor, - event: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED, - digestOrder: 30, + // Check to see if this user has notifications enabled. + if (!author.notifications.onModeration) { + return null; + } + + // Generate the unsubscribe URL. + const unsubscribeURL = await ctx.generateUnsubscribeURL(author); + + // Check to see which template we should use. + if (comment.status === GQLCOMMENT_STATUS.APPROVED) { + // Get the story that this was written on. + const story = await ctx.stories.load(comment.storyID); + if (!story) { + return null; + } + + return { + userID: author.id, + template: { + name: "notification/on-comment-approved", + context: { + commentPermalink: getURLWithCommentID(story.url, comment.id), + organizationName: ctx.tenant.organization.name, + organizationURL: ctx.tenant.organization.url, + unsubscribeURL, + }, + }, + }; + } else if (comment.status === GQLCOMMENT_STATUS.REJECTED) { + return { + userID: author.id, + template: { + name: "notification/on-comment-rejected", + context: { + organizationName: ctx.tenant.organization.name, + organizationURL: ctx.tenant.organization.url, + unsubscribeURL, + }, + }, + }; + } + + return null; }, -]; + events: [CoralEventType.COMMENT_STATUS_UPDATED], + digestOrder: 30, +}; diff --git a/src/core/server/services/notifications/categories/reply.ts b/src/core/server/services/notifications/categories/reply.ts index 8816f48c4..e6c595525 100644 --- a/src/core/server/services/notifications/categories/reply.ts +++ b/src/core/server/services/notifications/categories/reply.ts @@ -1,112 +1,95 @@ -import { CommentReplyCreatedInput } from "coral-server/graph/resolvers/Subscription/commentReplyCreated"; -import { CommentStatusUpdatedInput } from "coral-server/graph/resolvers/Subscription/commentStatusUpdated"; -import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types"; +import { + CommentReplyCreatedCoralEventPayload, + CommentStatusUpdatedCoralEventPayload, + CoralEventType, +} from "coral-server/events"; import { hasPublishedStatus } from "coral-server/models/comment"; import { getStoryTitle, getURLWithCommentID } from "coral-server/models/story"; -import NotificationContext from "../context"; -import { Notification } from "../notification"; import { NotificationCategory } from "./category"; -async function processor( - ctx: NotificationContext, - input: CommentReplyCreatedInput -): Promise { - const comment = await ctx.comments.load(input.commentID); - if (!comment || !hasPublishedStatus(comment) || !comment.authorID) { - return null; - } +type Payloads = + | CommentReplyCreatedCoralEventPayload + | CommentStatusUpdatedCoralEventPayload; - // Check to see if this is a reply to an existing comment. - if (!comment.parentID) { - return null; - } +export const reply: NotificationCategory = { + name: "reply", + process: async (ctx, input) => { + const comment = await ctx.comments.load(input.data.commentID); + if (!comment || !hasPublishedStatus(comment)) { + return null; + } - // Get the parent comment. - const parent = await ctx.comments.load(comment.parentID); - if (!parent || !hasPublishedStatus(parent) || !parent.authorID) { - return null; - } + // TODO: evaluate storing a history of comment statuses so we can ensure we don't double send. - // Get the parent comment's author. - const [author, parentAuthor] = await ctx.users.loadMany([ - comment.authorID, - parent.authorID, - ]); - if (!author || !parentAuthor) { - return null; - } + // Check to see if this is a reply to an existing comment. + if (!comment.parentID || !comment.authorID) { + return null; + } - // Check to see if the target user has notifications enabled for this type. - if (!parentAuthor.notifications.onReply) { - return null; - } + // Get the parent comment. + const parent = await ctx.comments.load(comment.parentID); + if (!parent || !hasPublishedStatus(parent) || !parent.authorID) { + return null; + } - // Check to see if this is yourself replying to yourself, if that's the case - // don't send a notification. - if (parentAuthor.id === author.id) { - return null; - } + // Get the parent comment's author. + const [author, parentAuthor] = await ctx.users.loadMany([ + comment.authorID, + parent.authorID, + ]); + if (!author || !parentAuthor) { + return null; + } - // Check to see if this user is ignoring the user who replied to their - // comment. - if (parentAuthor.ignoredUsers.some(user => user.id === author.id)) { - return null; - } + // Check to see if the target user has notifications enabled for this type. + if (!parentAuthor.notifications.onReply) { + return null; + } - // Get the story that this was written on. - const story = await ctx.stories.load(comment.storyID); - if (!story) { - return null; - } + // Check to see if this is yourself replying to yourself, if that's the case + // don't send a notification. + if (parentAuthor.id === author.id) { + return null; + } - // Generate the unsubscribe URL. - const unsubscribeURL = await ctx.generateUnsubscribeURL(parentAuthor); + // Check to see if this user is ignoring the user who replied to their + // comment. + if (parentAuthor.ignoredUsers.some(user => user.id === author.id)) { + return null; + } - // The user does have notifications for replied comments enabled, queue the - // notification to be sent. - return { - userID: parentAuthor.id, - template: { - name: "notification/on-reply", - context: { - // We know that the user had a username because they wrote a comment! - authorUsername: author.username!, - commentPermalink: getURLWithCommentID(story.url, comment.id), - storyTitle: getStoryTitle(story), - storyURL: story.url, - organizationName: ctx.tenant.organization.name, - organizationURL: ctx.tenant.organization.url, - unsubscribeURL, + // Get the story that this was written on. + const story = await ctx.stories.load(comment.storyID); + if (!story) { + return null; + } + + // Generate the unsubscribe URL. + const unsubscribeURL = await ctx.generateUnsubscribeURL(parentAuthor); + + // The user does have notifications for replied comments enabled, queue the + // notification to be sent. + return { + userID: parentAuthor.id, + template: { + name: "notification/on-reply", + context: { + // We know that the user had a username because they wrote a comment! + authorUsername: author.username!, + commentPermalink: getURLWithCommentID(story.url, comment.id), + storyTitle: getStoryTitle(story), + storyURL: story.url, + organizationName: ctx.tenant.organization.name, + organizationURL: ctx.tenant.organization.url, + unsubscribeURL, + }, }, - }, - }; -} - -export const reply: NotificationCategory[] = [ - { - name: "reply", - process: processor, - event: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED, - digestOrder: 30, + }; }, - { - name: "reply", - process: async (ctx, input: CommentStatusUpdatedInput) => { - const comment = await ctx.comments.load(input.commentID); - if (!comment || !hasPublishedStatus(comment)) { - return null; - } - - // TODO: evaluate storing a history of comment statuses so we can ensure we don't double send. - - // We've checked the status, let the processing continue! - return processor(ctx, { - commentID: comment.id, - ancestorIDs: comment.ancestorIDs, - }); - }, - event: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED, - digestOrder: 30, - }, -]; + events: [ + CoralEventType.COMMENT_STATUS_UPDATED, + CoralEventType.COMMENT_REPLY_CREATED, + ], + digestOrder: 30, +}; diff --git a/src/core/server/services/notifications/categories/staffReply.ts b/src/core/server/services/notifications/categories/staffReply.ts index d561f4322..465450dbd 100644 --- a/src/core/server/services/notifications/categories/staffReply.ts +++ b/src/core/server/services/notifications/categories/staffReply.ts @@ -1,114 +1,102 @@ -import { CommentReplyCreatedInput } from "coral-server/graph/resolvers/Subscription/commentReplyCreated"; -import { CommentStatusUpdatedInput } from "coral-server/graph/resolvers/Subscription/commentStatusUpdated"; -import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types"; +import { + CommentReplyCreatedCoralEventPayload, + CommentStatusUpdatedCoralEventPayload, + CoralEventType, +} from "coral-server/events"; import { hasPublishedStatus } from "coral-server/models/comment"; import { getStoryTitle, getURLWithCommentID } from "coral-server/models/story"; import { hasStaffRole } from "coral-server/models/user/helpers"; -import NotificationContext from "../context"; -import { Notification } from "../notification"; import { NotificationCategory } from "./category"; -async function processor( - ctx: NotificationContext, - input: CommentReplyCreatedInput -): Promise { - const comment = await ctx.comments.load(input.commentID); - if (!comment || !hasPublishedStatus(comment) || !comment.authorID) { - return null; - } +type Payloads = + | CommentStatusUpdatedCoralEventPayload + | CommentReplyCreatedCoralEventPayload; - // Check to see if this is a reply to an existing comment. - if (!comment.parentID) { - return null; - } +export const staffReply: NotificationCategory = { + name: "staffReply", + process: async (ctx, input) => { + const comment = await ctx.comments.load(input.data.commentID); + if (!comment || !hasPublishedStatus(comment)) { + return null; + } - // Get the parent comment. - const parent = await ctx.comments.load(comment.parentID); - if (!parent || !hasPublishedStatus(parent) || !parent.authorID) { - return null; - } + // TODO: evaluate storing a history of comment statuses so we can ensure we don't double send. - // Get the parent comment's author. - const [author, parentAuthor] = await ctx.users.loadMany([ - comment.authorID, - parent.authorID, - ]); - if (!author || !parentAuthor) { - return null; - } + // Check to see if this is a reply to an existing comment. + if (!comment.parentID) { + return null; + } - // Check to see if the author was a staff member. - if (!hasStaffRole(author)) { - return null; - } + // Get the parent comment. + const parent = await ctx.comments.load(comment.parentID); + if ( + !parent || + !hasPublishedStatus(parent) || + !parent.authorID || + !comment.authorID + ) { + return null; + } - // Check to see if the target user has notifications enabled for this type. - if (!parentAuthor.notifications.onStaffReplies) { - return null; - } + // Get the parent comment's author. + const [author, parentAuthor] = await ctx.users.loadMany([ + comment.authorID, + parent.authorID, + ]); + if (!author || !parentAuthor) { + return null; + } - // Check to see if this is yourself replying to yourself, if that's the case - // don't send a notification. - if (parentAuthor.id === author.id) { - return null; - } + // Check to see if the author was a staff member. + if (!hasStaffRole(author)) { + // This is a handler for staff replies only. + return null; + } - // Get the story that this was written on. - const story = await ctx.stories.load(comment.storyID); - if (!story) { - return null; - } + // Check to see if the target user has notifications enabled for this type. + if (!parentAuthor.notifications.onStaffReplies) { + return null; + } - // Generate the unsubscribe URL. - const unsubscribeURL = await ctx.generateUnsubscribeURL(parentAuthor); + // Check to see if this is yourself replying to yourself, if that's the case + // don't send a notification. + if (parentAuthor.id === author.id) { + return null; + } - // The user does have notifications for replied comments enabled, queue the - // notification to be sent. - return { - userID: parentAuthor.id, - template: { - name: "notification/on-staff-reply", - context: { - // We know that the user had a username because they wrote a comment! - authorUsername: author.username!, - commentPermalink: getURLWithCommentID(story.url, comment.id), - storyTitle: getStoryTitle(story), - storyURL: story.url, - organizationName: ctx.tenant.organization.name, - organizationURL: ctx.tenant.organization.url, - unsubscribeURL, + // Get the story that this was written on. + const story = await ctx.stories.load(comment.storyID); + if (!story) { + return null; + } + + // Generate the unsubscribe URL. + const unsubscribeURL = await ctx.generateUnsubscribeURL(parentAuthor); + + // The user does have notifications for replied comments enabled, queue the + // notification to be sent. + return { + userID: parentAuthor.id, + template: { + name: "notification/on-staff-reply", + context: { + // We know that the user had a username because they wrote a comment! + authorUsername: author.username!, + commentPermalink: getURLWithCommentID(story.url, comment.id), + storyTitle: getStoryTitle(story), + storyURL: story.url, + organizationName: ctx.tenant.organization.name, + organizationURL: ctx.tenant.organization.url, + unsubscribeURL, + }, }, - }, - }; -} - -export const staffReply: NotificationCategory[] = [ - { - name: "staffReply", - process: processor, - event: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED, - digestOrder: 30, - supersedesCategories: ["reply"], + }; }, - { - name: "staffReply", - process: async (ctx, input: CommentStatusUpdatedInput) => { - const comment = await ctx.comments.load(input.commentID); - if (!comment || !hasPublishedStatus(comment)) { - return null; - } - - // TODO: evaluate storing a history of comment statuses so we can ensure we don't double send. - - // We've checked the status, let the processing continue! - return processor(ctx, { - commentID: comment.id, - ancestorIDs: comment.ancestorIDs, - }); - }, - event: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED, - digestOrder: 30, - supersedesCategories: ["reply"], - }, -]; + events: [ + CoralEventType.COMMENT_STATUS_UPDATED, + CoralEventType.COMMENT_REPLY_CREATED, + ], + digestOrder: 30, + supersedesCategories: ["reply"], +}; diff --git a/src/core/server/services/slack/context.ts b/src/core/server/services/slack/context.ts deleted file mode 100644 index 6122b0d99..000000000 --- a/src/core/server/services/slack/context.ts +++ /dev/null @@ -1,55 +0,0 @@ -import Logger from "bunyan"; -import DataLoader from "dataloader"; -import { Db } from "mongodb"; - -import { Config } from "coral-server/config"; -import { Comment, retrieveManyComments } from "coral-server/models/comment"; -import { retrieveManyStories, Story } from "coral-server/models/story"; -import { Tenant } from "coral-server/models/tenant"; -import { retrieveManyUsers, User } from "coral-server/models/user"; -import { Request } from "coral-server/types/express"; - -interface Options { - mongo: Db; - tenant: Pick; - config: Config; - req?: Request; -} - -class SlackContext { - public readonly mongo: Db; - public readonly tenant: Pick; - public readonly logger: Logger; - public readonly config: Config; - public readonly req?: Request; - - public readonly comments: DataLoader< - string, - Readonly | null - > = new DataLoader(commentIDs => - retrieveManyComments(this.mongo, this.tenant.id, commentIDs) - ); - - public readonly stories: DataLoader< - string, - Readonly | null - > = new DataLoader(storyIDs => - retrieveManyStories(this.mongo, this.tenant.id, storyIDs) - ); - - public readonly users: DataLoader< - string, - Readonly | null - > = new DataLoader(userIDs => - retrieveManyUsers(this.mongo, this.tenant.id, userIDs) - ); - - constructor({ mongo, tenant, config, req }: Options) { - this.mongo = mongo; - this.tenant = tenant; - this.config = config; - this.req = req; - } -} - -export default SlackContext; diff --git a/src/core/server/services/slack/index.ts b/src/core/server/services/slack/index.ts deleted file mode 100644 index b26ba5afc..000000000 --- a/src/core/server/services/slack/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as slackPublisher } from "./publisher"; -export * from "./publisher"; -export * from "./context"; diff --git a/src/core/server/services/slack/publisher.ts b/src/core/server/services/slack/publisher.ts deleted file mode 100644 index 59fd6a724..000000000 --- a/src/core/server/services/slack/publisher.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { Db } from "mongodb"; - -import { reconstructTenantURL } from "coral-server/app/url"; -import { Config } from "coral-server/config"; -import { CommentCreatedInput } from "coral-server/graph/resolvers/Subscription/commentCreated"; -import { CommentEnteredModerationQueueInput } from "coral-server/graph/resolvers/Subscription/commentEnteredModerationQueue"; -import { CommentFeaturedInput } from "coral-server/graph/resolvers/Subscription/commentFeatured"; -import { CommentLeftModerationQueueInput } from "coral-server/graph/resolvers/Subscription/commentLeftModerationQueue"; -import { CommentReleasedInput } from "coral-server/graph/resolvers/Subscription/commentReleased"; -import { CommentReplyCreatedInput } from "coral-server/graph/resolvers/Subscription/commentReplyCreated"; -import { CommentStatusUpdatedInput } from "coral-server/graph/resolvers/Subscription/commentStatusUpdated"; -import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types"; -import logger from "coral-server/logger"; -import { getLatestRevision } from "coral-server/models/comment/helpers"; -import { - getStoryTitle, - getURLWithCommentID, -} from "coral-server/models/story/helpers"; -import { Tenant } from "coral-server/models/tenant"; - -import { GQLMODERATION_QUEUE } from "coral-server/graph/schema/__generated__/types"; - -import SlackContext from "./context"; - -type Payload = - | CommentEnteredModerationQueueInput - | CommentLeftModerationQueueInput - | CommentStatusUpdatedInput - | CommentReplyCreatedInput - | CommentCreatedInput - | CommentFeaturedInput - | CommentReleasedInput; - -function isFeatured(channel: SUBSCRIPTION_CHANNELS) { - return channel === SUBSCRIPTION_CHANNELS.COMMENT_FEATURED; -} - -function isReported(channel: SUBSCRIPTION_CHANNELS, payload: Payload) { - return ( - channel === SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE && - (payload as CommentEnteredModerationQueueInput).queue === - GQLMODERATION_QUEUE.REPORTED - ); -} - -function isPending(channel: SUBSCRIPTION_CHANNELS, payload: Payload) { - return ( - channel === SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE && - (payload as CommentEnteredModerationQueueInput).queue === - GQLMODERATION_QUEUE.PENDING - ); -} - -function createModerationLink(ctx: SlackContext, commentID: string) { - return reconstructTenantURL( - ctx.config, - ctx.tenant, - ctx.req, - `/admin/moderate/comment/${commentID}` - ); -} - -async function postCommentToSlack( - ctx: SlackContext, - message: string, - commentID: string, - hookURL: string -) { - const comment = await ctx.comments.load(commentID); - if (comment === null || !comment.authorID) { - return; - } - const author = await ctx.users.load(comment.authorID); - if (author === null) { - return; - } - const story = await ctx.stories.load(comment.storyID); - if (story === null) { - return; - } - - // Get some properties about the event. - const storyTitle = getStoryTitle(story); - const commentBody = getLatestRevision(comment).body; - const moderateLink = createModerationLink(ctx, commentID); - const commentLink = getURLWithCommentID(story.url, comment.id); - - // Replace HTML link breaks with newlines. - const body = commentBody.replace(//g, "\n"); - - const data = { - text: `${message} on *<${story.url}|${storyTitle}>*`, - attachments: [ - { - text: body, - footer: `Authored by *${author.username}* | <${moderateLink}|Go to Moderation> | <${commentLink}|See Comment>`, - }, - ], - }; - - try { - // Send the post to the Slack URL. - const res = await fetch(hookURL, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }); - - if (!res.ok) { - logger.error({ res }, "error sending Slack comment"); - } - } catch (err) { - logger.error({ err }, "error sending Slack comment"); - } -} - -export type SlackPublisher = ( - channel: SUBSCRIPTION_CHANNELS, - payload: Payload -) => Promise; - -function createSlackPublisher( - mongo: Db, - config: Config, - tenant: Tenant -): SlackPublisher { - if ( - !tenant.slack || - !tenant.slack.channels || - tenant.slack.channels.length === 0 - ) { - return async () => { - // noop - }; - } - - const { channels } = tenant.slack; - - return async (channel: SUBSCRIPTION_CHANNELS, payload: Payload) => { - const ctx = new SlackContext({ mongo, config, tenant }); - - try { - const reported = isReported(channel, payload); - const pending = isPending(channel, payload); - const featured = isFeatured(channel); - - // If the comment doesn't match any filter, then we don't need to send - // anything. - if (!reported && !pending && !featured) { - return; - } - - const { commentID } = payload; - - for (const ch of channels) { - if (!ch) { - return; - } - if (!ch.enabled) { - return; - } - const { hookURL } = ch; - if (!hookURL) { - return; - } - const { triggers } = ch; - if (!triggers) { - return; - } - - // Add ticket to add back all comments option (including approved) - - if (triggers.reportedComments && reported) { - await postCommentToSlack( - ctx, - "This comment has been reported", - commentID, - hookURL - ); - } else if (triggers.pendingComments && pending) { - await postCommentToSlack( - ctx, - "This comment is pending", - commentID, - hookURL - ); - } else if (triggers.featuredComments && featured) { - await postCommentToSlack( - ctx, - "This comment has been featured", - commentID, - hookURL - ); - } - } - } catch (err) { - logger.error( - { err, tenantID: tenant.id, channel, payload }, - "could not handle comment in Slack publisher" - ); - } - }; -} - -export default createSlackPublisher; diff --git a/src/core/server/services/stories/index.ts b/src/core/server/services/stories/index.ts index 8edbd0dd9..58a990487 100644 --- a/src/core/server/services/stories/index.ts +++ b/src/core/server/services/stories/index.ts @@ -4,6 +4,8 @@ import { Db } from "mongodb"; import isNonNullArray from "coral-common/helpers/isNonNullArray"; import { Config } from "coral-server/config"; import { StoryURLInvalidError } from "coral-server/errors"; +import { StoryCreatedCoralEvent } from "coral-server/events"; +import { CoralEventPublisherBroker } from "coral-server/events/publisher"; import logger from "coral-server/logger"; import { mergeCommentActionCounts, @@ -53,6 +55,7 @@ export type FindOrCreateStory = FindOrCreateStoryInput; export async function findOrCreate( mongo: Db, tenant: Tenant, + broker: CoralEventPublisherBroker, input: FindOrCreateStory, scraper: ScraperQueue, now = new Date() @@ -70,11 +73,24 @@ export async function findOrCreate( siteID = site.id; } - const story = await findOrCreateStory(mongo, tenant.id, input, siteID, now); + const { story, wasUpserted } = await findOrCreateStory( + mongo, + tenant.id, + input, + siteID, + now + ); if (!story) { return null; } + if (wasUpserted) { + StoryCreatedCoralEvent.publish(broker, { + storyID: story.id, + storyURL: story.url, + }); + } + if (tenant.stories.scraping.enabled && !story.metadata && !story.scrapedAt) { // If the scraper has not scraped this story, and we have no metadata, we // need to scrape it now! @@ -160,6 +176,7 @@ export type CreateStory = Partial< export async function create( mongo: Db, tenant: Tenant, + broker: CoralEventPublisherBroker, config: Config, storyID: string, storyURL: string, @@ -182,7 +199,7 @@ export async function create( } // Create the story in the database. - let newStory = await createStory( + let story = await createStory( mongo, tenant.id, storyID, @@ -193,10 +210,15 @@ export async function create( if (!metadata && tenant.stories.scraping.enabled) { // If the scraper has not scraped this story and story metadata was not // provided, we need to scrape it now! - newStory = await scrape(mongo, config, tenant.id, newStory.id, storyURL); + story = await scrape(mongo, config, tenant.id, story.id, storyURL); } - return newStory; + StoryCreatedCoralEvent.publish(broker, { + storyID: story.id, + storyURL: story.url, + }); + + return story; } export type UpdateStory = UpdateStoryInput; diff --git a/src/core/server/services/stories/scraper/scraper.ts b/src/core/server/services/stories/scraper/scraper.ts index 9c9277bd6..3b51b0b81 100644 --- a/src/core/server/services/stories/scraper/scraper.ts +++ b/src/core/server/services/stories/scraper/scraper.ts @@ -5,19 +5,17 @@ import descriptionScraper from "metascraper-description"; import imageScraper from "metascraper-image"; import titleScraper from "metascraper-title"; import { Db } from "mongodb"; -import fetch, { RequestInit } from "node-fetch"; import ProxyAgent from "proxy-agent"; -import { version } from "coral-common/version"; import { Config } from "coral-server/config"; import { ScrapeFailed } from "coral-server/errors"; import logger from "coral-server/logger"; import { retrieveStory, updateStory } from "coral-server/models/story"; import { retrieveTenant } from "coral-server/models/tenant"; +import { createFetch, Fetch, FetchOptions } from "coral-server/services/fetch"; import { GQLStoryMetadata } from "coral-server/graph/schema/__generated__/types"; -import abortAfter from "./abortAfter"; import { modifiedScraper } from "./rules/modified"; import { publishedScraper } from "./rules/published"; import { sectionScraper } from "./rules/section"; @@ -32,8 +30,10 @@ export type Rule = Record< class Scraper { private readonly rules: Rule[]; private readonly log: Logger; + private readonly fetch: Fetch; constructor(rules: Rule[]) { + this.fetch = createFetch({ name: "Scraper" }); this.rules = rules; this.log = logger.child({ taskName: "scraper" }, true); } @@ -85,27 +85,25 @@ class Scraper { public async download( url: string, - abortAfterMilliseconds: number, + timeout: number, customUserAgent?: string, proxyURL?: string ) { const log = this.log.child({ storyURL: url }, true); - // Abort the scrape request after the timeout is reached. - const { controller, timeout } = abortAfter(abortAfterMilliseconds); - - const options: RequestInit = { - headers: { - "User-Agent": customUserAgent || `Talk Scraper/${version}`, - }, - signal: controller.signal, - }; + const options: FetchOptions = { timeout }; + if (customUserAgent) { + options.headers = { + ...options.headers, + "User-Agent": customUserAgent, + }; + } if (proxyURL) { // Force the type here because there's a slight mismatch. options.agent = (new ProxyAgent( proxyURL - ) as unknown) as RequestInit["agent"]; + ) as unknown) as FetchOptions["agent"]; log.debug("using proxy for scrape"); } @@ -113,8 +111,8 @@ class Scraper { log.debug("starting scrape of Story"); try { - const res = await fetch(url, options); - if (!res.ok || res.status !== 200) { + const res = await this.fetch(url, options); + if (!res.ok) { log.warn( { statusCode: res.status, statusText: res.statusText }, "scrape failed with non-200 status code" @@ -129,8 +127,6 @@ class Scraper { return html; } catch (err) { throw new ScrapeFailed(url, err); - } finally { - clearTimeout(timeout); } } @@ -198,14 +194,12 @@ export async function scrape( // This typecast is needed because the custom `ms` format does not return the // desired `number` type even though that's the only type it can output. - const abortAfterMilliseconds = (config.get( - "scrape_timeout" - ) as unknown) as number; + const timeout = (config.get("scrape_timeout") as unknown) as number; // Get the metadata from the scraped html. const metadata = await scraper.scrape( storyURL, - abortAfterMilliseconds, + timeout, tenant.stories.scraping.customUserAgent, tenant.stories.scraping.proxyURL ); diff --git a/src/core/server/services/tenant/index.ts b/src/core/server/services/tenant/index.ts index cc92bfb91..9be9a8afd 100644 --- a/src/core/server/services/tenant/index.ts +++ b/src/core/server/services/tenant/index.ts @@ -14,12 +14,19 @@ import { createTenantAnnouncement, CreateTenantInput, createTenantSSOKey, + createTenantWebhookEndpoint, + CreateTenantWebhookEndpointInput, deleteTenantAnnouncement, + deleteTenantWebhookEndpoint, disableTenantFeatureFlag, enableTenantFeatureFlag, + getWebhookEndpoint, + rollTenantWebhookEndpointSecret, rotateTenantSSOKey, Tenant, updateTenant, + updateTenantWebhookEndpoint, + UpdateTenantWebhookEndpointInput, } from "coral-server/models/tenant"; import { I18n } from "coral-server/services/i18n"; @@ -27,6 +34,7 @@ import { GQLFEATURE_FLAG, GQLSettingsInput, GQLSettingsWordListInput, + GQLWEBHOOK_EVENT_NAME, } from "coral-server/graph/schema/__generated__/types"; import TenantCache from "./cache"; @@ -203,6 +211,258 @@ export async function discoverOIDCConfiguration(issuerString: string) { return discover(issuer); } +interface WebhookEndpointInput { + url: string; + all: boolean; + events: GQLWEBHOOK_EVENT_NAME[]; +} + +export function validateWebhookEndpointInput( + config: Config, + input: WebhookEndpointInput +) { + // Check to see that this URL is valid and has a https:// scheme if in + // production mode. + const url = new URL(input.url); + if (config.get("env") === "production" && url.protocol !== "https:") { + throw new Error(`invalid scheme provided in production: ${url.protocol}`); + } + + // Ensure that either the "all" or "events" is provided but not both. + if (input.all && input.events.length > 0) { + throw new Error("both all events and specific events were requested"); + } +} + +export async function createWebhookEndpoint( + mongo: Db, + redis: Redis, + config: Config, + cache: TenantCache, + tenant: Tenant, + input: CreateTenantWebhookEndpointInput, + now: Date +) { + // Validate the input. + validateWebhookEndpointInput(config, input); + + // Looks good in create this, send it off to be created. + const result = await createTenantWebhookEndpoint( + mongo, + tenant.id, + input, + now + ); + if (!result.tenant) { + throw new Error("could not create the tenant endpoint, tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, result.tenant); + + return { + endpoint: result.endpoint, + settings: result.tenant, + }; +} + +export async function updateWebhookEndpoint( + mongo: Db, + redis: Redis, + config: Config, + cache: TenantCache, + tenant: Tenant, + endpointID: string, + input: UpdateTenantWebhookEndpointInput +) { + // Find the endpoint. + let endpoint = getWebhookEndpoint(tenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + // Extract the input. + const { + url = endpoint.url, + all = endpoint.all, + events = endpoint.events, + } = input; + + // Validate the input. + validateWebhookEndpointInput(config, { + url, + all, + events, + }); + + const updatedTenant = await updateTenantWebhookEndpoint( + mongo, + tenant.id, + endpointID, + input + ); + if (!updatedTenant) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + // Find the updated endpoint. + endpoint = getWebhookEndpoint(updatedTenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + return endpoint; +} + +export async function enableWebhookEndpoint( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + endpointID: string +) { + // Find the endpoint. + let endpoint = getWebhookEndpoint(tenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + // Endpoint is already enabled. + if (endpoint.enabled === true) { + return endpoint; + } + + const updatedTenant = await updateTenantWebhookEndpoint( + mongo, + tenant.id, + endpointID, + { enabled: true } + ); + if (!updatedTenant) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + // Find the updated endpoint. + endpoint = getWebhookEndpoint(updatedTenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + return endpoint; +} + +export async function disableWebhookEndpoint( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + endpointID: string +) { + // Find the endpoint. + let endpoint = getWebhookEndpoint(tenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + // Endpoint is already disabled. + if (endpoint.enabled === false) { + return endpoint; + } + + const updatedTenant = await updateTenantWebhookEndpoint( + mongo, + tenant.id, + endpointID, + { enabled: false } + ); + if (!updatedTenant) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + // Find the updated endpoint. + endpoint = getWebhookEndpoint(updatedTenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + return endpoint; +} + +export async function deleteWebhookEndpoint( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + endpointID: string +) { + // Find the endpoint. + const endpoint = getWebhookEndpoint(tenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + const updatedTenant = await deleteTenantWebhookEndpoint( + mongo, + tenant.id, + endpointID + ); + if (!updatedTenant) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + return endpoint; +} + +export async function rotateWebhookEndpointSecret( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + endpointID: string, + inactiveIn: number, + now: Date +) { + // Compute the inactiveAt dates for the current active secrets. + const inactiveAt = DateTime.fromJSDate(now) + .plus({ seconds: inactiveIn }) + .toJSDate(); + + // Rotate the secrets. + const updatedTenant = await rollTenantWebhookEndpointSecret( + mongo, + tenant.id, + endpointID, + inactiveAt, + now + ); + if (!updatedTenant) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + // Find the updated endpoint. + const endpoint = getWebhookEndpoint(updatedTenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + return endpoint; +} + export async function enableFeatureFlag( mongo: Db, redis: Redis, diff --git a/src/core/server/services/users/auth/invite.ts b/src/core/server/services/users/auth/invite.ts index 65972d575..f820b9857 100644 --- a/src/core/server/services/users/auth/invite.ts +++ b/src/core/server/services/users/auth/invite.ts @@ -4,7 +4,15 @@ import { DateTime } from "luxon"; import { Db } from "mongodb"; import uuid from "uuid"; +import { constructTenantURL } from "coral-server/app/url"; import { Config } from "coral-server/config"; +import { + IntegrationDisabled, + InviteIncludesExistingUser, + InviteRequiresEmailAddresses, + InviteTokenExpired, + TokenInvalidError, +} from "coral-server/errors"; import { createInvite, Invite, @@ -28,15 +36,8 @@ import { verifyJWT, } from "coral-server/services/jwt"; -import { constructTenantURL } from "coral-server/app/url"; -import { - IntegrationDisabled, - InviteIncludesExistingUser, - InviteRequiresEmailAddresses, - InviteTokenExpired, - TokenInvalidError, -} from "coral-server/errors"; import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types"; + import { validateEmail, validatePassword, validateUsername } from "../helpers"; export interface InviteToken extends Required { @@ -324,5 +325,7 @@ export async function redeem( now ); + // TODO: (wyattjoh) emit that a user was created + return user; } diff --git a/src/core/server/services/users/download/download.ts b/src/core/server/services/users/download/download.ts index f95713f0f..cefa051eb 100644 --- a/src/core/server/services/users/download/download.ts +++ b/src/core/server/services/users/download/download.ts @@ -6,9 +6,11 @@ import htmlToText from "html-to-text"; import { kebabCase } from "lodash"; import { Db } from "mongodb"; -import { getLatestRevision } from "coral-server/models/comment"; -import { Comment } from "coral-server/models/comment"; -import { retrieveManyStories } from "coral-server/models/story"; +import { Comment, getLatestRevision } from "coral-server/models/comment"; +import { + getURLWithCommentID, + retrieveManyStories, +} from "coral-server/models/story"; import { Tenant } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; @@ -102,15 +104,11 @@ export async function sendUserDownload( const revision = getLatestRevision(comment); - const commentID = comment.id; const createdAt = formatter.format(new Date(comment.createdAt)); - const storyURL = story.url; - const urlBuilder = new URL(storyURL); - urlBuilder.searchParams.set("commentID", commentID); - const commentURL = urlBuilder.href; const body = htmlToText.fromString(revision.body); + const commentURL = getURLWithCommentID(story.url, comment.id); - csv.write([commentID, createdAt, storyURL, commentURL, body]); + csv.write([comment.id, createdAt, story.url, commentURL, body]); } commentBatch = []; diff --git a/src/core/server/services/users/users.ts b/src/core/server/services/users/users.ts index 1c477e19e..da701733e 100644 --- a/src/core/server/services/users/users.ts +++ b/src/core/server/services/users/users.ts @@ -5,8 +5,8 @@ import { ALLOWED_USERNAME_CHANGE_TIMEFRAME_DURATION, COMMENT_REPEAT_POST_DURATION, DOWNLOAD_LIMIT_TIMEFRAME_DURATION, + SCHEDULED_DELETION_WINDOW_DURATION, } from "coral-common/constants"; -import { SCHEDULED_DELETION_WINDOW_DURATION } from "coral-common/constants"; import { Config } from "coral-server/config"; import { DuplicateEmailError, @@ -25,10 +25,6 @@ import { UsernameUpdatedWithinWindowError, UserNotFoundError, } from "coral-server/errors"; -import { - GQLAuthIntegrations, - GQLUSER_ROLE, -} from "coral-server/graph/schema/__generated__/types"; import logger from "coral-server/logger"; import { Comment, retrieveComment } from "coral-server/models/comment"; import { Tenant } from "coral-server/models/tenant"; @@ -72,12 +68,16 @@ import { import { getLocalProfile, hasLocalProfile, + hasStaffRole, } from "coral-server/models/user/helpers"; -import { hasStaffRole } from "coral-server/models/user/helpers"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; +import { JWTSigningConfig, signPATString } from "coral-server/services/jwt"; import { sendConfirmationEmail } from "coral-server/services/users/auth"; -import { JWTSigningConfig, signPATString } from "coral-server/services/jwt"; +import { + GQLAuthIntegrations, + GQLUSER_ROLE, +} from "coral-server/graph/schema/__generated__/types"; import { AugmentedRedis } from "../redis"; import { @@ -125,7 +125,16 @@ export async function findOrCreate( // Validate the input. validateFindOrCreateUserInput(input, options); - const user = await findOrCreateUser(mongo, tenant.id, input, now); + const { user, wasUpserted } = await findOrCreateUser( + mongo, + tenant.id, + input, + now + ); + + if (wasUpserted) { + // TODO: (wyattjoh) emit that a user was created + } // TODO: (wyattjoh) evaluate the tenant to determine if we should send the verification email. @@ -169,6 +178,8 @@ export async function create( const user = await createUser(mongo, tenant.id, input, now); + // TODO: (wyattjoh) emit that a user was created + // TODO: (wyattjoh) evaluate the tenant to determine if we should send the verification email. return user; diff --git a/src/core/server/stacks/approveComment.ts b/src/core/server/stacks/approveComment.ts index 91e6aeea2..1daabbe6d 100644 --- a/src/core/server/stacks/approveComment.ts +++ b/src/core/server/stacks/approveComment.ts @@ -1,7 +1,7 @@ import { Db } from "mongodb"; import { Config } from "coral-server/config"; -import { Publisher } from "coral-server/graph/subscriptions/publisher"; +import { CoralEventPublisherBroker } from "coral-server/events/publisher"; import { Tenant } from "coral-server/models/tenant"; import { moderate } from "coral-server/services/comments/moderation"; import { notifyPerspectiveModerationDecision } from "coral-server/services/perspective"; @@ -15,7 +15,7 @@ const approveComment = async ( mongo: Db, redis: AugmentedRedis, config: Config, - publisher: Publisher, + broker: CoralEventPublisherBroker, tenant: Tenant, commentID: string, commentRevisionID: string, @@ -44,7 +44,7 @@ const approveComment = async ( }); // Publish changes to the event publisher. - await publishChanges(publisher, { + await publishChanges(broker, { ...result, ...counts, moderatorID, diff --git a/src/core/server/stacks/createComment.ts b/src/core/server/stacks/createComment.ts index 326a436a5..a793b14be 100644 --- a/src/core/server/stacks/createComment.ts +++ b/src/core/server/stacks/createComment.ts @@ -8,7 +8,7 @@ import { CoralError, StoryNotFoundError, } from "coral-server/errors"; -import { Publisher } from "coral-server/graph/subscriptions/publisher"; +import { CoralEventPublisherBroker } from "coral-server/events/publisher"; import logger from "coral-server/logger"; import { encodeActionCounts, @@ -59,7 +59,7 @@ export default async function create( mongo: Db, redis: AugmentedRedis, config: Config, - publisher: Publisher, + broker: CoralEventPublisherBroker, tenant: Tenant, author: User, input: CreateComment, @@ -233,19 +233,19 @@ export default async function create( }); // Publish changes to the event publisher. - await publishChanges(publisher, { + await publishChanges(broker, { ...counts, after: comment, }); // If this is a reply, publish it. if (input.parentID) { - publishCommentReplyCreated(publisher, comment); + publishCommentReplyCreated(broker, comment); } // If this comment is visible (and not a reply), publish it. if (!input.parentID && hasPublishedStatus(comment)) { - publishCommentCreated(publisher, comment); + publishCommentCreated(broker, comment); } return comment; diff --git a/src/core/server/stacks/editComment.ts b/src/core/server/stacks/editComment.ts index b4e471f8b..79a9f3376 100644 --- a/src/core/server/stacks/editComment.ts +++ b/src/core/server/stacks/editComment.ts @@ -4,7 +4,7 @@ import { Db } from "mongodb"; import { Omit } from "coral-common/types"; import { Config } from "coral-server/config"; import { CommentNotFoundError, StoryNotFoundError } from "coral-server/errors"; -import { Publisher } from "coral-server/graph/subscriptions/publisher"; +import { CoralEventPublisherBroker } from "coral-server/events/publisher"; import logger from "coral-server/logger"; import { encodeActionCounts, @@ -57,7 +57,7 @@ export default async function edit( mongo: Db, redis: AugmentedRedis, config: Config, - publisher: Publisher, + broker: CoralEventPublisherBroker, tenant: Tenant, author: User, input: EditComment, @@ -188,7 +188,7 @@ export default async function edit( }); // Publish changes to the event publisher. - await publishChanges(publisher, { + await publishChanges(broker, { ...result, ...counts, }); diff --git a/src/core/server/stacks/helpers/publishChanges.ts b/src/core/server/stacks/helpers/publishChanges.ts index 2461615ce..747e3c944 100644 --- a/src/core/server/stacks/helpers/publishChanges.ts +++ b/src/core/server/stacks/helpers/publishChanges.ts @@ -1,4 +1,4 @@ -import { Publisher } from "coral-server/graph/subscriptions/publisher"; +import { CoralEventPublisherBroker } from "coral-server/events/publisher"; import { Comment, CommentModerationQueueCounts, @@ -19,17 +19,17 @@ interface PublishChangesInput { } export default async function publishChanges( - publish: Publisher, + broker: CoralEventPublisherBroker, input: PublishChangesInput ) { // Publish changes. - publishModerationQueueChanges(publish, input.moderationQueue, input.after); + publishModerationQueueChanges(broker, input.moderationQueue, input.after); // If this was a change, and it has a "before" state for the comment, process // those updates too. if (input.before) { publishCommentStatusChanges( - publish, + broker, input.before.status, input.after.status, input.after.id, @@ -37,7 +37,7 @@ export default async function publishChanges( ); if (hasModeratorStatus(input.before) && hasPublishedStatus(input.after)) { - publishCommentReleased(publish, input.after); + publishCommentReleased(broker, input.after); } } } diff --git a/src/core/server/stacks/rejectComment.ts b/src/core/server/stacks/rejectComment.ts index 0a1d16611..3292d48e6 100644 --- a/src/core/server/stacks/rejectComment.ts +++ b/src/core/server/stacks/rejectComment.ts @@ -1,7 +1,7 @@ import { Db } from "mongodb"; import { Config } from "coral-server/config"; -import { Publisher } from "coral-server/graph/subscriptions/publisher"; +import { CoralEventPublisherBroker } from "coral-server/events/publisher"; import { hasTag } from "coral-server/models/comment"; import { Tenant } from "coral-server/models/tenant"; import { removeTag } from "coral-server/services/comments"; @@ -20,7 +20,7 @@ const rejectComment = async ( mongo: Db, redis: AugmentedRedis, config: Config, - publisher: Publisher, + broker: CoralEventPublisherBroker, tenant: Tenant, commentID: string, commentRevisionID: string, @@ -49,7 +49,7 @@ const rejectComment = async ( }); // Publish changes to the event publisher. - await publishChanges(publisher, { + await publishChanges(broker, { ...result, ...counts, moderatorID, diff --git a/src/locales/da/admin.ftl b/src/locales/da/admin.ftl index 0c1667580..f19bc30c9 100644 --- a/src/locales/da/admin.ftl +++ b/src/locales/da/admin.ftl @@ -541,7 +541,7 @@ community-filter-allStatuses = Alle statuer community-column-username = Brugernavn community-column-email = Email community-column-memberSince = Medlem siden -community-column-role = Rolle +community-column-role = Rotatee community-column-status = Status community-role-popover = diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index bee9b1707..d35f3e66b 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -79,6 +79,7 @@ configure-sideBarNavigation-advanced = Advanced configure-sideBarNavigation-email = Email configure-sideBarNavigation-bannedAndSuspectWords = Banned and Suspect Words configure-sideBarNavigation-slack = Slack +configure-sideBarNavigation-webhooks = Webhooks configure-sideBar-saveChanges = Save Changes configure-configurationSubHeader = Configuration @@ -87,6 +88,88 @@ configure-onOffField-off = Off configure-radioButton-allow = Allow configure-radioButton-dontAllow = Don't allow +### Webhooks +configure-webhooks-webhookEndpointNotFound = Webhook endpoint not found +configure-webhooks-header-title = Configure webhook endpoint +configure-webhooks-description = + Configure an endpoint to send events to when events occur within + Coral. These events will be JSON encoded and signed. To learn more + about webhook signing, visit our Webhook Guide. +configure-webhooks-addEndpoint = Add webhook endpoint +configure-webhooks-addEndpointButton = Add webhook endpoint +configure-webhooks-endpoints = Endpoints +configure-webhooks-url = URL +configure-webhooks-status = Status +configure-webhooks-noEndpoints = There are no webhook endpoints configured, add one above. +configure-webhooks-enabledWebhookEndpoint = Enabled +configure-webhooks-disabledWebhookEndpoint = Disabled +configure-webhooks-endpointURL = Endpoint URL +configure-webhooks-cancelButton = Cancel +configure-webhooks-updateWebhookEndpointButton = Update webhook endpoint +configure-webhooks-eventsToSend = Events to send +configure-webhooks-clearEventsToSend = Clear +configure-webhooks-eventsToSendDescription = + These are the events that are registered to this particular endpoint. Visit + our Webhook Guide for the schema of these events. + Any event matching the following will be sent to the endpoint if it is + enabled: +configure-webhooks-allEvents = + The endpoint will receive all events, including any added in the future. +configure-webhooks-selectedEvents = + { $count } { $count -> + [1] event + *[other] events + } selected. +configure-webhooks-selectAnEvent = + Select events above or . +configure-webhooks-configureWebhookEndpoint = Configure webhook endpoint +configure-webhooks-confirmEnable = + Enabling the webhook endpoint will start to send events to this URL. Are you sure you want to continue? +configure-webhooks-confirmDisable = + Disabling this webhook endpoint will stop any new events from being sent to this URL. Are you sure you want to continue? +configure-webhooks-confirmDelete = + Deleting this webhook endpoint will stop any new events from being sent to this URL, and remove all the associated settings with this webhook endpoint. Are you sure you want to continue? +configure-webhooks-dangerZone = Danger Zone +configure-webhooks-rotateSigningSecret = Rotate signing secret +configure-webhooks-rotateSigningSecretDescription = + Rotating the signing secret will allow to you to safely replace a signing + secret used in production with a delay. +configure-webhooks-rotateSigningSecretButton = Rotate signing secret +configure-webhooks-rotateSigningSecretHelper = + After it expires, signatures will no longer be generated with the old secret. +configure-webhooks-rotateSigningSecretSuccessUseNewSecret = + Webhook endpoint signing secret has been rotated. Please ensure + you update your integrations to use the new secret below. +configure-webhooks-disableEndpoint = Disable endpoint +configure-webhooks-disableEndpointDescription = + This endpoint is current enabled. By disabling this endpoint no new events + will be sent to the URL provided. +configure-webhooks-disableEndpointButton = Disable endpoint +configure-webhooks-enableEndpoint = Enable endpoint +configure-webhooks-enableEndpointDescription = + This endpoint is current disabled. By enabling this endpoint new events will + be sent to the URL provided. +configure-webhooks-enableEndpointButton = Enable endpoint +configure-webhooks-deleteEndpoint = Delete endpoint +configure-webhooks-deleteEndpointDescription = + Deleting the endpoint will prevent any new events from being sent to the URL + provided. +configure-webhooks-deleteEndpointButton = Delete endpoint +configure-webhooks-endpointStatus = Endpoint status +configure-webhooks-signingSecret = Signing secret +configure-webhooks-signingSecretDescription = + The following signing secret is used to sign request payloads sent + to the URL. To learn more about webhook signing, visit our + Webhook Guide. +configure-webhooks-expiresOldSecret = Expire the old secret +configure-webhooks-expiresOldSecretImmediately = Immediately +configure-webhooks-expiresOldSecretHoursFromNow = + { $hours -> + [1] 1 hour + *[other] { $hours } hours + } from now +configure-webhooks-detailsButton = Details keyboard_arrow_right + ### General configure-general-guidelines-title = Community guidelines summary configure-general-guidelines-explanation = diff --git a/src/locales/en-US/framework.ftl b/src/locales/en-US/framework.ftl index 9b88ede20..afde0384c 100644 --- a/src/locales/en-US/framework.ftl +++ b/src/locales/en-US/framework.ftl @@ -33,6 +33,7 @@ framework-validation-notAWholeNumberGreaterThan = Please enter a whole number gr framework-validation-notAWholeNumberGreaterThanOrEqual = Please enter a whole number greater than or equal to { $x } framework-validation-usernamesDoNotMatch = Usernames do not match. Try again. framework-validation-deleteConfirmationInvalid = Incorrect confirmation. Try again. +framework-validation-invalidWebhookEndpointEventSelection = Select at least one event to receive. framework-timeago-just-now = Just now