diff --git a/EXTERNAL_MODERATION_PHASES.md b/EXTERNAL_MODERATION_PHASES.md new file mode 100644 index 000000000..b29d4070c --- /dev/null +++ b/EXTERNAL_MODERATION_PHASES.md @@ -0,0 +1,240 @@ +# External Moderation Phases Guide + +This document is in reference to external moderation phases emitted by Coral. +You can configure external moderation phases on your installation of Coral by +visiting `/admin/configure/moderation/phases`. + +Once you've configured a external moderation phase in Coral, you will start to +receive moderation requests in the form of a +[External Moderation Requests](#external-moderation-request) at the provided +callback URL. These will be in the form of `POST` requests with a `JSON` +payload. + +When a comment is created or edited, it will be processed by moderation phases in +a predefined order. Any external moderation phase is run last, and only if all +other moderation phases before it do not return a status. The current set of +moderation phases is listed in order [here](https://github.com/coralproject/talk/blob/master/src/core/server/services/comments/pipeline/phases/index.ts). + +Once you have received a moderation request, you must respond within the +provided timeout else the phase will be skipped and it will continue. It is +strongly recommended to [verify the request signature](#request-signing). + +The external moderation phase must respond with one of the following: + +1. Do not moderate the comment, and return a 204 without a body. +2. Perform a moderation action and return a 200 with a [External Moderation Response](#external-moderation-response) + as a `JSON` encoded body containing the operations you want to perform on the + comment. + + + +## Table of Contents + +- [Request Signing](#request-signing) +- [Schema](#schema) + - [External Moderation Request](#external-moderation-request) + - [External Moderation Response](#external-moderation-response) + + + +## Request Signing + +Requests sent by Coral for external moderation phases use the same process as +those used by webhooks. Refer to the [webhooks documentation](WEBHOOKS.md#webhook-signing) +for instructions on how to verify signatures sent by Coral. + +## Schema + +### External Moderation Request + +```ts +interface ExternalModerationRequest { + /** + * action refers to the specific operation being performed. If `NEW`, this + * is referring to a new comment being created. If `EDIT`, then this refers to + * an operation involving an edit operation on an existing Comment. + */ + action: "NEW" | "EDIT"; + + /** + * comment refers to the actual Comment data for the Comment being + * created/edited. + */ + comment: { + /** + * body refers to the actual body text of the Comment being created/edited. + */ + body: string; + + /** + * parentID is the identifier for the parent comment (if this Comment is a + * reply, null otherwise). + */ + parentID: string | null; + }; + + /** + * author refers to the User that is creating/editing the Comment. + */ + author: { + /** + * id is the identifier for this User. + */ + id: string; + + /** + * role refers to the role of this User. + */ + role: "COMMENTER" | "STAFF" | "MODERATOR" | "ADMIN"; + }; + + /** + * story refers to the Story being commented on. + */ + story: { + /** + * id is the identifier for this Story. + */ + id: string; + + /** + * url is the URL for this Story. + */ + url: string; + }; + + /** + * site refers to the Site that the story being commented on belongs to. + */ + site: { + /** + * id is the identifier for this Site. + */ + id: string; + }; + + /** + * tenantID is the identifer of the Tenant that this Comment is being + * created/edited on. + */ + tenantID: string; + + /** + * tenantDomain is the domain that is associated with this Tenant that this + * Comment is being created/edited on. + */ + tenantDomain: string; +} +``` + +#### Example + +New comment on a story: + +```json +{ + "action": "NEW", + "comment": { + "body": "Here's a comment!", + "parentID": null + }, + "author": { + "id": "baf4e943-3594-4fcc-b2ba-3e8de7a76352", + "role": "COMMENTER" + }, + "story": { + "id": "245b3856-b0a0-4d2f-a6bb-58c71f18d6a6", + "url": "http://localhost:1313/posts/a-story-url/" + }, + "site": { + "id": "a4bede88-2d2c-4424-bc18-4322a9e285a6" + }, + "tenantID": "19ba5794-7eeb-4d46-a81b-c00c61672501", + "tenantDomain": "localhost" +} +``` + +New reply on a comment on a story: + +```json +{ + "action": "NEW", + "comment": { + "body": "Here's a reply!", + "parentID": "d79b787f-f406-49a0-a179-72e3652e54be" + }, + "author": { + "id": "baf4e943-3594-4fcc-b2ba-3e8de7a76352", + "role": "COMMENTER" + }, + "story": { + "id": "245b3856-b0a0-4d2f-a6bb-58c71f18d6a6", + "url": "http://localhost:1313/posts/a-story-url/" + }, + "site": { + "id": "a4bede88-2d2c-4424-bc18-4322a9e285a6" + }, + "tenantID": "19ba5794-7eeb-4d46-a81b-c00c61672501", + "tenantDomain": "localhost" +} +``` + +### External Moderation Response + +```ts +interface ExternalModerationResponse { + /** + * actions is an optional list of any flags to be added to this Comment. + */ + actions?: Array<{ + actionType: "FLAG"; + reason: "COMMENT_DETECTED_TOXIC" | "COMMENT_DETECTED_SPAM"; + }>; + + /** + * tags are any listed tags that should be added to the comment. + */ + tags?: Array<"FEATURED" | "STAFF">; + + /** + * status when provided decides and terminates the moderation process by + * setting the status of the comment. + */ + status?: "NONE" | "APPROVED" | "REJECTED" | "PREMOD" | "SYSTEM_WITHHELD"; +} +``` + +#### Examples + +Add a flag to a comment and do not set a status: + +```json +{ + "actions": [{ "actionType": "FLAG", "reason": "COMMENT_DETECTED_TOXIC" }] +} +``` + +Reject a comment: + +```json +{ + "status": "REJECTED" +} +``` + +Feature a comment and do not set a status: + +```json +{ + "tags": ["FEATURED"] +} +``` + +Approve a comment and mark it as featured: + +```json +{ + "status": "APPROVED", + "tags": ["FEATURED"] +} +``` diff --git a/LICENSE b/LICENSE index 2db3b1d5b..50eddcc87 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2019 Vox Media, Inc +Copyright 2020 Vox Media, Inc Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/package.json b/package.json index 4965462c5..d43f9f9fe 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "build:server": "gulp server", "migration:create": "ts-node --transpile-only ./scripts/migration/create.ts", "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", + "doctoc": "doctoc --maxlevel=3 --title '## Table of Contents' README.md CLIENT_EVENTS.md CONTRIBUTING.md WEBHOOKS.md EXTERNAL_MODERATION_PHASES.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/", @@ -413,7 +413,7 @@ "{src/core/client/stream/events.ts,scripts/generateEventDocs.ts,CLIENT_EVENTS.md}": [ "npm run docs:events -- --verify" ], - "{README,CLIENT_EVENTS,CONTRIBUTING,WEBHOOKS}.md": [ + "{README,CLIENT_EVENTS,CONTRIBUTING,WEBHOOKS,EXTERNAL_MODERATION_PHASES}.md": [ "npm run doctoc" ] }, diff --git a/src/core/client/admin/helpers/getExternalModerationPhaseLink.ts b/src/core/client/admin/helpers/getExternalModerationPhaseLink.ts new file mode 100644 index 000000000..a251235cd --- /dev/null +++ b/src/core/client/admin/helpers/getExternalModerationPhaseLink.ts @@ -0,0 +1,5 @@ +import { urls } from "coral-framework/helpers"; + +export default function getExternalModerationPhaseLink(phaseID: string) { + return `${urls.admin.configureExternalModerationPhase}/${phaseID}`; +} diff --git a/src/core/client/admin/routeConfig.tsx b/src/core/client/admin/routeConfig.tsx index 2896c3793..a3d198b12 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 { + AddExternalModerationPhaseRoute, AddWebhookEndpointRoute, AdvancedConfigRoute, AuthConfigRoute, + ConfigureExternalModerationPhaseRoute, ConfigureWebhookEndpointRoute, EmailConfigRoute, GeneralConfigRoute, ModerationConfigRoute, + ModerationPhasesConfigRoute, OrganizationConfigRoute, SlackConfigRoute, WebhookEndpointsConfigRoute, WordListConfigRoute, } from "./routes/Configure/sections"; +import ModerationPhasesLayout from "./routes/Configure/sections/ModerationPhases/ModerationPhasesLayout"; import { Sites } from "./routes/Configure/sections/Sites"; import AddSiteRoute from "./routes/Configure/sections/Sites/AddSiteRoute"; import SiteRoute from "./routes/Configure/sections/Sites/SiteRoute"; @@ -120,13 +124,28 @@ export default makeRouteConfig( path="organization" {...OrganizationConfigRoute.routeConfig} /> - + + + + + + diff --git a/src/core/client/admin/routes/Configure/ConfigureLinks.tsx b/src/core/client/admin/routes/Configure/ConfigureLinks.tsx index c24cb225a..bb06ac8ed 100644 --- a/src/core/client/admin/routes/Configure/ConfigureLinks.tsx +++ b/src/core/client/admin/routes/Configure/ConfigureLinks.tsx @@ -14,7 +14,12 @@ const ConfigureLinks: FunctionComponent<{}> = () => { Organization - Moderation + + Moderation + + + + Moderation Phases Banned and Suspect Words diff --git a/src/core/client/admin/routes/Configure/Link.tsx b/src/core/client/admin/routes/Configure/Link.tsx index e60901468..e8ea9be0d 100644 --- a/src/core/client/admin/routes/Configure/Link.tsx +++ b/src/core/client/admin/routes/Configure/Link.tsx @@ -7,6 +7,7 @@ interface Props { className?: string; children: React.ReactNode; to: string | LocationDescriptor; + exact?: boolean; } const Link: FunctionComponent = (props) => ( @@ -15,6 +16,7 @@ const Link: FunctionComponent = (props) => ( to={props.to} className={styles.link} activeClassName={styles.linkActive} + exact={props.exact} > {props.children} diff --git a/src/core/client/admin/routes/Configure/sections/Auth/RegenerateSSOKeyMutation.ts b/src/core/client/admin/routes/Configure/sections/Auth/RegenerateSSOKeyMutation.ts deleted file mode 100644 index b729ddb38..000000000 --- a/src/core/client/admin/routes/Configure/sections/Auth/RegenerateSSOKeyMutation.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { graphql } from "react-relay"; -import { Environment } from "relay-runtime"; - -import { - commitMutationPromiseNormalized, - createMutation, -} from "coral-framework/lib/relay"; - -import { RegenerateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/RegenerateSSOKeyMutation.graphql"; - -let clientMutationId = 0; - -const RegenerateSSOKeyMutation = createMutation( - "regenerateSSOKey", - (environment: Environment) => - commitMutationPromiseNormalized(environment, { - mutation: graphql` - mutation RegenerateSSOKeyMutation($input: RegenerateSSOKeyInput!) { - regenerateSSOKey(input: $input) { - settings { - auth { - integrations { - sso { - key - keyGeneratedAt - } - } - } - } - clientMutationId - } - } - `, - variables: { - input: { - clientMutationId: (clientMutationId++).toString(), - }, - }, - }) -); - -export default RegenerateSSOKeyMutation; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx index 3908cf187..405268505 100644 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx @@ -8,7 +8,7 @@ import { FormFieldDescription } from "coral-ui/components/v2"; import Header from "../../Header"; import ConfigBoxWithToggleField from "./ConfigBoxWithToggleField"; import RegistrationField from "./RegistrationField"; -import SSOKeyRotationQuery from "./SSOKeyRotation/SSOKeyRotationQuery"; +import SSOSigningSecretRotationQuery from "./SSOSigningSecretRotation/SSOSigningSecretRotationQuery"; import TargetFilterField from "./TargetFilterField"; // eslint-disable-next-line no-unused-expressions @@ -60,7 +60,9 @@ const SSOConfig: FunctionComponent = ({ disabled }) => ( for additional information on single sign on. - + diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.css b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DateField.css similarity index 100% rename from src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.css rename to src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DateField.css diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DateField.tsx similarity index 89% rename from src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.tsx rename to src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DateField.tsx index 528875980..6dd0d9869 100644 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.tsx +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DateField.tsx @@ -3,11 +3,11 @@ import React, { FunctionComponent } from "react"; import { Flex, Label } from "coral-ui/components/v2"; -import { SSOKeyStatus } from "./StatusField"; +import { SSOSigningSecretStatus } from "./StatusField"; import styles from "./DateField.css"; -export interface SSOKeyDates { +export interface SSOSigningSecretDates { readonly createdAt: string; readonly lastUsedAt: string | null; readonly rotatedAt: string | null; @@ -15,13 +15,13 @@ export interface SSOKeyDates { } interface Props { - status: SSOKeyStatus; - dates: SSOKeyDates; + status: SSOSigningSecretStatus; + dates: SSOSigningSecretDates; } const DateField: FunctionComponent = ({ status, dates }) => { switch (status) { - case SSOKeyStatus.ACTIVE: + case SSOSigningSecretStatus.ACTIVE: return ( <>
@@ -37,7 +37,7 @@ const DateField: FunctionComponent = ({ status, dates }) => { ); - case SSOKeyStatus.EXPIRING: + case SSOSigningSecretStatus.EXPIRING: return ( <>
@@ -63,7 +63,7 @@ const DateField: FunctionComponent = ({ status, dates }) => { ); - case SSOKeyStatus.EXPIRED: + case SSOSigningSecretStatus.EXPIRED: return ( <>
diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotateSSOKeyMutation.ts b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DeactivateSSOSigningSecretMutation.ts similarity index 67% rename from src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotateSSOKeyMutation.ts rename to src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DeactivateSSOSigningSecretMutation.ts index 8010a0aa8..77c83e430 100644 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotateSSOKeyMutation.ts +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DeactivateSSOSigningSecretMutation.ts @@ -7,23 +7,25 @@ import { MutationInput, } from "coral-framework/lib/relay"; -import { RotateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/RotateSSOKeyMutation.graphql"; +import { DeactivateSSOSigningSecretMutation as MutationTypes } from "coral-admin/__generated__/DeactivateSSOSigningSecretMutation.graphql"; const clientMutationId = 0; -const RotateSSOKeyMutation = createMutation( - "rotateSSOKey", +const DeactivateSSOSigningSecretMutation = createMutation( + "deactivateSSOSigningSecret", (environment: Environment, input: MutationInput) => { return commitMutationPromiseNormalized(environment, { mutation: graphql` - mutation RotateSSOKeyMutation($input: RotateSSOKeyInput!) { - rotateSSOKey(input: $input) { + mutation DeactivateSSOSigningSecretMutation( + $input: DeactivateSSOSigningSecretInput! + ) { + deactivateSSOSigningSecret(input: $input) { settings { auth { integrations { sso { enabled - keys { + signingSecrets { kid secret createdAt @@ -49,4 +51,4 @@ const RotateSSOKeyMutation = createMutation( } ); -export default RotateSSOKeyMutation; +export default DeactivateSSOSigningSecretMutation; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeactivateSSOKeyMutation.ts b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DeleteSSOSigningSecretMutation.ts similarity index 68% rename from src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeactivateSSOKeyMutation.ts rename to src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DeleteSSOSigningSecretMutation.ts index 16bab846d..996fa88c8 100644 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeactivateSSOKeyMutation.ts +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DeleteSSOSigningSecretMutation.ts @@ -7,23 +7,25 @@ import { MutationInput, } from "coral-framework/lib/relay"; -import { DeactivateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/DeactivateSSOKeyMutation.graphql"; +import { DeleteSSOSigningSecretMutation as MutationTypes } from "coral-admin/__generated__/DeleteSSOSigningSecretMutation.graphql"; const clientMutationId = 0; -const DeactivateSSOKeyMutation = createMutation( - "deactivateSSOKey", +const DeleteSSOSigningSecretMutation = createMutation( + "deleteSSOSigningSecret", (environment: Environment, input: MutationInput) => { return commitMutationPromiseNormalized(environment, { mutation: graphql` - mutation DeactivateSSOKeyMutation($input: DeactivateSSOKeyInput!) { - deactivateSSOKey(input: $input) { + mutation DeleteSSOSigningSecretMutation( + $input: DeleteSSOSigningSecretInput! + ) { + deleteSSOSigningSecret(input: $input) { settings { auth { integrations { sso { enabled - keys { + signingSecrets { kid secret createdAt @@ -49,4 +51,4 @@ const DeactivateSSOKeyMutation = createMutation( } ); -export default DeactivateSSOKeyMutation; +export default DeleteSSOSigningSecretMutation; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeleteSSOKeyMutation.ts b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/RotateSSOSigningSecretMutation.ts similarity index 68% rename from src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeleteSSOKeyMutation.ts rename to src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/RotateSSOSigningSecretMutation.ts index b459854c1..58365d681 100644 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeleteSSOKeyMutation.ts +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/RotateSSOSigningSecretMutation.ts @@ -7,23 +7,25 @@ import { MutationInput, } from "coral-framework/lib/relay"; -import { DeleteSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/DeleteSSOKeyMutation.graphql"; +import { RotateSSOSigningSecretMutation as MutationTypes } from "coral-admin/__generated__/RotateSSOSigningSecretMutation.graphql"; const clientMutationId = 0; -const DeleteSSOKeyMutation = createMutation( - "deleteSSOKey", +const RotateSSOSigningSecretMutation = createMutation( + "rotateSSOSigningSecret", (environment: Environment, input: MutationInput) => { return commitMutationPromiseNormalized(environment, { mutation: graphql` - mutation DeleteSSOKeyMutation($input: DeleteSSOKeyInput!) { - deleteSSOKey(input: $input) { + mutation RotateSSOSigningSecretMutation( + $input: RotateSSOSigningSecretInput! + ) { + rotateSSOSigningSecret(input: $input) { settings { auth { integrations { sso { enabled - keys { + signingSecrets { kid secret createdAt @@ -49,4 +51,4 @@ const DeleteSSOKeyMutation = createMutation( } ); -export default DeleteSSOKeyMutation; +export default RotateSSOSigningSecretMutation; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.css b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/RotationDropdown.css similarity index 100% rename from src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.css rename to src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/RotationDropdown.css diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/RotationDropdown.tsx similarity index 100% rename from src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.tsx rename to src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/RotationDropdown.tsx diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationOption.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/RotationOption.tsx similarity index 100% rename from src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationOption.tsx rename to src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/RotationOption.tsx diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.css b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/SSOSigningSecretCard.css similarity index 100% rename from src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.css rename to src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/SSOSigningSecretCard.css diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/SSOSigningSecretCard.tsx similarity index 75% rename from src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.tsx rename to src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/SSOSigningSecretCard.tsx index 39df18340..095234664 100644 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.tsx +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/SSOSigningSecretCard.tsx @@ -15,16 +15,16 @@ import { } from "coral-ui/components/v2"; import DateField from "./DateField"; -import DeactivateSSOKeyMutation from "./DeactivateSSOKeyMutation"; -import DeleteSSOKeyMutation from "./DeleteSSOKeyMutation"; -import RotateSSOKeyMutation from "./RotateSSOKeyMutation"; +import DeactivateSSOSigningSecretMutation from "./DeactivateSSOSigningSecretMutation"; +import DeleteSSOSigningSecretMutation from "./DeleteSSOSigningSecretMutation"; +import RotateSSOSigningSecretMutation from "./RotateSSOSigningSecretMutation"; import RotationDropDown from "./RotationDropdown"; import { RotateOptions } from "./RotationOption"; -import StatusField, { SSOKeyStatus } from "./StatusField"; +import StatusField, { SSOSigningSecretStatus } from "./StatusField"; -import styles from "./SSOKeyCard.css"; +import styles from "./SSOSigningSecretCard.css"; -export interface SSOKeyDates { +export interface SSOSigningSecretDates { readonly createdAt: string; readonly lastUsedAt: string | null; readonly rotatedAt: string | null; @@ -34,22 +34,22 @@ export interface SSOKeyDates { interface Props { id: string; secret: string; - status: SSOKeyStatus; - dates: SSOKeyDates; + status: SSOSigningSecretStatus; + dates: SSOSigningSecretDates; disabled?: boolean; } function createActionButton( - status: SSOKeyStatus, + status: SSOSigningSecretStatus, onRotateKey: (rotation: string) => void, onDeactivateKey: () => void, onDelete: () => void, disabled?: boolean ) { switch (status) { - case SSOKeyStatus.ACTIVE: + case SSOSigningSecretStatus.ACTIVE: return ; - case SSOKeyStatus.EXPIRING: + case SSOSigningSecretStatus.EXPIRING: return ( ); - case SSOKeyStatus.EXPIRED: + case SSOSigningSecretStatus.EXPIRED: return ( + + + + {phase.enabled ? ( + + + + + + + This external moderation phase is current enabled. By disabling, + no new moderation queries will be sent to the URL provided. + + + + + + + ) : ( + + + + + + + This external moderation phase is currently disabled. By enabling, + new moderation queries will be sent to the URL provided. + + + + + + + )} + + + + + + + Deleting this external moderation phase will stop any new moderation + queries from being sent to this URL and will remove all the + associated settings. + + + + + + + + ); +}; + +const enhanced = withRouter( + withFragmentContainer({ + phase: graphql` + fragment ExternalModerationPhaseDangerZone_phase on ExternalModerationPhase { + id + enabled + } + `, + })(ExternalModerationPhaseDangerZone) +); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/ExternalModerationPhaseDetails.tsx b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/ExternalModerationPhaseDetails.tsx new file mode 100644 index 000000000..2e86058cb --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/ExternalModerationPhaseDetails.tsx @@ -0,0 +1,35 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; +import { graphql } from "react-relay"; + +import Subheader from "coral-admin/routes/Configure/Subheader"; +import { withFragmentContainer } from "coral-framework/lib/relay"; + +import { ExternalModerationPhaseDetails_phase } from "coral-admin/__generated__/ExternalModerationPhaseDetails_phase.graphql"; + +import ConfigureExternalModerationPhaseForm from "../ConfigureExternalModerationPhaseForm"; + +interface Props { + phase: ExternalModerationPhaseDetails_phase; +} + +const ExternalModerationPhaseDetails: FunctionComponent = ({ + phase, +}) => ( + <> + + Phase details + + + +); + +const enhanced = withFragmentContainer({ + phase: graphql` + fragment ExternalModerationPhaseDetails_phase on ExternalModerationPhase { + ...ConfigureExternalModerationPhaseForm_phase + } + `, +})(ExternalModerationPhaseDetails); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/ExternalModerationPhaseStatus.tsx b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/ExternalModerationPhaseStatus.tsx new file mode 100644 index 000000000..97340b80f --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/ExternalModerationPhaseStatus.tsx @@ -0,0 +1,91 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; +import { graphql } from "react-relay"; + +import Subheader from "coral-admin/routes/Configure/Subheader"; +import { CopyButton } from "coral-framework/components"; +import { ExternalLink } from "coral-framework/lib/i18n/components"; +import { withFragmentContainer } from "coral-framework/lib/relay"; +import { + Flex, + FormField, + FormFieldDescription, + HelperText, + Label, + PasswordField, +} from "coral-ui/components/v2"; + +import { ExternalModerationPhaseStatus_phase } from "coral-admin/__generated__/ExternalModerationPhaseStatus_phase.graphql"; + +import StatusMarker from "../StatusMarker"; + +interface Props { + phase: ExternalModerationPhaseStatus_phase; +} + +const ExternalModerationPhaseStatus: FunctionComponent = ({ phase }) => { + return ( + <> + + Phase status + + + + + + + + + + + + + } + > + + The following signing secret is used to sign request payloads sent + to the URL. To learn more about webhook signing, visit our{" "} + + docs + + . + + + + + + + + + KEY GENERATED AT: {phase.signingSecret.createdAt} + + + + + ); +}; + +const enhanced = withFragmentContainer({ + phase: graphql` + fragment ExternalModerationPhaseStatus_phase on ExternalModerationPhase { + id + enabled + signingSecret { + secret + createdAt + } + } + `, +})(ExternalModerationPhaseStatus); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/RotateExternalModerationPhaseSigningSecretMutation.ts b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/RotateExternalModerationPhaseSigningSecretMutation.ts new file mode 100644 index 000000000..c5122f26a --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/RotateExternalModerationPhaseSigningSecretMutation.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 { RotateExternalModerationPhaseSigningSecretMutation as MutationTypes } from "coral-admin/__generated__/RotateExternalModerationPhaseSigningSecretMutation.graphql"; + +let clientMutationId = 0; + +const RotateExternalModerationPhaseSigningSecretMutation = createMutation( + "rotateExternalModerationPhaseSigningSecret", + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation RotateExternalModerationPhaseSigningSecretMutation( + $input: RotateExternalModerationPhaseSigningSecretInput! + ) { + rotateExternalModerationPhaseSigningSecret(input: $input) { + phase { + ...ConfigureExternalModerationPhaseContainer_phase + } + } + } + `, + variables: { + input: { + ...input, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }) +); + +export default RotateExternalModerationPhaseSigningSecretMutation; diff --git a/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/RotateSigningSecretModal.css b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/RotateSigningSecretModal.css new file mode 100644 index 000000000..09aae758a --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/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/ModerationPhases/ConfigureExternalModerationPhase/RotateSigningSecretModal.tsx b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/RotateSigningSecretModal.tsx new file mode 100644 index 000000000..0923eeeaa --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/RotateSigningSecretModal.tsx @@ -0,0 +1,170 @@ +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 RotateExternalModerationPhaseSigningSecretMutation from "./RotateExternalModerationPhaseSigningSecretMutation"; + +import styles from "./RotateSigningSecretModal.css"; + +interface Props { + phaseID: string; + onHide: () => void; + open: boolean; +} + +const RotateWebhookEndpointSigningSecretModal: FunctionComponent = ({ + onHide, + open, + phaseID, +}) => { + const rotateExternalModerationPhaseSigningSecret = useMutation( + RotateExternalModerationPhaseSigningSecretMutation + ); + const { setMessage, clearMessage } = useNotification(); + const onRotateSecret = useCallback( + async ({ inactiveIn: inactiveInString }) => { + try { + const inactiveIn = parseInt(inactiveInString, 10); + await rotateExternalModerationPhaseSigningSecret({ + id: phaseID, + inactiveIn, + }); + + // Post a notification about the successful change. + setMessage( + + + External moderation phase signing secret has been rotated. Please + ensure you update your integrations to use the new secret below. + + + ); + + // Scroll after a zero timeout because chrome won't scroll otherwise. + setTimeout(() => window.scroll(0, 0), 0); + } catch (err) { + if (err instanceof InvalidRequestError) { + return err.invalidArgs; + } + return { [FORM_ERROR]: err.message }; + } + + // Dismiss the modal. + onHide(); + + return; + }, + [phaseID, rotateExternalModerationPhaseSigningSecret] + ); + + 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 RotateWebhookEndpointSigningSecretModal; diff --git a/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/index.ts b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/index.ts new file mode 100644 index 000000000..927d09099 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhase/index.ts @@ -0,0 +1,4 @@ +export { + default, + default as ConfigureWebhookEndpointRoute, +} from "./ConfigureExternalModerationPhaseRoute"; diff --git a/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhaseForm/ConfigureExternalModerationPhaseForm.tsx b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhaseForm/ConfigureExternalModerationPhaseForm.tsx new file mode 100644 index 000000000..467ed45dc --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhaseForm/ConfigureExternalModerationPhaseForm.tsx @@ -0,0 +1,240 @@ +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 { graphql } from "react-relay"; + +import getExternalModerationPhaseLink from "coral-admin/helpers/getExternalModerationPhaseLink"; +import { InvalidRequestError } from "coral-framework/lib/errors"; +import { + colorFromMeta, + parseInteger, + ValidationMessage, +} from "coral-framework/lib/form"; +import { useMutation, withFragmentContainer } from "coral-framework/lib/relay"; +import { + composeValidators, + required, + validateURL, + validateWholeNumberBetween, +} from "coral-framework/lib/validation"; +import { GQLCOMMENT_BODY_FORMAT } from "coral-framework/schema"; +import { + Button, + CallOut, + Flex, + FormField, + HelperText, + HorizontalGutter, + Label, + Option, + SelectField, + TextField, +} from "coral-ui/components/v2"; + +import { ConfigureExternalModerationPhaseForm_phase } from "coral-admin/__generated__/ConfigureExternalModerationPhaseForm_phase.graphql"; + +import CreateExternalModerationPhaseMutation from "./CreateExternalModerationPhaseMutation"; +import UpdateExternalModerationPhaseMutation from "./UpdateExternalModerationPhaseMutation"; + +interface Props { + onCancel?: () => void; + router: Router; + match: Match; + phase: ConfigureExternalModerationPhaseForm_phase | null; +} + +const initialValues = (phase?: any) => + phase + ? phase + : { + name: "", + url: "", + timeout: 200, + format: "HTML", + }; + +const ConfigureExternalModerationPhaseForm: FunctionComponent = ({ + onCancel, + phase, + router, +}) => { + const create = useMutation(CreateExternalModerationPhaseMutation); + const update = useMutation(UpdateExternalModerationPhaseMutation); + const onSubmit = useCallback( + async (values) => { + try { + if (phase) { + // The external moderation phase was defined, update it. + await update(values); + } else { + // The external moderation phase wasn't defined, create it. + const result = await create(values); + + // Redirect the user to the new external moderation phase page. + router.push(getExternalModerationPhaseLink(result.phase.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 }; + } + }, + [phase, create, update, router] + ); + + return ( +
+ {({ handleSubmit, submitting, submitError, pristine }) => ( + + + {submitError && ( + + {submitError} + + )} + + {({ input, meta }) => ( + + + + + + + + )} + + + {({ input, meta }) => ( + + + + + + + The URL that Coral moderation requests will be POST'ed to. + The provided URL must respond within the designated + timeout or the decision of the moderation action will be + skipped. + + + + + + )} + + + {({ input, meta }) => ( + + + + + + + The time that Coral will wait for your moderation response + in milliseconds. + + + + + + )} + + + {({ input, meta }) => ( + + + + + + + The format that Coral will send the comment body in. By + default, Coral will send the comment in the original HTML + encoded format. If "Plain Text" is selected, then the HTML + stripped version will be sent instead. + + + + + + + + + + + + + )} + + + {onCancel && ( + + + + )} + {phase ? ( + + + + ) : ( + + + + )} + + +
+ )} + + ); +}; + +const enhanced = withRouter( + withFragmentContainer({ + phase: graphql` + fragment ConfigureExternalModerationPhaseForm_phase on ExternalModerationPhase { + id + name + url + timeout + format + } + `, + })(ConfigureExternalModerationPhaseForm) +); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhaseForm/CreateExternalModerationPhaseMutation.ts b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhaseForm/CreateExternalModerationPhaseMutation.ts new file mode 100644 index 000000000..0fc44e506 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhaseForm/CreateExternalModerationPhaseMutation.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 { CreateExternalModerationPhaseMutation as MutationTypes } from "coral-admin/__generated__/CreateExternalModerationPhaseMutation.graphql"; + +let clientMutationId = 0; + +const CreateExternalModerationPhaseMutation = createMutation( + "createExternalModerationPhase", + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation CreateExternalModerationPhaseMutation( + $input: CreateExternalModerationPhaseInput! + ) { + createExternalModerationPhase(input: $input) { + phase { + id + } + settings { + ...ModerationPhasesConfigContainer_settings + } + } + } + `, + variables: { + input: { + ...input, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }) +); + +export default CreateExternalModerationPhaseMutation; diff --git a/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhaseForm/UpdateExternalModerationPhaseMutation.ts b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhaseForm/UpdateExternalModerationPhaseMutation.ts new file mode 100644 index 000000000..cea5fe2d5 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhaseForm/UpdateExternalModerationPhaseMutation.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 { UpdateExternalModerationPhaseMutation as MutationTypes } from "coral-admin/__generated__/UpdateExternalModerationPhaseMutation.graphql"; + +let clientMutationId = 0; + +const UpdateExternalModerationPhaseMutation = createMutation( + "updateExternalModerationPhase", + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation UpdateExternalModerationPhaseMutation( + $input: UpdateExternalModerationPhaseInput! + ) { + updateExternalModerationPhase(input: $input) { + phase { + ...ConfigureExternalModerationPhaseContainer_phase + } + } + } + `, + variables: { + input: { + ...input, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }) +); + +export default UpdateExternalModerationPhaseMutation; diff --git a/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhaseForm/index.ts b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhaseForm/index.ts new file mode 100644 index 000000000..06e590bd5 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ConfigureExternalModerationPhaseForm/index.ts @@ -0,0 +1,4 @@ +export { + default, + default as ConfigureExternalModerationPhaseForm, +} from "./ConfigureExternalModerationPhaseForm"; diff --git a/src/core/client/admin/routes/Configure/sections/ModerationPhases/ExperimentalExternalModerationPhaseCallOut.tsx b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ExperimentalExternalModerationPhaseCallOut.tsx new file mode 100644 index 000000000..c9d7fca4d --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ExperimentalExternalModerationPhaseCallOut.tsx @@ -0,0 +1,23 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +import ExperimentalCallOut from "coral-admin/components/ExperimentalCallOut"; +import { ExternalLink } from "coral-framework/lib/i18n/components"; + +const ExperimentalExternalModerationPhaseCallOut: FunctionComponent = () => ( + } + > + + The custom moderation phases feature is currently in active development. + Please{" "} + + contact us with any feedback or requests + + . + + +); + +export default ExperimentalExternalModerationPhaseCallOut; diff --git a/src/core/client/admin/routes/Configure/sections/ModerationPhases/ExternalModerationPhaseRow.css b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ExternalModerationPhaseRow.css new file mode 100644 index 000000000..1b5b25d5a --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ExternalModerationPhaseRow.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/ModerationPhases/ExternalModerationPhaseRow.tsx b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ExternalModerationPhaseRow.tsx new file mode 100644 index 000000000..492f73b71 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ExternalModerationPhaseRow.tsx @@ -0,0 +1,61 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; +import { graphql } from "react-relay"; + +import getExternalModerationPhaseLink from "coral-admin/helpers/getExternalModerationPhaseLink"; +import { withFragmentContainer } from "coral-framework/lib/relay"; +import { + Button, + Flex, + Icon, + TableCell, + TableRow, +} from "coral-ui/components/v2"; + +import { ExternalModerationPhaseRow_phase } from "coral-admin/__generated__/ExternalModerationPhaseRow_phase.graphql"; + +import StatusMarker from "./StatusMarker"; + +import styles from "./ExternalModerationPhaseRow.css"; + +interface Props { + phase: ExternalModerationPhaseRow_phase; +} + +const ExternalModerationPhaseRow: FunctionComponent = ({ phase }) => ( + + {phase.name} + + + + + + keyboard_arrow_right} + > + + + + + +); + +const enhanced = withFragmentContainer({ + phase: graphql` + fragment ExternalModerationPhaseRow_phase on ExternalModerationPhase { + id + name + enabled + } + `, +})(ExternalModerationPhaseRow); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/ModerationPhases/ModerationPhasesConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ModerationPhasesConfigContainer.tsx new file mode 100644 index 000000000..5359a46e1 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ModerationPhasesConfigContainer.tsx @@ -0,0 +1,123 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; +import { graphql } from "react-relay"; + +import { urls } from "coral-framework/helpers"; +import { ExternalLink } from "coral-framework/lib/i18n/components"; +import { withFragmentContainer } from "coral-framework/lib/relay"; +import { + Button, + CallOut, + FormFieldDescription, + HorizontalGutter, + Icon, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "coral-ui/components/v2"; + +import { ModerationPhasesConfigContainer_settings } from "coral-admin/__generated__/ModerationPhasesConfigContainer_settings.graphql"; + +import ConfigBox from "../../ConfigBox"; +import Header from "../../Header"; +import Subheader from "../../Subheader"; +import ExperimentalExternalModerationPhaseCallOut from "./ExperimentalExternalModerationPhaseCallOut"; +import ExternalModerationPhaseRow from "./ExternalModerationPhaseRow"; + +interface Props { + settings: ModerationPhasesConfigContainer_settings; +} + +const ModerationPhasesConfigContainer: FunctionComponent = ({ + settings, +}) => { + return ( + + + +
+ Moderation Phases +
+ + } + > + + } + > + + Configure a external moderation phase to automate some moderation + actions. Moderation requests will be JSON encoded and signed. To + learn more about moderation requests, visit our{" "} + + docs + + . + + + + + Moderation Phases + + {settings.integrations.external && + settings.integrations.external.phases.length > 0 ? ( + + + + + Name + + + Status + + + + + + {settings.integrations.external.phases.map((phase, idx) => ( + + ))} + +
+ ) : ( + + + There are no external moderation phases configured, add one above. + + + )} +
+
+ ); +}; + +const enhanced = withFragmentContainer({ + settings: graphql` + fragment ModerationPhasesConfigContainer_settings on Settings { + integrations { + external { + phases { + ...ExternalModerationPhaseRow_phase + } + } + } + } + `, +})(ModerationPhasesConfigContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/ModerationPhases/ModerationPhasesConfigRoute.tsx b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ModerationPhasesConfigRoute.tsx new file mode 100644 index 000000000..146ffc002 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ModerationPhasesConfigRoute.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 { ModerationPhasesConfigRouteQueryResponse } from "coral-admin/__generated__/ModerationPhasesConfigRouteQuery.graphql"; + +import ModerationPhasesConfigContainer from "./ModerationPhasesConfigContainer"; + +interface Props { + data: ModerationPhasesConfigRouteQueryResponse | null; +} + +const ModerationPhasesConfigRoute: FunctionComponent = ({ data }) => { + if (!data) { + return ( + + + + ); + } + + return ; +}; + +const enhanced = withRouteConfig({ + query: graphql` + query ModerationPhasesConfigRouteQuery { + settings { + ...ModerationPhasesConfigContainer_settings + } + } + `, +})(ModerationPhasesConfigRoute); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/ModerationPhases/ModerationPhasesLayout.tsx b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ModerationPhasesLayout.tsx new file mode 100644 index 000000000..ad766b9a4 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/ModerationPhasesLayout.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 ModerationPhasesLayout: FunctionComponent = (props) => { + return ( + + + + + +
{props.children}
+
+
+ ); +}; + +export default ModerationPhasesLayout; diff --git a/src/core/client/admin/routes/Configure/sections/ModerationPhases/StatusMarker.css b/src/core/client/admin/routes/Configure/sections/ModerationPhases/StatusMarker.css new file mode 100644 index 000000000..3d54c73b6 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/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/ModerationPhases/StatusMarker.tsx b/src/core/client/admin/routes/Configure/sections/ModerationPhases/StatusMarker.tsx new file mode 100644 index 000000000..7eabcafc7 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/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/ModerationPhases/index.ts b/src/core/client/admin/routes/Configure/sections/ModerationPhases/index.ts new file mode 100644 index 000000000..708e7d93c --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/ModerationPhases/index.ts @@ -0,0 +1,6 @@ +export { + default, + default as ModerationPhasesConfigRoute, +} from "./ModerationPhasesConfigRoute"; +export { default as AddExternalModerationPhaseRoute } from "./AddExternalModerationPhase"; +export { default as ConfigureExternalModerationPhaseRoute } from "./ConfigureExternalModerationPhase"; 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 index 723feb7a4..97b219f82 100644 --- a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/EndpointDetails.tsx +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/EndpointDetails.tsx @@ -1,3 +1,4 @@ +import { Localized } from "@fluent/react/compat"; import React, { FunctionComponent } from "react"; import { graphql } from "react-relay"; @@ -19,7 +20,9 @@ const EndpointDetails: FunctionComponent = ({ settings, }) => ( <> - Endpoint details + + Endpoint details + = ({ webhookEndpoint }) => { /> - - KEY GENERATED AT: {webhookEndpoint.signingSecret.createdAt} - + + + KEY GENERATED AT: {webhookEndpoint.signingSecret.createdAt} + + ); 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 index 697fec947..81bc9c2f9 100644 --- a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateSigningSecretModal.tsx +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateSigningSecretModal.tsx @@ -22,7 +22,7 @@ import { } from "coral-ui/components/v2"; import AppNotification from "coral-ui/components/v2/AppNotification"; -import RotateWebhookEndpointSecretMutation from "./RotateWebhookEndpointSecretMutation"; +import RotateWebhookEndpointSigningSecretMutation from "./RotateWebhookEndpointSigningSecretMutation"; import styles from "./RotateSigningSecretModal.css"; @@ -32,20 +32,23 @@ interface Props { open: boolean; } -const RotateWebhookEndpointSecretModal: FunctionComponent = ({ +const RotateWebhookEndpointSigningSecretModal: FunctionComponent = ({ onHide, open, endpointID, }) => { - const rotateWebhookEndpointSecret = useMutation( - RotateWebhookEndpointSecretMutation + const rotateWebhookEndpointSigningSecret = useMutation( + RotateWebhookEndpointSigningSecretMutation ); const { setMessage, clearMessage } = useNotification(); const onRotateSecret = useCallback( async ({ inactiveIn: inactiveInString }) => { try { const inactiveIn = parseInt(inactiveInString, 10); - await rotateWebhookEndpointSecret({ id: endpointID, inactiveIn }); + await rotateWebhookEndpointSigningSecret({ + id: endpointID, + inactiveIn, + }); // Post a notification about the successful change. setMessage( @@ -56,7 +59,9 @@ const RotateWebhookEndpointSecretModal: FunctionComponent = ({ ); - window.scroll(0, 0); + + // Scroll after a zero timeout because chrome won't scroll otherwise. + setTimeout(() => window.scroll(0, 0), 0); } catch (err) { if (err instanceof InvalidRequestError) { return err.invalidArgs; @@ -69,7 +74,7 @@ const RotateWebhookEndpointSecretModal: FunctionComponent = ({ return; }, - [endpointID, rotateWebhookEndpointSecret] + [endpointID, rotateWebhookEndpointSigningSecret] ); return ( @@ -162,4 +167,4 @@ const RotateWebhookEndpointSecretModal: FunctionComponent = ({ ); }; -export default RotateWebhookEndpointSecretModal; +export default RotateWebhookEndpointSigningSecretModal; diff --git a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateWebhookEndpointSecretMutation.ts b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateWebhookEndpointSigningSecretMutation.ts similarity index 57% rename from src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateWebhookEndpointSecretMutation.ts rename to src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateWebhookEndpointSigningSecretMutation.ts index 8d5719b84..68a39d25b 100644 --- a/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateWebhookEndpointSecretMutation.ts +++ b/src/core/client/admin/routes/Configure/sections/WebhookEndpoints/ConfigureWebhookEndpoint/RotateWebhookEndpointSigningSecretMutation.ts @@ -7,19 +7,19 @@ import { MutationInput, } from "coral-framework/lib/relay"; -import { RotateWebhookEndpointSecretMutation as MutationTypes } from "coral-admin/__generated__/RotateWebhookEndpointSecretMutation.graphql"; +import { RotateWebhookEndpointSigningSecretMutation as MutationTypes } from "coral-admin/__generated__/RotateWebhookEndpointSigningSecretMutation.graphql"; let clientMutationId = 0; -const RotateWebhookEndpointSecretMutation = createMutation( - "rotateWebhookEndpointSecret", +const RotateWebhookEndpointSigningSecretMutation = createMutation( + "rotateWebhookEndpointSigningSecret", (environment: Environment, input: MutationInput) => commitMutationPromiseNormalized(environment, { mutation: graphql` - mutation RotateWebhookEndpointSecretMutation( - $input: RotateWebhookEndpointSecretInput! + mutation RotateWebhookEndpointSigningSecretMutation( + $input: RotateWebhookEndpointSigningSecretInput! ) { - rotateWebhookEndpointSecret(input: $input) { + rotateWebhookEndpointSigningSecret(input: $input) { endpoint { ...ConfigureWebhookEndpointContainer_webhookEndpoint } @@ -35,4 +35,4 @@ const RotateWebhookEndpointSecretMutation = createMutation( }) ); -export default RotateWebhookEndpointSecretMutation; +export default RotateWebhookEndpointSigningSecretMutation; diff --git a/src/core/client/admin/routes/Configure/sections/index.ts b/src/core/client/admin/routes/Configure/sections/index.ts index cff9bfbb7..bdbf16ddd 100644 --- a/src/core/client/admin/routes/Configure/sections/index.ts +++ b/src/core/client/admin/routes/Configure/sections/index.ts @@ -11,3 +11,8 @@ export { ConfigureWebhookEndpointRoute, AddWebhookEndpointRoute, } from "./WebhookEndpoints"; +export { + ModerationPhasesConfigRoute, + ConfigureExternalModerationPhaseRoute, + AddExternalModerationPhaseRoute, +} from "./ModerationPhases"; 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 fcf9f9386..5b86791b0 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 @@ -52,6 +52,15 @@ exports[`renders configure advanced 1`] = ` Moderation +
  • + + Moderation Phases + +
  • +
  • + + Moderation Phases + +
  • +
  • + + Moderation Phases + +
  • +
  • + + Moderation Phases + +
  • +
  • + + Moderation Phases + +
  • +
  • + + Moderation Phases + +
  • { const { testRenderer } = await createTestRenderer({ resolvers: createResolversStub({ Mutation: { - rotateSSOKey: () => { + rotateSSOSigningSecret: () => { return { settings: pureMerge( settingsWithEmptyAuth, @@ -71,7 +71,7 @@ it("rotate sso key", async () => { integrations: { sso: { enabled: true, - keys: [ + signingSecrets: [ { kid: "kid-01", secret: "secret", diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index 539bb507c..de8b931d6 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -114,7 +114,7 @@ export const settings = createFixture({ admin: true, stream: true, }, - keys: [ + signingSecrets: [ { kid: "kid-01", secret: "secret", @@ -212,7 +212,7 @@ export const settingsWithEmptyAuth = createFixture( stream: true, }, key: "", - keys: [ + signingSecrets: [ { kid: "kid-01", secret: "secret", diff --git a/src/core/client/framework/helpers/urls.tsx b/src/core/client/framework/helpers/urls.tsx index 7447841f8..0f829623e 100644 --- a/src/core/client/framework/helpers/urls.tsx +++ b/src/core/client/framework/helpers/urls.tsx @@ -1,7 +1,9 @@ export default { admin: { moderate: "/admin/moderate", - configureWebhooks: "/admin/configure/webhooks", + moderationPhases: "/admin/configure/moderation/phases", + addExternalModerationPhase: "/admin/configure/moderation/phases/add", + configureExternalModerationPhase: "/admin/configure/moderation/phases", webhooks: "/admin/configure/webhooks", addWebhookEndpoint: "/admin/configure/webhooks/add", configureWebhookEndpoint: "/admin/configure/webhooks/endpoint", diff --git a/src/core/client/framework/lib/messages.tsx b/src/core/client/framework/lib/messages.tsx index 3a7b68293..e27058f74 100644 --- a/src/core/client/framework/lib/messages.tsx +++ b/src/core/client/framework/lib/messages.tsx @@ -92,13 +92,13 @@ export const NOT_A_WHOLE_NUMBER = () => ( export const NOT_A_WHOLE_NUMBER_GREATER_THAN = (x: number) => ( - Please enter a valid whole number greater than $x + Please enter a valid whole number greater than {x} ); export const NOT_A_WHOLE_NUMBER_GREATER_THAN_OR_EQUAL = (x: number) => ( - Please enter a valid whole number greater than or equal to $x + Please enter a valid whole number greater than or equal to {x} ); diff --git a/src/core/client/framework/lib/validation.tsx b/src/core/client/framework/lib/validation.tsx index 82c267c72..946fd202f 100644 --- a/src/core/client/framework/lib/validation.tsx +++ b/src/core/client/framework/lib/validation.tsx @@ -1,7 +1,5 @@ import { ReactNode } from "react"; -import startsWith from "coral-common/utils/startsWith"; - import { EMAIL_REGEX, PASSWORD_MIN_LENGTH, @@ -10,6 +8,7 @@ import { USERNAME_MIN_LENGTH, USERNAME_REGEX, } from "coral-common/helpers/validate"; +import startsWith from "coral-common/utils/startsWith"; import { DELETE_CONFIRMATION_INVALID, diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 0c5d683d4..3659a6545 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -35,7 +35,7 @@ import { Metrics } from "coral-server/services/metrics"; import { MigrationManager } from "coral-server/services/migrate"; import { PersistedQueryCache } from "coral-server/services/queries"; import { AugmentedRedis } from "coral-server/services/redis"; -import TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; import { healthHandler, versionHandler } from "./handlers"; import { compileTrust } from "./helpers"; diff --git a/src/core/server/app/middleware/error.ts b/src/core/server/app/middleware/error.ts index 463ccc351..7b5b1047a 100644 --- a/src/core/server/app/middleware/error.ts +++ b/src/core/server/app/middleware/error.ts @@ -1,6 +1,6 @@ import { FluentBundle } from "@fluent/bundle/compat"; -import { CoralError, InternalError } from "coral-server/errors"; +import { CoralError, WrappedInternalError } from "coral-server/errors"; import { I18n } from "coral-server/services/i18n"; import { ErrorRequestHandler, Request } from "coral-server/types/express"; @@ -12,7 +12,7 @@ import { ErrorRequestHandler, Request } from "coral-server/types/express"; const wrapError = (err: Error) => err instanceof CoralError ? err - : new InternalError(err, "wrapped internal error"); + : new WrappedInternalError(err, "wrapped internal error"); /** * serializeError will return a serialized error that can be returned via the diff --git a/src/core/server/app/middleware/passport/strategies/oauth2.ts b/src/core/server/app/middleware/passport/strategies/oauth2.ts index 188eefa44..535d85c62 100644 --- a/src/core/server/app/middleware/passport/strategies/oauth2.ts +++ b/src/core/server/app/middleware/passport/strategies/oauth2.ts @@ -7,8 +7,10 @@ import { IntegrationDisabled } from "coral-server/errors"; import { AuthIntegrations } from "coral-server/models/settings"; import { Tenant } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; -import TenantCache from "coral-server/services/tenant/cache"; -import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter"; +import { + TenantCache, + TenantCacheAdapter, +} from "coral-server/services/tenant/cache"; import { Request } from "coral-server/types/express"; import { Profile } from "passport"; import { VerifyCallback } from "passport-oauth2"; diff --git a/src/core/server/app/middleware/passport/strategies/oidc/index.ts b/src/core/server/app/middleware/passport/strategies/oidc/index.ts index 71c5cf242..1540301a3 100644 --- a/src/core/server/app/middleware/passport/strategies/oidc/index.ts +++ b/src/core/server/app/middleware/passport/strategies/oidc/index.ts @@ -23,8 +23,10 @@ import { User, } from "coral-server/models/user"; import { AsymmetricSigningAlgorithm } from "coral-server/services/jwt"; -import TenantCache from "coral-server/services/tenant/cache"; -import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter"; +import { + TenantCache, + TenantCacheAdapter, +} from "coral-server/services/tenant/cache"; import { findOrCreate } from "coral-server/services/users"; import { validateUsername } from "coral-server/services/users/helpers"; import { Request } from "coral-server/types/express"; diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/oidc.ts b/src/core/server/app/middleware/passport/strategies/verifiers/oidc.ts index fcd10db80..9827acdf8 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/oidc.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/oidc.ts @@ -2,10 +2,10 @@ import jwks, { JwksClient } from "jwks-rsa"; import { Db } from "mongodb"; import { AppOptions } from "coral-server/app"; -import { Tenant } from "coral-server/models/tenant"; -import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter"; - import logger from "coral-server/logger"; +import { Tenant } from "coral-server/models/tenant"; +import { TenantCacheAdapter } from "coral-server/services/tenant/cache"; + import { Verifier } from "../jwt"; import { findOrCreateOIDCUserWithToken, 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 3bc93f6cd..988580bac 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts @@ -7,10 +7,14 @@ import { Db } from "mongodb"; import { validate } from "coral-server/app/request/body"; import { IntegrationDisabled, TokenInvalidError } from "coral-server/errors"; import logger from "coral-server/logger"; -import { Secret, SSOAuthIntegration } from "coral-server/models/settings"; +import { + filterActiveSigningSecrets, + SigningSecret, + SSOAuthIntegration, +} from "coral-server/models/settings"; import { Tenant, - updateLastUsedAtTenantSSOKey, + updateLastUsedAtTenantSSOSigningSecret, } from "coral-server/models/tenant"; import { retrieveUserWithProfile, @@ -167,7 +171,7 @@ export async function findOrCreateSSOUser( const updateLastUsedAtKID = throttle( async (redis: Redis, tenantID: string, kid: string, now: Date) => { try { - await updateLastUsedAtTenantSSOKey(redis, tenantID, kid, now); + await updateLastUsedAtTenantSSOSigningSecret(redis, tenantID, kid, now); logger.trace({ tenantID, kid }, "updated last used tenant sso key"); } catch (err) { logger.error( @@ -185,23 +189,18 @@ export interface SSOVerifierOptions { redis: AugmentedRedis; } -export function getRelevantSSOKeys( +export function getRelevantSSOSigningSecrets( integration: SSOAuthIntegration, tokenString: string, now: Date, kid?: string -): Secret[] { +): SigningSecret[] { // Collect all the current valid keys. - const keys = integration.keys.filter((k) => { - if (k.inactiveAt && now >= k.inactiveAt) { - return false; - } - - return k; - }); - - // If there is only one key, that's all we can use! + const keys = integration.signingSecrets.filter( + filterActiveSigningSecrets(now) + ); if (keys.length === 1) { + // There is only one key, that's all we can use! return keys; } @@ -259,14 +258,13 @@ export class SSOVerifier implements Verifier { throw new IntegrationDisabled("sso"); } - // check to see if there is at least one key associated with this - // integration. - if (integration.keys.length === 0) { - throw new Error("integration key does not exist"); - } - // Get the valid configurations for the given token and integration pair. - const keys = getRelevantSSOKeys(integration, tokenString, now, kid); + const keys = getRelevantSSOSigningSecrets( + integration, + tokenString, + now, + kid + ); if (keys.length === 0) { throw new TokenInvalidError( tokenString, diff --git a/src/core/server/app/middleware/tenant.ts b/src/core/server/app/middleware/tenant.ts index 62adce3ea..ac6423fe8 100644 --- a/src/core/server/app/middleware/tenant.ts +++ b/src/core/server/app/middleware/tenant.ts @@ -2,7 +2,7 @@ import { v1 as uuid } from "uuid"; import { TenantNotFoundError } from "coral-server/errors"; import logger from "coral-server/logger"; -import TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; import { RequestHandler } from "coral-server/types/express"; export interface MiddlewareOptions { diff --git a/src/core/server/app/router/client.ts b/src/core/server/app/router/client.ts index ceb596606..8fd725758 100644 --- a/src/core/server/app/router/client.ts +++ b/src/core/server/app/router/client.ts @@ -9,7 +9,7 @@ import { cspSiteMiddleware } from "coral-server/app/middleware/csp/tenant"; import { installedMiddleware } from "coral-server/app/middleware/installed"; import { tenantMiddleware } from "coral-server/app/middleware/tenant"; import logger from "coral-server/logger"; -import TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; import { RequestHandler } from "coral-server/types/express"; import Entrypoints, { Entrypoint } from "../helpers/entrypoints"; diff --git a/src/core/server/config.ts b/src/core/server/config.ts index 8e0f40c9a..1723950cf 100644 --- a/src/core/server/config.ts +++ b/src/core/server/config.ts @@ -7,7 +7,7 @@ import os from "os"; import { LOCALES } from "coral-common/helpers/i18n/locales"; import { ensureEndSlash } from "coral-common/utils"; -import { InternalError } from "./errors"; +import { WrappedInternalError } from "./errors"; // Add custom format for the mongo uri scheme. convict.addFormat({ @@ -15,7 +15,7 @@ convict.addFormat({ validate: (url: string) => { parseConnectionString(url, (err) => { if (err) { - throw new InternalError(err, "invalid mongo-uri"); + throw new WrappedInternalError(err, "invalid mongo-uri"); } }); }, @@ -237,6 +237,13 @@ const config = convict({ default: false, env: "DISABLE_RATE_LIMITERS", }, + scrape_max_response_size: { + doc: "The maximum size (in bytes) to allow for scraping responses.", + format: Number, + default: 10e6, + env: "SCRAPE_MAX_RESPONSE_SIZE", + arg: "scrapeMaxResponseSize", + }, scrape_timeout: { doc: "The request timeout (in ms) for scraping operations.", format: "ms", diff --git a/src/core/server/cron/accountDeletion.ts b/src/core/server/cron/accountDeletion.ts index 57a167a6e..b8fd5479e 100644 --- a/src/core/server/cron/accountDeletion.ts +++ b/src/core/server/cron/accountDeletion.ts @@ -2,7 +2,7 @@ import { Db } from "mongodb"; import { retrieveUserScheduledForDeletion } from "coral-server/models/user"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; -import TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; import { deleteUser } from "coral-server/services/users/delete"; import { diff --git a/src/core/server/cron/index.ts b/src/core/server/cron/index.ts index b9f328fdd..161a2b8d8 100644 --- a/src/core/server/cron/index.ts +++ b/src/core/server/cron/index.ts @@ -3,7 +3,7 @@ import { Db } from "mongodb"; import { Config } from "coral-server/config"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { JWTSigningConfig } from "coral-server/services/jwt"; -import TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; import { registerAccountDeletion } from "./accountDeletion"; import { registerNotificationDigesting } from "./notificationDigesting"; diff --git a/src/core/server/cron/notificationDigesting.ts b/src/core/server/cron/notificationDigesting.ts index 758c6d309..f8f921dfd 100644 --- a/src/core/server/cron/notificationDigesting.ts +++ b/src/core/server/cron/notificationDigesting.ts @@ -7,7 +7,7 @@ import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates"; import { JWTSigningConfig } from "coral-server/services/jwt"; import NotificationContext from "coral-server/services/notifications/context"; -import TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; import { ScheduledJob, diff --git a/src/core/server/errors/index.spec.ts b/src/core/server/errors/index.spec.ts index 73a7e02ce..4f9eb267d 100644 --- a/src/core/server/errors/index.spec.ts +++ b/src/core/server/errors/index.spec.ts @@ -1,6 +1,6 @@ import { VError } from "verror"; -import { CoralError, DuplicateUserError, InternalError } from "."; +import { CoralError, DuplicateUserError, WrappedInternalError } from "."; it("has the right inheritance chain", () => { const err = new DuplicateUserError(); @@ -14,12 +14,12 @@ it("has the right inheritance chain", () => { }); it("provides an accurate stack", () => { - const err = new InternalError( + const err = new WrappedInternalError( new Error("this is a test"), "this is the reason" ); - expect(err).toBeInstanceOf(InternalError); + expect(err).toBeInstanceOf(WrappedInternalError); expect(err).toBeInstanceOf(CoralError); expect(err).toBeInstanceOf(VError); expect(err).toBeInstanceOf(Error); diff --git a/src/core/server/errors/index.ts b/src/core/server/errors/index.ts index 16694c0c1..eb4dd9fd6 100644 --- a/src/core/server/errors/index.ts +++ b/src/core/server/errors/index.ts @@ -465,7 +465,7 @@ export class IntegrationDisabled extends CoralError { } } -export class InternalError extends CoralError { +export class WrappedInternalError extends CoralError { constructor(cause: Error, reason: string) { super({ code: ERROR_CODES.INTERNAL_ERROR, @@ -476,6 +476,16 @@ export class InternalError extends CoralError { } } +export class InternalError extends CoralError { + constructor(reason: string, context?: Record) { + super({ + code: ERROR_CODES.INTERNAL_ERROR, + context: { pvt: { reason, ...context } }, + status: 500, + }); + } +} + export class InternalDevelopmentError extends CoralError { constructor(cause: Error, reason: string) { super({ diff --git a/src/core/server/graph/context.ts b/src/core/server/graph/context.ts index 054391305..8145b43e7 100644 --- a/src/core/server/graph/context.ts +++ b/src/core/server/graph/context.ts @@ -19,7 +19,7 @@ import { WebhookQueue } from "coral-server/queue/tasks/webhook"; import { I18n } from "coral-server/services/i18n"; import { JWTSigningConfig } from "coral-server/services/jwt"; import { AugmentedRedis } from "coral-server/services/redis"; -import TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; import { Request } from "coral-server/types/express"; import loaders from "./loaders"; diff --git a/src/core/server/graph/extensions/ErrorWrappingExtension.ts b/src/core/server/graph/extensions/ErrorWrappingExtension.ts index 20bcb3bc4..0969cf870 100644 --- a/src/core/server/graph/extensions/ErrorWrappingExtension.ts +++ b/src/core/server/graph/extensions/ErrorWrappingExtension.ts @@ -6,7 +6,7 @@ import { merge } from "lodash"; import { CoralError, InternalDevelopmentError, - InternalError, + WrappedInternalError, } from "coral-server/errors"; import GraphContext from "coral-server/graph/context"; import { getOriginalError } from "./helpers"; @@ -71,7 +71,7 @@ function getWrappedOriginalError( ); } - return new InternalError(originalError, "wrapped internal error"); + return new WrappedInternalError(originalError, "wrapped internal error"); } /** diff --git a/src/core/server/graph/loaders/Auth.ts b/src/core/server/graph/loaders/Auth.ts index e7dd7dc2e..2b7205de7 100644 --- a/src/core/server/graph/loaders/Auth.ts +++ b/src/core/server/graph/loaders/Auth.ts @@ -1,7 +1,7 @@ import DataLoader from "dataloader"; import GraphContext from "coral-server/graph/context"; -import { retrieveLastUsedAtTenantSSOKeys } from "coral-server/models/tenant"; +import { retrieveLastUsedAtTenantSSOSigningSecrets } from "coral-server/models/tenant"; import { discoverOIDCConfiguration } from "coral-server/services/tenant"; import { GQLDiscoveredOIDCConfiguration } from "coral-server/graph/schema/__generated__/types"; @@ -19,7 +19,7 @@ export default (ctx: GraphContext) => ({ cache: !ctx.disableCaching, } ), - retrieveSSOKeyLastUsedAt: new DataLoader((kids: string[]) => - retrieveLastUsedAtTenantSSOKeys(ctx.redis, ctx.tenant.id, kids) + retrieveSSOSigningSecretLastUsedAt: new DataLoader((kids: string[]) => + retrieveLastUsedAtTenantSSOSigningSecrets(ctx.redis, ctx.tenant.id, kids) ), }); diff --git a/src/core/server/graph/loaders/Stories.ts b/src/core/server/graph/loaders/Stories.ts index dba8307bb..28d1a62f1 100644 --- a/src/core/server/graph/loaders/Stories.ts +++ b/src/core/server/graph/loaders/Stories.ts @@ -211,12 +211,13 @@ export default (ctx: GraphContext) => ({ // 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. - scraper.scrape( + scraper.scrape({ url, - (ctx.config.get("scrape_timeout") as unknown) as number, - ctx.tenant.stories.scraping.customUserAgent, - ctx.tenant.stories.scraping.proxyURL - ) + timeout: (ctx.config.get("scrape_timeout") as unknown) as number, + size: ctx.config.get("scrape_response_max_size"), + customUserAgent: ctx.tenant.stories.scraping.customUserAgent, + proxyURL: ctx.tenant.stories.scraping.proxyURL, + }) ), { // Disable caching for the DataLoader if the Context is designed to be diff --git a/src/core/server/graph/mutators/Settings.ts b/src/core/server/graph/mutators/Settings.ts index 7fe3baac0..6b14d6949 100644 --- a/src/core/server/graph/mutators/Settings.ts +++ b/src/core/server/graph/mutators/Settings.ts @@ -2,34 +2,46 @@ import GraphContext from "coral-server/graph/context"; import { Tenant } from "coral-server/models/tenant"; import { createAnnouncement, + createExternalModerationPhase, createWebhookEndpoint, - deactivateSSOKey, + deactivateSSOSigningSecret, deleteAnnouncement, - deleteSSOKey, + deleteExternalModerationPhase, + deleteSSOSigningSecret, deleteWebhookEndpoint, + disableExternalModerationPhase, disableFeatureFlag, disableWebhookEndpoint, + enableExternalModerationPhase, enableFeatureFlag, enableWebhookEndpoint, regenerateSSOKey, - rotateSSOKey, - rotateWebhookEndpointSecret, + rotateExternalModerationPhaseSigningSecret, + rotateSSOSigningSecret, + rotateWebhookEndpointSigningSecret, sendSMTPTest, update, + updateExternalModerationPhase, updateWebhookEndpoint, } from "coral-server/services/tenant"; import { GQLCreateAnnouncementInput, + GQLCreateExternalModerationPhaseInput, GQLCreateWebhookEndpointInput, - GQLDeactivateSSOKeyInput, - GQLDeleteSSOKeyInput, + GQLDeactivateSSOSigningSecretInput, + GQLDeleteExternalModerationPhaseInput, + GQLDeleteSSOSigningSecretInput, GQLDeleteWebhookEndpointInput, + GQLDisableExternalModerationPhaseInput, GQLDisableWebhookEndpointInput, + GQLEnableExternalModerationPhaseInput, GQLEnableWebhookEndpointInput, GQLFEATURE_FLAG, - GQLRotateSSOKeyInput, - GQLRotateWebhookEndpointSecretInput, + GQLRotateExternalModerationPhaseSigningSecretInput, + GQLRotateSSOSigningSecretInput, + GQLRotateWebhookEndpointSigningSecretInput, + GQLUpdateExternalModerationPhaseInput, GQLUpdateSettingsInput, GQLUpdateWebhookEndpointInput, } from "coral-server/graph/schema/__generated__/types"; @@ -50,20 +62,21 @@ export const Settings = ({ input: WithoutMutationID ): Promise => update(mongo, redis, tenantCache, config, tenant, input.settings), + // DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0. regenerateSSOKey: (): Promise => regenerateSSOKey(mongo, redis, tenantCache, tenant, now), - rotateSSOKey: ({ inactiveIn }: GQLRotateSSOKeyInput) => - rotateSSOKey(mongo, redis, tenantCache, tenant, inactiveIn, now), - deactivateSSOKey: ({ kid }: GQLDeactivateSSOKeyInput) => - deactivateSSOKey(mongo, redis, tenantCache, tenant, kid, now), - deleteSSOKey: ({ kid }: GQLDeleteSSOKeyInput) => - deleteSSOKey(mongo, redis, tenantCache, tenant, kid), + rotateSSOSigningSecret: ({ inactiveIn }: GQLRotateSSOSigningSecretInput) => + rotateSSOSigningSecret(mongo, redis, tenantCache, tenant, inactiveIn, now), + deleteSSOSigningSecret: ({ kid }: GQLDeleteSSOSigningSecretInput) => + deleteSSOSigningSecret(mongo, redis, tenantCache, tenant, kid), + deactivateSSOSigningSecret: ({ kid }: GQLDeactivateSSOSigningSecretInput) => + deactivateSSOSigningSecret(mongo, redis, tenantCache, tenant, kid, now), enableFeatureFlag: (flag: GQLFEATURE_FLAG) => enableFeatureFlag(mongo, redis, tenantCache, tenant, flag), disableFeatureFlag: (flag: GQLFEATURE_FLAG) => disableFeatureFlag(mongo, redis, tenantCache, tenant, flag), createAnnouncement: (input: GQLCreateAnnouncementInput) => - createAnnouncement(mongo, redis, tenantCache, tenant, input, now), + createAnnouncement(mongo, redis, tenantCache, tenant, input), deleteAnnouncement: () => deleteAnnouncement(mongo, redis, tenantCache, tenant), createWebhookEndpoint: ( @@ -92,10 +105,59 @@ export const Settings = ({ deleteWebhookEndpoint: ( input: WithoutMutationID ) => deleteWebhookEndpoint(mongo, redis, tenantCache, tenant, input.id), - rotateWebhookEndpointSecret: ( - input: WithoutMutationID + rotateWebhookEndpointSigningSecret: ( + input: WithoutMutationID ) => - rotateWebhookEndpointSecret( + rotateWebhookEndpointSigningSecret( + mongo, + redis, + tenantCache, + tenant, + input.id, + input.inactiveIn, + now + ), + createExternalModerationPhase: ( + input: WithoutMutationID + ) => + createExternalModerationPhase( + mongo, + redis, + config, + tenantCache, + tenant, + input, + now + ), + updateExternalModerationPhase: ({ + id, + ...input + }: WithoutMutationID) => + updateExternalModerationPhase( + mongo, + redis, + config, + tenantCache, + tenant, + id, + input + ), + enableExternalModerationPhase: ( + input: WithoutMutationID + ) => + enableExternalModerationPhase(mongo, redis, tenantCache, tenant, input.id), + disableExternalModerationPhase: ( + input: WithoutMutationID + ) => + disableExternalModerationPhase(mongo, redis, tenantCache, tenant, input.id), + deleteExternalModerationPhase: ( + input: WithoutMutationID + ) => + deleteExternalModerationPhase(mongo, redis, tenantCache, tenant, input.id), + rotateExternalModerationPhaseSigningSecret: ( + input: WithoutMutationID + ) => + rotateExternalModerationPhaseSigningSecret( mongo, redis, tenantCache, diff --git a/src/core/server/graph/resolvers/Comment.ts b/src/core/server/graph/resolvers/Comment.ts index 65c38f806..a1fedd2d2 100644 --- a/src/core/server/graph/resolvers/Comment.ts +++ b/src/core/server/graph/resolvers/Comment.ts @@ -9,6 +9,7 @@ import { } from "coral-server/models/action/comment"; import * as comment from "coral-server/models/comment"; import { + getDepth, getLatestRevision, hasAncestors, hasPublishedStatus, @@ -52,9 +53,9 @@ export const Comment: GQLCommentTypeResolver = { c.revisions.length > 0 ? { revision: getLatestRevision(c), comment: c } : null, + deleted: ({ deletedAt }) => !!deletedAt, revisionHistory: (c) => c.revisions.map((revision) => ({ revision, comment: c })), - deleted: ({ deletedAt }) => !!deletedAt, editing: ({ revisions, createdAt }, input, ctx) => ({ // When there is more than one body history, then the comment has been // edited. @@ -98,8 +99,8 @@ export const Comment: GQLCommentTypeResolver = { }), viewerActionPresence: (c, input, ctx) => ctx.user ? ctx.loaders.Comments.retrieveMyActionPresence.load(c.id) : null, - parentCount: (c) => (hasAncestors(c) ? c.ancestorIDs.length : 0), - depth: (c) => (hasAncestors(c) ? c.ancestorIDs.length : 0), + parentCount: (c) => getDepth(c), + depth: (c) => getDepth(c), rootParent: (c, input, ctx, info) => hasAncestors(c) ? maybeLoadOnlyID(ctx, info, c.ancestorIDs[c.ancestorIDs.length - 1]) diff --git a/src/core/server/graph/resolvers/ExternalModerationPhase.ts b/src/core/server/graph/resolvers/ExternalModerationPhase.ts new file mode 100644 index 000000000..0a8d65c21 --- /dev/null +++ b/src/core/server/graph/resolvers/ExternalModerationPhase.ts @@ -0,0 +1,8 @@ +import * as settings from "coral-server/models/settings"; + +import { GQLExternalModerationPhaseTypeResolver } from "coral-server/graph/schema/__generated__/types"; + +export const ExternalModerationPhase: GQLExternalModerationPhaseTypeResolver = { + signingSecret: ({ signingSecrets }) => + signingSecrets[signingSecrets.length - 1], +}; diff --git a/src/core/server/graph/resolvers/Mutation.ts b/src/core/server/graph/resolvers/Mutation.ts index 6b69d6abe..59ff09cd4 100644 --- a/src/core/server/graph/resolvers/Mutation.ts +++ b/src/core/server/graph/resolvers/Mutation.ts @@ -75,20 +75,21 @@ export const Mutation: Required> = { comment: await ctx.mutators.Comments.unfeature(input), clientMutationId, }), + // DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0. regenerateSSOKey: async (source, { input }, ctx) => ({ settings: await ctx.mutators.Settings.regenerateSSOKey(), clientMutationId: input.clientMutationId, }), - rotateSSOKey: async (source, { input }, ctx) => ({ - settings: await ctx.mutators.Settings.rotateSSOKey(input), + rotateSSOSigningSecret: async (source, { input }, ctx) => ({ + settings: await ctx.mutators.Settings.rotateSSOSigningSecret(input), clientMutationId: input.clientMutationId, }), - deactivateSSOKey: async (source, { input }, ctx) => ({ - settings: await ctx.mutators.Settings.deactivateSSOKey(input), + deactivateSSOSigningSecret: async (source, { input }, ctx) => ({ + settings: await ctx.mutators.Settings.deactivateSSOSigningSecret(input), clientMutationId: input.clientMutationId, }), - deleteSSOKey: async (source, { input }, ctx) => ({ - settings: await ctx.mutators.Settings.deleteSSOKey(input), + deleteSSOSigningSecret: async (source, { input }, ctx) => ({ + settings: await ctx.mutators.Settings.deleteSSOSigningSecret(input), clientMutationId: input.clientMutationId, }), createStory: async (source, { input }, ctx) => ({ @@ -319,12 +320,64 @@ export const Mutation: Required> = { endpoint: await ctx.mutators.Settings.deleteWebhookEndpoint(input), clientMutationId, }), - rotateWebhookEndpointSecret: async ( + rotateWebhookEndpointSigningSecret: async ( source, { input: { clientMutationId, ...input } }, ctx ) => ({ - endpoint: await ctx.mutators.Settings.rotateWebhookEndpointSecret(input), + endpoint: await ctx.mutators.Settings.rotateWebhookEndpointSigningSecret( + input + ), + clientMutationId, + }), + createExternalModerationPhase: async ( + source, + { input: { clientMutationId, ...input } }, + ctx + ) => ({ + ...(await ctx.mutators.Settings.createExternalModerationPhase(input)), + clientMutationId, + }), + updateExternalModerationPhase: async ( + source, + { input: { clientMutationId, ...input } }, + ctx + ) => ({ + phase: await ctx.mutators.Settings.updateExternalModerationPhase(input), + clientMutationId, + }), + disableExternalModerationPhase: async ( + source, + { input: { clientMutationId, ...input } }, + ctx + ) => ({ + phase: await ctx.mutators.Settings.disableExternalModerationPhase(input), + clientMutationId, + }), + enableExternalModerationPhase: async ( + source, + { input: { clientMutationId, ...input } }, + ctx + ) => ({ + phase: await ctx.mutators.Settings.enableExternalModerationPhase(input), + clientMutationId, + }), + deleteExternalModerationPhase: async ( + source, + { input: { clientMutationId, ...input } }, + ctx + ) => ({ + phase: await ctx.mutators.Settings.deleteExternalModerationPhase(input), + clientMutationId, + }), + rotateExternalModerationPhaseSigningSecret: async ( + source, + { input: { clientMutationId, ...input } }, + ctx + ) => ({ + phase: await ctx.mutators.Settings.rotateExternalModerationPhaseSigningSecret( + input + ), clientMutationId, }), testSMTP: async (source, { input: { clientMutationId } }, ctx) => { diff --git a/src/core/server/graph/resolvers/Query.ts b/src/core/server/graph/resolvers/Query.ts index 9b14a1132..8bc6ef986 100644 --- a/src/core/server/graph/resolvers/Query.ts +++ b/src/core/server/graph/resolvers/Query.ts @@ -1,3 +1,4 @@ +import { getExternalModerationPhase } from "coral-server/models/settings"; import { getWebhookEndpoint } from "coral-server/models/tenant"; import { GQLQueryTypeResolver } from "coral-server/graph/schema/__generated__/types"; @@ -29,4 +30,8 @@ export const Query: Required> = { site: (source, { id }, ctx) => (id ? ctx.loaders.Sites.site.load(id) : null), webhookEndpoint: (source, { id }, ctx) => getWebhookEndpoint(ctx.tenant, id), queues: () => ({}), + externalModerationPhase: (source, { id }, ctx) => + ctx.tenant.integrations.external + ? getExternalModerationPhase(ctx.tenant.integrations.external, id) + : null, }; diff --git a/src/core/server/graph/resolvers/SSOAuthIntegration.ts b/src/core/server/graph/resolvers/SSOAuthIntegration.ts index c6f446968..855ac9776 100644 --- a/src/core/server/graph/resolvers/SSOAuthIntegration.ts +++ b/src/core/server/graph/resolvers/SSOAuthIntegration.ts @@ -1,25 +1,25 @@ import * as settings from "coral-server/models/settings"; import { GQLSSOAuthIntegrationTypeResolver } from "coral-server/graph/schema/__generated__/types"; +import { filterFreshSigningSecrets } from "coral-server/models/settings"; -function getActiveSSOKey(keys: settings.Secret[]) { - // Any key that has been rotated cannot be the active key. - return keys.find((key) => !key.rotatedAt); +function getActiveSSOSigningSecret(keys: settings.SigningSecret[]) { + return keys.find(filterFreshSigningSecrets()); } export const SSOAuthIntegration: GQLSSOAuthIntegrationTypeResolver = { - key: ({ keys }) => { - const key = getActiveSSOKey(keys); - if (key) { - return key.secret; + key: ({ signingSecrets }) => { + const signingSecret = getActiveSSOSigningSecret(signingSecrets); + if (signingSecret) { + return signingSecret.secret; } return null; }, - keyGeneratedAt: ({ keys }) => { - const key = getActiveSSOKey(keys); - if (key) { - return key.createdAt; + keyGeneratedAt: ({ signingSecrets }) => { + const signingSecret = getActiveSSOSigningSecret(signingSecrets); + if (signingSecret) { + return signingSecret.createdAt; } return null; diff --git a/src/core/server/graph/resolvers/Secret.ts b/src/core/server/graph/resolvers/Secret.ts deleted file mode 100644 index dccbb155a..000000000 --- a/src/core/server/graph/resolvers/Secret.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as settings from "coral-server/models/settings"; - -import { GQLSecretTypeResolver } from "coral-server/graph/schema/__generated__/types"; - -export const Secret: GQLSecretTypeResolver = { - lastUsedAt: async ({ kid }, args, ctx) => - ctx.loaders.Auth.retrieveSSOKeyLastUsedAt.load(kid), -}; diff --git a/src/core/server/graph/resolvers/SigningSecret.ts b/src/core/server/graph/resolvers/SigningSecret.ts new file mode 100644 index 000000000..c16ee195c --- /dev/null +++ b/src/core/server/graph/resolvers/SigningSecret.ts @@ -0,0 +1,8 @@ +import * as settings from "coral-server/models/settings"; + +import { GQLSigningSecretTypeResolver } from "coral-server/graph/schema/__generated__/types"; + +export const SigningSecret: GQLSigningSecretTypeResolver = { + lastUsedAt: async ({ kid }, args, ctx) => + ctx.loaders.Auth.retrieveSSOSigningSecretLastUsedAt.load(kid), +}; diff --git a/src/core/server/graph/resolvers/index.ts b/src/core/server/graph/resolvers/index.ts index 905d26a76..942f1bd81 100644 --- a/src/core/server/graph/resolvers/index.ts +++ b/src/core/server/graph/resolvers/index.ts @@ -20,6 +20,7 @@ import { CommentReplyCreatedPayload } from "./CommentReplyCreatedPayload"; import { CommentRevision } from "./CommentRevision"; import { CommentStatusUpdatedPayload } from "./CommentStatusUpdatedPayload"; import { DisableCommenting } from "./DisableCommenting"; +import { ExternalModerationPhase } from "./ExternalModerationPhase"; import { FacebookAuthIntegration } from "./FacebookAuthIntegration"; import { FeatureCommentPayload } from "./FeatureCommentPayload"; import { Flag } from "./Flag"; @@ -39,8 +40,8 @@ import { Queue } from "./Queue"; import { Queues } from "./Queues"; import { RecentCommentHistory } from "./RecentCommentHistory"; import { RejectCommentPayload } from "./RejectCommentPayload"; -import { Secret } from "./Secret"; import { Settings } from "./Settings"; +import { SigningSecret } from "./SigningSecret"; import { SlackConfiguration } from "./SlackConfiguration"; import { SSOAuthIntegration } from "./SSOAuthIntegration"; import { Story } from "./Story"; @@ -73,6 +74,7 @@ const Resolvers: GQLResolver = { CommentStatusUpdatedPayload, Cursor, DisableCommenting, + ExternalModerationPhase, FacebookAuthIntegration, FeatureCommentPayload, Flag, @@ -92,7 +94,7 @@ const Resolvers: GQLResolver = { RecentCommentHistory, RejectCommentPayload, SSOAuthIntegration, - Secret, + SigningSecret, Story, StorySettings, Subscription, diff --git a/src/core/server/graph/schema/schema.graphql b/src/core/server/graph/schema/schema.graphql index c3d9f9a83..3377cac14 100644 --- a/src/core/server/graph/schema/schema.graphql +++ b/src/core/server/graph/schema/schema.graphql @@ -495,7 +495,7 @@ type LocalAuthIntegration { ## SSOAuthIntegration ########################## -type Secret { +type SigningSecret { """ kid is the identifier for the key used when verifying tokens issued by the provider. @@ -551,23 +551,23 @@ type SSOAuthIntegration { targetFilter: AuthenticationTargetFilter! """ - keys are the different SSOKey's used by this Tenant. + signingSecrets are the different SigningSecret's used by this Tenant. """ - keys: [Secret!]! @auth(roles: [ADMIN]) + signingSecrets: [SigningSecret!]! @auth(roles: [ADMIN]) """ key is the secret that is used to sign tokens. """ key: String @auth(roles: [ADMIN]) - @deprecated(reason: "field is deprecated in favour of `keys`") + @deprecated(reason: "field is deprecated in favour of `signingSecrets`") """ keyGeneratedAt is the Time that the key was effective from. """ keyGeneratedAt: Time @auth(roles: [ADMIN]) - @deprecated(reason: "field is deprecated in favour of `keys`") + @deprecated(reason: "field is deprecated in favour of `signingSecrets`") } ########################## @@ -908,6 +908,73 @@ type PerspectiveExternalIntegration { sendFeedback: Boolean @auth(roles: [ADMIN]) } +""" +COMMENT_BODY_FORMAT describes the various formats that a comment body can be +provided in. +""" +enum COMMENT_BODY_FORMAT { + """ + HTML describes the format of the comment body using HTML. + """ + HTML + + """ + PLAIN_TEXT describes the format of the comment body with the HTML stripped. + """ + PLAIN_TEXT +} + +""" +ExternalModerationPhase describes a phase use in the moderation pipeline that +calls out to an external resource as defined by the provided URL. +""" +type ExternalModerationPhase { + """ + id identifies this particular External Moderation Phase. + """ + id: ID! + + """ + name is the name assigned to this ExternalModerationPhase for identification + purposes. + """ + name: String! + + """ + enabled when true, will use this phase in the moderation pipeline. + """ + enabled: Boolean! + + """ + url is the actual URL that should be called. + """ + url: String! + + """ + format is the format of the comment body sent. + """ + format: COMMENT_BODY_FORMAT! + + """ + timeout is the number of milliseconds that this moderation is maximum expected + to take before it is skipped. + """ + timeout: Int! + + """ + signingSecret is the secret used to sign outgoing requests to the url during + the moderation pipeline. + """ + signingSecret: SigningSecret! +} + +type CustomExternalIntegration { + """ + phases is all the external moderation phases for this Tenant. + """ + phases: [ExternalModerationPhase!]! +} + type ExternalIntegrations { """ akismet provides integration with the Akismet Spam detection service. @@ -919,6 +986,12 @@ type ExternalIntegrations { platform. """ perspective: PerspectiveExternalIntegration! + + """ + external provides integration details for external moderation phases that can be + used in the moderation pipeline. + """ + external: CustomExternalIntegration } ################################################################################ @@ -1318,7 +1391,7 @@ type WebhookEndpoint { """ signingSecret is the current secret used to sign the events sent out. """ - signingSecret: Secret! + signingSecret: SigningSecret! """ deliveries store the deliveries for each event sent for the last 50 events. @@ -3183,6 +3256,13 @@ type Query { queues returns information on queues used in Coral to manage """ queues: Queues! @auth(roles: [ADMIN]) + + """ + externalModerationPhase will return a specific ExternalModerationPhase if it + exists. + """ + externalModerationPhase(id: ID!): ExternalModerationPhase + @auth(roles: [ADMIN]) } ################################################################################ @@ -4369,7 +4449,7 @@ input RegenerateSSOKeyInput { type RegenerateSSOKeyPayload { """ - settings is the Settings that the SSO key was regenerated on. + settings is the Settings that the SSO secret was regenerated on. """ settings: Settings @@ -5158,10 +5238,10 @@ type UpdateWebhookEndpointPayload { } ################## -# rotateWebhookEndpointSecret +# rotateWebhookEndpointSigningSecret ################## -input RotateWebhookEndpointSecretInput { +input RotateWebhookEndpointSigningSecretInput { """ clientMutationId is required for Relay support. """ @@ -5179,7 +5259,7 @@ input RotateWebhookEndpointSecretInput { inactiveIn: Int! } -type RotateWebhookEndpointSecretPayload { +type RotateWebhookEndpointSigningSecretPayload { """ clientMutationId is required for Relay support. """ @@ -5188,7 +5268,7 @@ type RotateWebhookEndpointSecretPayload { """ endpoint is the endpoint that we just updated. """ - endpoint: WebhookEndpoint + endpoint: WebhookEndpoint! } ################## @@ -5216,7 +5296,7 @@ type DisableWebhookEndpointPayload { """ endpoint is the endpoint that we just disabled. """ - endpoint: WebhookEndpoint + endpoint: WebhookEndpoint! } ################## @@ -5244,7 +5324,7 @@ type EnableWebhookEndpointPayload { """ endpoint is the endpoint that we just enabled. """ - endpoint: WebhookEndpoint + endpoint: WebhookEndpoint! } ################## @@ -5272,7 +5352,225 @@ type DeleteWebhookEndpointPayload { """ endpoint is the endpoint that we just deleted. """ - endpoint: WebhookEndpoint + endpoint: WebhookEndpoint! +} + +################# +# createExternalModerationPhase +################## + +input CreateExternalModerationPhaseInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + name is the name assigned to this ExternalModerationPhase for identification + purposes. + """ + name: String! + + """ + url is the URL that Coral will POST moderation queries to. + """ + url: String! + + """ + format is the format of the comment body sent. + """ + format: COMMENT_BODY_FORMAT! + + """ + timeout is the number of milliseconds that this moderation is maximum expected + to take before it is skipped. + """ + timeout: Int! +} + +type CreateExternalModerationPhasePayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + phase is the ExternalModerationPhase that we just created. + """ + phase: ExternalModerationPhase! + + """ + settings is the updated settings also containing the new phase. + """ + settings: Settings! +} + +################## +# updateExternalModerationPhase +################## + +input UpdateExternalModerationPhaseInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + id is the ID of the ExternalModerationPhase being updated. + """ + id: ID! + + """ + name is the name assigned to this ExternalModerationPhase for identification + purposes. + """ + name: String + + """ + url is the URL that Coral will POST moderation queries to. + """ + url: String + + """ + format is the format of the comment body sent. + """ + format: COMMENT_BODY_FORMAT + + """ + timeout is the number of milliseconds that this moderation is maximum expected + to take before it is skipped. + """ + timeout: Int +} + +type UpdateExternalModerationPhasePayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + phase is the ExternalModerationPhase that we just updated. + """ + phase: ExternalModerationPhase! +} + +################## +# deleteExternalModerationPhase +################## + +input DeleteExternalModerationPhaseInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + id is the ID of the ExternalModerationPhase being deleted. + """ + id: ID! +} + +type DeleteExternalModerationPhasePayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + phase is the ExternalModerationPhase that we just deleted. + """ + phase: ExternalModerationPhase! +} + +################## +# disableExternalModerationPhase +################## + +input DisableExternalModerationPhaseInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + id is the ID of the ExternalModerationPhase being disabled. + """ + id: ID! +} + +type DisableExternalModerationPhasePayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + phase is the ExternalModerationPhase that we just disabled. + """ + phase: ExternalModerationPhase! +} + +################## +# enableExternalModerationPhase +################## + +input EnableExternalModerationPhaseInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + id is the ID of the ExternalModerationPhase being enabled. + """ + id: ID! +} + +type EnableExternalModerationPhasePayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + phase is the ExternalModerationPhase that we just enabled. + """ + phase: ExternalModerationPhase! +} + +################## +# rotateExternalModerationPhaseSigningSecret +################## + +input RotateExternalModerationPhaseSigningSecretInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + id is the ID of the ExternalModerationPhase being updated. + """ + id: ID! + + """ + inactiveIn is the number of seconds that the current active Secret should be + kept active. + """ + inactiveIn: Int! +} + +type RotateExternalModerationPhaseSigningSecretPayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + phase is the ExternalModerationPhase that we just updated. + """ + phase: ExternalModerationPhase! } ################## @@ -6001,87 +6299,87 @@ type EnableFeatureFlagPayload { } ######################### -## rotateSSOKey +## rotateSSOSigningSecret ######################### -input RotateSSOKeyInput { +input RotateSSOSigningSecretInput { """ clientMutationId is required for Relay support. """ clientMutationId: String! """ - inactiveIn is the number of seconds that the current active SSOKey should be - kept active (allow signed tokens signed with this secret) before rejecting - them. + inactiveIn is the number of seconds that the current active SigningSecret + should be kept active (allow signed tokens signed with this secret) before + rejecting them. """ inactiveIn: Int! } -type RotateSSOKeyPayload { +type RotateSSOSigningSecretPayload { """ clientMutationId is required for Relay support. """ clientMutationId: String! """ - settings is the Settings that the SSO key was regenerated on. + settings is the Settings that the SSO secret was regenerated on. """ settings: Settings } ######################### -## deactivateSSOKey +## deactivateSSOSigningSecret ######################### -input DeactivateSSOKeyInput { +input DeactivateSSOSigningSecretInput { """ clientMutationId is required for Relay support. """ clientMutationId: String! """ - kid is the ID of the SSOKey being deactivated. + kid is the ID of the SigningSecret being deactivated. """ kid: ID! } -type DeactivateSSOKeyPayload { +type DeactivateSSOSigningSecretPayload { """ clientMutationId is required for Relay support. """ clientMutationId: String! """ - settings is the Settings that the SSO key was regenerated on. + settings is the Settings that the SSO secret was regenerated on. """ settings: Settings } ######################### -## deleteSSOKey +## deleteSSOSigningSecret ######################### -input DeleteSSOKeyInput { +input DeleteSSOSigningSecretInput { """ clientMutationId is required for Relay support. """ clientMutationId: String! """ - kid is the ID of the SSOKey being deleted. + kid is the ID of the SigningSecret being deleted. """ kid: ID! } -type DeleteSSOKeyPayload { +type DeleteSSOSigningSecretPayload { """ clientMutationId is required for Relay support. """ clientMutationId: String! """ - settings is the Settings that the SSO key was regenerated on. + settings is the Settings that the SSO secret was regenerated on. """ settings: Settings } @@ -6349,30 +6647,35 @@ type Mutation { @auth(roles: [ADMIN]) """ - regenerateSSOKey will regenerate the SSO key used to sign secrets. This will + regenerateSSOKey will regenerate the SSO secret used to sign secrets. This will invalidate any existing user sessions. + + DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0. """ regenerateSSOKey(input: RegenerateSSOKeyInput!): RegenerateSSOKeyPayload! @auth(roles: [ADMIN]) - @deprecated(reason: "deprecated in favour of `rotateSSOKey`") + @deprecated(reason: "deprecated in favour of `rotateSSOSigningSecret`") """ - rotateSSOKey can be used to rotate a given active SSOKey. + rotateSSOSigningSecret can be used to rotate a given active SigningSecret. """ - rotateSSOKey(input: RotateSSOKeyInput!): RotateSSOKeyPayload! - @auth(roles: [ADMIN]) + rotateSSOSigningSecret( + input: RotateSSOSigningSecretInput! + ): RotateSSOSigningSecretPayload! @auth(roles: [ADMIN]) """ - deactivateSSOKey will deactivate a given deactivated SSOKey. + deactivateSSOSigningSecret will deactivate a given deactivated SigningSecret. """ - deactivateSSOKey(input: DeactivateSSOKeyInput!): DeactivateSSOKeyPayload! - @auth(roles: [ADMIN]) + deactivateSSOSigningSecret( + input: DeactivateSSOSigningSecretInput! + ): DeactivateSSOSigningSecretPayload! @auth(roles: [ADMIN]) """ - deleteSSOKey will delete a given inactive SSOKey. + deleteSSOSigningSecret will delete a given inactive SigningSecret. """ - deleteSSOKey(input: DeleteSSOKeyInput!): DeleteSSOKeyPayload! - @auth(roles: [ADMIN]) + deleteSSOSigningSecret( + input: DeleteSSOSigningSecretInput! + ): DeleteSSOSigningSecretPayload! @auth(roles: [ADMIN]) """ createCommentReaction will create a Reaction authored by the current logged in @@ -6762,11 +7065,56 @@ type Mutation { ): DeleteWebhookEndpointPayload! @auth(roles: [ADMIN]) """ - rotateWebhookEndpointSecret will roll the current active secret to a new key. + rotateWebhookEndpointSigningSecret will roll the current active secret to a new key. """ - rotateWebhookEndpointSecret( - input: RotateWebhookEndpointSecretInput! - ): RotateWebhookEndpointSecretPayload! @auth(roles: [ADMIN]) + rotateWebhookEndpointSigningSecret( + input: RotateWebhookEndpointSigningSecretInput! + ): RotateWebhookEndpointSigningSecretPayload! @auth(roles: [ADMIN]) + + """ + createExternalModerationPhase will create a new ExternalModerationPhase. + """ + createExternalModerationPhase( + input: CreateExternalModerationPhaseInput! + ): CreateExternalModerationPhasePayload! @auth(roles: [ADMIN]) + + """ + updateExternalModerationPhase will update a ExternalModerationPhase. + """ + updateExternalModerationPhase( + input: UpdateExternalModerationPhaseInput! + ): UpdateExternalModerationPhasePayload! @auth(roles: [ADMIN]) + + """ + enableExternalModerationPhase will enable a ExternalModerationPhase to recieve + new comments. + """ + enableExternalModerationPhase( + input: EnableExternalModerationPhaseInput! + ): EnableExternalModerationPhasePayload! @auth(roles: [ADMIN]) + + """ + disableExternalModerationPhase will disable a ExternalModerationPhase from + recieving new comments. + """ + disableExternalModerationPhase( + input: DisableExternalModerationPhaseInput! + ): DisableExternalModerationPhasePayload! @auth(roles: [ADMIN]) + + """ + deleteExternalModerationPhase will delete a ExternalModerationPhase. + """ + deleteExternalModerationPhase( + input: DeleteExternalModerationPhaseInput! + ): DeleteExternalModerationPhasePayload! @auth(roles: [ADMIN]) + + """ + rotateExternalModerationPhaseSigningSecret will roll the current active secret + to a new key. + """ + rotateExternalModerationPhaseSigningSecret( + input: RotateExternalModerationPhaseSigningSecretInput! + ): RotateExternalModerationPhaseSigningSecretPayload! @auth(roles: [ADMIN]) """ updateStoryMode will set the story mode. diff --git a/src/core/server/graph/subscriptions/server.ts b/src/core/server/graph/subscriptions/server.ts index d8307a47e..1d9320f66 100644 --- a/src/core/server/graph/subscriptions/server.ts +++ b/src/core/server/graph/subscriptions/server.ts @@ -24,10 +24,10 @@ import { } from "coral-server/app/middleware/passport/strategies/jwt"; import { CoralError, - InternalError, LiveUpdatesDisabled, RawQueryNotAuthorized, TenantNotFoundError, + WrappedInternalError, } from "coral-server/errors"; import { enrichError, logError, logQuery } from "coral-server/graph/extensions"; import { getOperationMetadata } from "coral-server/graph/extensions/helpers"; @@ -151,7 +151,10 @@ export function onConnect(options: OnConnectOptions): OnConnectFn { if (!(err instanceof CoralError)) { // eslint-disable-next-line no-ex-assign - err = new InternalError(err, "could not setup websocket connection"); + err = new WrappedInternalError( + err, + "could not setup websocket connection" + ); } const { message } = err.serializeExtensions( options.i18n.getDefaultBundle() diff --git a/src/core/server/index.ts b/src/core/server/index.ts index a0b9ef212..0c1f72763 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -34,7 +34,7 @@ import { createAugmentedRedisClient, createRedisClient, } from "coral-server/services/redis"; -import TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; import { NotifierCoralEventListener, diff --git a/src/core/server/models/comment/helpers.ts b/src/core/server/models/comment/helpers.ts index fb4cc8e6c..fc6fea031 100644 --- a/src/core/server/models/comment/helpers.ts +++ b/src/core/server/models/comment/helpers.ts @@ -57,3 +57,14 @@ export function calculateRejectionRate(counts: CommentStatusCounts): number { export function hasTag(comment: Pick, tag: GQLTAG) { return comment.tags.some((v) => v.type === tag); } + +/** + * getDepth will return the depth of the comment. + * + * @param comment the comment to check for depth + */ +export function getDepth( + comment: Pick +): number { + return hasAncestors(comment) ? comment.ancestorIDs.length : 0; +} diff --git a/src/core/server/models/settings/helpers.ts b/src/core/server/models/settings/helpers.ts new file mode 100644 index 000000000..8e35ca203 --- /dev/null +++ b/src/core/server/models/settings/helpers.ts @@ -0,0 +1,15 @@ +import { + ExternalModerationExternalIntegration, + ExternalModerationPhase, +} from "./settings"; + +export function filterActivePhase() { + return (phase: Pick) => phase.enabled; +} + +export function getExternalModerationPhase( + integration: ExternalModerationExternalIntegration, + phaseID: string +) { + return integration.phases.find((p) => p.id === phaseID) || null; +} diff --git a/src/core/server/models/settings/index.ts b/src/core/server/models/settings/index.ts index 5fd61cce8..4015ea764 100644 --- a/src/core/server/models/settings/index.ts +++ b/src/core/server/models/settings/index.ts @@ -1,2 +1,3 @@ export * from "./settings"; -export * from "./secret"; +export * from "./helpers"; +export * from "./signingSecret"; diff --git a/src/core/server/models/settings/secret.ts b/src/core/server/models/settings/secret.ts deleted file mode 100644 index e23f6abfe..000000000 --- a/src/core/server/models/settings/secret.ts +++ /dev/null @@ -1,44 +0,0 @@ -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/settings.ts b/src/core/server/models/settings/settings.ts index a3302bed2..744d238c5 100644 --- a/src/core/server/models/settings/settings.ts +++ b/src/core/server/models/settings/settings.ts @@ -1,6 +1,8 @@ import { + GQLAkismetExternalIntegration, GQLAuth, GQLAuthenticationTargetFilter, + GQLCOMMENT_BODY_FORMAT, GQLEmailConfiguration, GQLFacebookAuthIntegration, GQLGoogleAuthIntegration, @@ -8,10 +10,11 @@ import { GQLLocalAuthIntegration, GQLMODERATION_MODE, GQLOIDCAuthIntegration, + GQLPerspectiveExternalIntegration, GQLSettings, } from "coral-server/graph/schema/__generated__/types"; -import { Secret } from "./secret"; +import { SigningSecretResource } from "./signingSecret"; export type LiveConfiguration = Omit; @@ -38,11 +41,10 @@ export type FacebookAuthIntegration = Omit< "callbackURL" | "redirectURL" >; -export interface SSOAuthIntegration { +export interface SSOAuthIntegration extends SigningSecretResource { enabled: boolean; allowRegistration: boolean; targetFilter: GQLAuthenticationTargetFilter; - keys: Secret[]; } /** @@ -85,6 +87,71 @@ export type Auth = Omit & { integrations: AuthIntegrations; }; +export interface ExternalModerationPhase extends SigningSecretResource { + /** + * id identifies this particular External Moderation Phase. + */ + id: string; + + /** + * name is the name assigned to this ExternalModerationPhase for + * identification purposes. + */ + name: string; + + /** + * enabled when true, will use this phase in the moderation pipeline. + */ + enabled: boolean; + + /** + * url is the actual URL that should be called. + */ + url: string; + + /** + * format is the format of the comment body sent. + */ + format: GQLCOMMENT_BODY_FORMAT; + + /** + * timeout is the number of milliseconds that this moderation is maximum + * expected to take before it is skipped. + */ + timeout: number; + + /** + * createdAt is the date that this External Moderation Phase was created at. + */ + createdAt: Date; +} + +export interface ExternalModerationExternalIntegration { + /** + * phases is all the external moderation phases for this Tenant. + */ + phases: ExternalModerationPhase[]; +} + +export interface ExternalIntegrations { + /** + * akismet provides integration with the Akismet Spam detection service. + */ + akismet: GQLAkismetExternalIntegration; + + /** + * perspective provides integration with the Perspective API comment analysis + * platform. + */ + perspective: GQLPerspectiveExternalIntegration; + + /** + * external provides integration details for external moderation phases that can be + * used in the moderation pipeline. + */ + external?: ExternalModerationExternalIntegration; +} + /** * CloseCommenting contains settings related to the automatic closing of commenting on * Stories. @@ -108,7 +175,6 @@ export type Settings = GlobalModerationSettings & | "email" | "recentCommentHistory" | "wordList" - | "integrations" | "reaction" | "staff" | "editCommentWindowLength" @@ -146,6 +212,11 @@ export type Settings = GlobalModerationSettings & */ accountFeatures: AccountFeatures; + /** + * integrations contains all the external integrations that can be enabled. + */ + integrations: ExternalIntegrations; + /** * newCommenters is the configuration for how new commenters comments are treated. */ diff --git a/src/core/server/models/settings/signingSecret/helpers.ts b/src/core/server/models/settings/signingSecret/helpers.ts new file mode 100644 index 000000000..e16ff32b3 --- /dev/null +++ b/src/core/server/models/settings/signingSecret/helpers.ts @@ -0,0 +1,99 @@ +import crypto from "crypto"; + +import { SigningSecret } from "./signingSecret"; + +function generateSecureRandomString(size: number, drift = 5) { + return crypto + .randomBytes(size + Math.floor(Math.random() * drift)) + .toString("hex"); +} + +export function generateSigningSecret( + prefix: string, + createdAt: Date +): SigningSecret { + // 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 = prefix + "_" + generateSecureRandomString(32, 5); + const kid = generateSecureRandomString(8, 3); + + return { kid, secret, createdAt }; +} + +/** + * isSecretExpired is a function that given a secret and the current date will + * return whether the secret has expired. + * + * @param secret the secret to test + * @param now the current date + */ +function isSigningSecretExpired({ inactiveAt }: SigningSecret, now: Date) { + if (inactiveAt && inactiveAt <= now) { + return true; + } + + return false; +} + +/** + * filterExpiredSecrets is a filter function that can be used to filter only + * secrets that are inactive or expired. + * + * @param now the current date + */ +export function filterExpiredSigningSecrets(now: Date) { + return (secret: SigningSecret) => isSigningSecretExpired(secret, now); +} + +/** + * filterFreshSecrets is a filtering function that can be used to filter for any + * secret that has not been rotated. + */ +export function filterFreshSigningSecrets() { + return (secret: SigningSecret) => !secret.rotatedAt; +} + +/** + * filterActiveSecrets is a filter function that can be used to filter only + * secrets that are active. + * + * @param now the current date + */ +export function filterActiveSigningSecrets(now: Date) { + return (secret: SigningSecret) => !isSigningSecretExpired(secret, now); +} + +/** + * generateSignature will generate a signature used to assist clients to + * validate that the request came from Coral. + * + * @param key the secret used to sign the body with + * @param data the data to sign + */ +function generateSignature(key: string, data: string) { + return crypto.createHmac("sha256", key).update(data).digest().toString("hex"); +} + +/** + * generateSignatures will return a header value that can be used to verify the + * integrity and authenticity of a payload sent from Coral. + * + * @param signingSecrets the secrets that should be used to sign the data with + * @param data the data to sign + * @param now the current date + */ +export function generateSignatures( + signingSecrets: SigningSecret[], + data: string, + now: Date +) { + // For each of the signatures, we only want to sign the body with secrets that + // are still active. + return signingSecrets + .filter(filterActiveSigningSecrets(now)) + .map(({ secret }) => generateSignature(secret, data)) + .map((signature) => `sha256=${signature}`) + .join(","); +} diff --git a/src/core/server/models/settings/signingSecret/index.ts b/src/core/server/models/settings/signingSecret/index.ts new file mode 100644 index 000000000..cef795e0f --- /dev/null +++ b/src/core/server/models/settings/signingSecret/index.ts @@ -0,0 +1,2 @@ +export * from "./helpers"; +export * from "./signingSecret"; diff --git a/src/core/server/models/settings/signingSecret/signingSecret.ts b/src/core/server/models/settings/signingSecret/signingSecret.ts new file mode 100644 index 000000000..22ff7cc6e --- /dev/null +++ b/src/core/server/models/settings/signingSecret/signingSecret.ts @@ -0,0 +1,287 @@ +import { get } from "lodash"; +import { Collection, FindOneAndUpdateOption, UpdateQuery } from "mongodb"; + +import logger from "coral-server/logger"; +import { FilterQuery } from "coral-server/models/helpers"; + +import { filterFreshSigningSecrets, generateSigningSecret } from "./helpers"; + +export interface SigningSecret { + /** + * 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; +} + +/** + * SigningSecretResource is a resource that contains signing credentials. + */ +export interface SigningSecretResource { + /** + * signingSecrets are secrets used for signing and verification. + */ + signingSecrets: SigningSecret[]; +} + +interface SigningSecretResourceNode extends SigningSecretResource { + id: string; +} + +interface RotateSigningSecretOptions { + /** + * collection is the database collection that the document exists in. + */ + collection: Collection>; + + /** + * filter is the database query used to filter out the specific document that + * contains a SigningResource. + */ + filter: FilterQuery; + + /** + * path is the dot notation path of the resource that contains the signing + * secrets. + */ + path: string; + + /** + * prefix is the secret prefix that will be added to the new secret that is + * generated. + */ + prefix: string; + + /** + * id is the identifier used when querying for the resource identified by the + * `path` parameter above. + */ + id?: string; + + /** + * inactiveAt is the date that the previous keys should be active for. + */ + inactiveAt: Date; + + /** + * now is the current date. + */ + now: Date; +} + +/** + * getSecretKIDsToDeprecate will return all the keys that should be deprecated + * from the first phase of the rolling process. + * + * @param signingSecrets the keys returned by the query operation + */ +const getSecretKIDsToDeprecate = (signingSecrets: SigningSecret[]) => + signingSecrets + // By excluding the last one (the one we just pushed)... + .splice(0, signingSecrets.length - 1) + // And only finding keys that have not been rotated yet. + .filter(filterFreshSigningSecrets()) + // And get their kid's. + .map((s) => s.kid); + +async function pushNewSigningSecret({ + collection, + filter, + path, + prefix, + id, + now, +}: RotateSigningSecretOptions) { + // Create the new secret. + const secret = generateSigningSecret(prefix, now); + + // Generate the update for the operation. + let update: UpdateQuery; + if (id) { + update = { + $push: { + [`${path}.$[resource].signingSecrets`]: secret, + }, + }; + } else { + update = { + $push: { + [`${path}.signingSecrets`]: secret, + }, + }; + } + + // Generate the options for the operation. + const options: FindOneAndUpdateOption = { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + }; + if (id) { + options.arrayFilters = [ + // Select the secret resource under the object. + { "resource.id": id }, + ]; + } + + // Update the resource with this new secret. + const result = await collection.findOneAndUpdate(filter, update, options); + if (!result.value) { + return null; + } + + return result.value; +} + +function getResourceFromDoc( + { id, path }: Pick, "id" | "path">, + doc: T +) { + if (id) { + // The ID was provided, so we need to get the referenced resource by it's + // ID. This also means that the resource indicated exists in an Array, so + // treat it as such. + + // Get the resource referenced by the path (should be a path to an array). + const resources: SigningSecretResourceNode[] | undefined = get(doc, path); + if (!resources || !Array.isArray(resources)) { + return null; + } + + // Get the specific resource from the array. + const resource = resources.find((r) => r.id === id); + if (!resource) { + return null; + } + + return resource; + } + + // The ID was not provided, which means that the resource is not in an array. + const resource: SigningSecretResource | undefined = get(doc, path); + if (!resource) { + if (Array.isArray(resource)) { + throw new Error("we were not passed an ID but got an array anyways"); + } + + return null; + } + + return resource; +} + +async function deprecateOldSigningSecrets( + { + collection, + path, + inactiveAt, + filter, + id, + now, + }: Pick< + RotateSigningSecretOptions, + "collection" | "path" | "inactiveAt" | "filter" | "id" | "now" + >, + doc: Readonly +) { + // Get the resource from the value. + const resource = getResourceFromDoc({ id, path }, doc); + if (!resource) { + return null; + } + + // Get the secrets we need to deactivate... + const secretKIDsToDeprecate = getSecretKIDsToDeprecate( + resource.signingSecrets + ); + if (secretKIDsToDeprecate.length === 0) { + return doc; + } + + logger.trace( + { kids: secretKIDsToDeprecate, filter, arrayFilter: { id: id || null } }, + "deprecating old signingSecrets" + ); + + // Construct the update operation for rotating the secret. + let update: UpdateQuery; + if (id) { + update = { + $set: { + [`${path}.$[resource].signingSecrets.$[signingSecret].inactiveAt`]: inactiveAt, + [`${path}.$[resource].signingSecrets.$[signingSecret].rotatedAt`]: now, + }, + }; + } else { + update = { + $set: { + [`${path}.signingSecrets.$[signingSecret].inactiveAt`]: inactiveAt, + [`${path}.signingSecrets.$[signingSecret].rotatedAt`]: now, + }, + }; + } + + // Construct the options for the operation. + const options: FindOneAndUpdateOption = { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + arrayFilters: [ + // Select any signing secrets with the given ids. + { "signingSecret.kid": { $in: secretKIDsToDeprecate } }, + ], + }; + if (id) { + options.arrayFilters!.push( + // Select the secret resource under the object. + { "resource.id": id } + ); + } + + // Deactivate the old keys. + const result = await collection.findOneAndUpdate(filter, update, options); + if (!result.value) { + return null; + } + + return result.value; +} + +export async function rotateSigningSecret( + options: RotateSigningSecretOptions +) { + // Push the new secret into the resource and return it. + let doc = await pushNewSigningSecret(options); + if (!doc) { + return null; + } + + // Deprecate any old secrets on the document. + doc = await deprecateOldSigningSecrets(options, doc); + if (!doc) { + return null; + } + + return doc; +} diff --git a/src/core/server/models/tenant/externalModerationPhase.ts b/src/core/server/models/tenant/externalModerationPhase.ts new file mode 100644 index 000000000..457622949 --- /dev/null +++ b/src/core/server/models/tenant/externalModerationPhase.ts @@ -0,0 +1,215 @@ +import { isEmpty } from "lodash"; +import { Db } from "mongodb"; +import uuid from "uuid/v4"; + +import { dotize } from "coral-common/utils/dotize"; +import { tenants as collection } from "coral-server/services/mongodb/collections"; + +import { GQLCOMMENT_BODY_FORMAT } from "coral-server/graph/schema/__generated__/types"; + +import { + ExternalModerationPhase, + generateSigningSecret, + getExternalModerationPhase, + rotateSigningSecret, +} from "../settings"; +import { retrieveTenant } from "./tenant"; + +export async function rotateTenantExternalModerationPhaseSigningSecret( + mongo: Db, + id: string, + phaseID: string, + inactiveAt: Date, + now: Date +) { + return rotateSigningSecret({ + collection: collection(mongo), + filter: { id }, + path: "integrations.external.phases", + id: phaseID, + prefix: "empsec", + inactiveAt, + now, + }); +} + +export interface CreateTenantExternalModerationPhaseInput { + name: string; + url: string; + format: GQLCOMMENT_BODY_FORMAT; + timeout: number; +} + +export async function createTenantExternalModerationPhase( + mongo: Db, + id: string, + input: CreateTenantExternalModerationPhaseInput, + now: Date +) { + // Create the new phase. + const phase: ExternalModerationPhase = { + ...input, + id: uuid(), + enabled: true, + signingSecrets: [generateSigningSecret("empsec", now)], + createdAt: now, + }; + + // Update the Tenant with this new phase. + const result = await collection(mongo).findOneAndUpdate( + { id }, + { $push: { "integrations.external.phases": phase } }, + { + // 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 { + phase: null, + tenant: null, + }; + } + + throw new Error("update failed for an unexpected reason"); + } + + return { + phase, + tenant: result.value, + }; +} + +export interface UpdateTenantExternalModerationPhaseInput { + name?: string; + enabled?: boolean; + url?: string; + format?: GQLCOMMENT_BODY_FORMAT; + timeout?: number; +} + +export async function updateTenantExternalModerationPhase( + mongo: Db, + id: string, + phaseID: string, + update: UpdateTenantExternalModerationPhaseInput +) { + const $set = dotize( + { "integrations.external.phases.$[phase]": 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: [{ "phase.id": phaseID }], + } + ); + if (!result.value) { + const tenant = await retrieveTenant(mongo, id); + if (!tenant) { + return null; + } + + if (!tenant.integrations.external) { + throw new Error(`phase not found with id: ${phaseID} on tenant: ${id}`); + } + + const endpoint = getExternalModerationPhase( + tenant.integrations.external, + phaseID + ); + if (!endpoint) { + throw new Error(`phase not found with id: ${phaseID} on tenant: ${id}`); + } + + throw new Error("update failed for an unexpected reason"); + } + + return result.value; +} + +export async function deleteTenantExternalModerationPhase( + mongo: Db, + id: string, + phaseID: string +) { + const result = await collection(mongo).findOneAndUpdate( + { id }, + { + $pull: { + "integrations.external.phases": { id: phaseID }, + }, + }, + { + // 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; +} + +export async function deleteTenantExternalModerationPhaseSigningSecrets( + mongo: Db, + id: string, + phaseID: string, + kids: string[] +) { + const result = await collection(mongo).findOneAndUpdate( + { id }, + { + $pull: { + "integrations.external.phases.$[phase].signingSecrets": { + kid: { $in: kids }, + }, + }, + }, + { returnOriginal: false, arrayFilters: [{ "phase.id": phaseID }] } + ); + if (!result.value) { + const tenant = await retrieveTenant(mongo, id); + if (!tenant) { + return null; + } + + if (!tenant.integrations.external) { + throw new Error(`phase not found with id: ${phaseID} on tenant: ${id}`); + } + + const endpoint = getExternalModerationPhase( + tenant.integrations.external, + phaseID + ); + if (!endpoint) { + throw new Error(`phase not found with id: ${phaseID} on tenant: ${id}`); + } + + throw new Error("update failed for an unexpected reason"); + } + + return result.value; +} diff --git a/src/core/server/models/tenant/helpers.ts b/src/core/server/models/tenant/helpers.ts index 8d109884a..5a1f31188 100644 --- a/src/core/server/models/tenant/helpers.ts +++ b/src/core/server/models/tenant/helpers.ts @@ -1,5 +1,4 @@ import { FluentBundle } from "@fluent/bundle/compat"; -import crypto from "crypto"; import { translate } from "coral-server/services/i18n"; @@ -10,7 +9,6 @@ import { GQLStaffConfiguration, } from "coral-server/graph/schema/__generated__/types"; -import { Secret } from "../settings"; import { Tenant } from "./tenant"; export const getDefaultReactionConfiguration = ( @@ -34,23 +32,6 @@ export const getDefaultStaffConfiguration = ( label: translate(bundle, "Staff", "staff-label"), }); -export function generateRandomString(size: number, drift = 5) { - return crypto - .randomBytes(size + Math.floor(Math.random() * drift)) - .toString("hex"); -} - -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 = prefix + "_" + generateRandomString(32, 5); - const kid = generateRandomString(8, 3); - - return { kid, secret, createdAt }; -} - /** * hasFeatureFlag will check to see if the Tenant has a particular feature flag * enabled. diff --git a/src/core/server/models/tenant/index.ts b/src/core/server/models/tenant/index.ts index b28af480a..aa9274201 100644 --- a/src/core/server/models/tenant/index.ts +++ b/src/core/server/models/tenant/index.ts @@ -1,2 +1,5 @@ export * from "./tenant"; export * from "./helpers"; +export * from "./externalModerationPhase"; +export * from "./webhookEndpoint"; +export * from "./sso"; diff --git a/src/core/server/models/tenant/sso.ts b/src/core/server/models/tenant/sso.ts new file mode 100644 index 000000000..9f4d52e33 --- /dev/null +++ b/src/core/server/models/tenant/sso.ts @@ -0,0 +1,71 @@ +import { Db } from "mongodb"; + +import { rotateSigningSecret } from "coral-server/models/settings"; +import { tenants as collection } from "coral-server/services/mongodb/collections"; + +export async function rotateTenantSSOSigningSecret( + mongo: Db, + id: string, + inactiveAt: Date, + now: Date +) { + return rotateSigningSecret({ + collection: collection(mongo), + filter: { id }, + path: "auth.integrations.sso", + prefix: "ssosec", + inactiveAt, + now, + }); +} + +export async function deactivateTenantSSOSigningSecret( + mongo: Db, + id: string, + kid: string, + inactiveAt: Date, + now: Date +) { + // Update the tenant. + const result = await collection(mongo).findOneAndUpdate( + { id }, + { + $set: { + "auth.integrations.sso.keys.$[keys].inactiveAt": inactiveAt, + "auth.integrations.sso.keys.$[keys].rotatedAt": now, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + // Add an ArrayFilter to only update one of the keys. + arrayFilters: [{ "keys.kid": kid }], + } + ); + + return result.value || null; +} + +export async function deleteTenantSSOSigningSecret( + mongo: Db, + id: string, + kid: string +) { + // Update the tenant. + const result = await collection(mongo).findOneAndUpdate( + { id }, + { + $pull: { + "auth.integrations.sso.keys": { kid }, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + + return result.value || null; +} diff --git a/src/core/server/models/tenant/tenant.ts b/src/core/server/models/tenant/tenant.ts index ce4f77c66..07d3b61b4 100644 --- a/src/core/server/models/tenant/tenant.ts +++ b/src/core/server/models/tenant/tenant.ts @@ -1,4 +1,3 @@ -import { Redis } from "ioredis"; import { isEmpty } from "lodash"; import { DateTime } from "luxon"; import { Db } from "mongodb"; @@ -10,7 +9,11 @@ import TIME from "coral-common/time"; import { DeepPartial, Sub } from "coral-common/types"; import { isBeforeDate } from "coral-common/utils"; import { dotize } from "coral-common/utils/dotize"; -import logger from "coral-server/logger"; +import { + generateSigningSecret, + Settings, + SigningSecretResource, +} from "coral-server/models/settings"; import { I18n } from "coral-server/services/i18n"; import { tenants as collection } from "coral-server/services/mongodb/collections"; @@ -22,12 +25,9 @@ import { GQLWEBHOOK_EVENT_NAME, } from "coral-server/graph/schema/__generated__/types"; -import { Secret, Settings } from "../settings"; import { - generateSecret, getDefaultReactionConfiguration, getDefaultStaffConfiguration, - getWebhookEndpoint, } from "./helpers"; /** @@ -42,7 +42,7 @@ export interface TenantResource { readonly tenantID: string; } -export interface Endpoint { +export interface Endpoint extends SigningSecretResource { /** * id is the unique identifier for this specific endpoint. */ @@ -58,11 +58,6 @@ export interface Endpoint { */ url: string; - /** - * signingSecret is the secret used to sign the events sent out. - */ - signingSecrets: Secret[]; - /** * all when true indicates that all events should trigger. */ @@ -198,7 +193,7 @@ export async function createTenant( stream: true, }, // TODO: [CORL-754] (wyattjoh) remove this in favor of generating this when needed - keys: [generateSecret("ssosec", now)], + signingSecrets: [generateSigningSecret("ssosec", now)], }, oidc: { enabled: false, @@ -360,59 +355,6 @@ export async function updateTenant( return result.value || null; } -/** - * regenerateTenantSSOKey will regenerate the SSO key used for Single Sing-On - * for the specified Tenant. All existing user sessions signed with the old - * secret will be invalidated. - */ -export async function createTenantSSOKey(mongo: Db, id: string, now: Date) { - // Construct the new key. - const key = generateSecret("ssosec", now); - - // Update the Tenant with this new key. - const result = await collection(mongo).findOneAndUpdate( - { id }, - { - $push: { - "auth.integrations.sso.keys": key, - }, - }, - // False to return the updated document instead of the original - // document. - { returnOriginal: false } - ); - - return result.value || null; -} - -export async function deactivateTenantSSOKey( - mongo: Db, - id: string, - kid: string, - inactiveAt: Date, - now: Date -) { - // Update the tenant. - const result = await collection(mongo).findOneAndUpdate( - { id }, - { - $set: { - "auth.integrations.sso.keys.$[keys].inactiveAt": inactiveAt, - "auth.integrations.sso.keys.$[keys].rotatedAt": now, - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - // Add an ArrayFilter to only update one of the keys. - arrayFilters: [{ "keys.kid": kid }], - } - ); - - return result.value || null; -} - export async function enableTenantFeatureFlag( mongo: Db, id: string, @@ -460,24 +402,6 @@ export async function disableTenantFeatureFlag( return result.value || null; } -export async function deleteTenantSSOKey(mongo: Db, id: string, kid: string) { - // Update the tenant. - const result = await collection(mongo).findOneAndUpdate( - { id }, - { - $pull: { - "auth.integrations.sso.keys": { kid }, - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - - return result.value || null; -} export interface CreateAnnouncementInput { content: string; @@ -542,306 +466,3 @@ 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; -} - -function lastUsedAtTenantSSOKey(id: string): string { - return `${id}:lastUsedSSOKey`; -} - -/** - * updateLastUsedAtTenantSSOKey will update the time stamp that the SSO key was - * last used at. - * - * @param redis the Redis connection to use to update the timestamp on - * @param id the ID of the Tenant - * @param kid the kid of the token that was used - * @param when the date that the token was last used at - */ -export async function updateLastUsedAtTenantSSOKey( - redis: Redis, - id: string, - kid: string, - when: Date -) { - await redis.hset(lastUsedAtTenantSSOKey(id), kid, when.toISOString()); -} - -/** - * - * @param redis the Redis connection to use to remove the last used on. - * @param id the ID of the Tenant - * @param kid the kid of the token that is being deleted - */ -export async function deleteLastUsedAtTenantSSOKey( - redis: Redis, - id: string, - kid: string -) { - await redis.hdel(lastUsedAtTenantSSOKey(id), kid); -} - -/** - * retrieveLastUsedAtTenantSSOKeys will get the dates that the requested sso - * keys were last used on. - * - * @param redis the Redis connection to use to update the timestamp on - * @param id the ID of the Tenant - * @param kids the kids of the tokens that we want to know when they were last used - */ -export async function retrieveLastUsedAtTenantSSOKeys( - redis: Redis, - id: string, - kids: string[] -) { - const results: Array = await redis.hmget( - lastUsedAtTenantSSOKey(id), - ...kids - ); - - return results.map((lastUsedAt) => { - if (!lastUsedAt) { - return null; - } - - return new Date(lastUsedAt); - }); -} diff --git a/src/core/server/models/tenant/webhookEndpoint.ts b/src/core/server/models/tenant/webhookEndpoint.ts new file mode 100644 index 000000000..127260f1e --- /dev/null +++ b/src/core/server/models/tenant/webhookEndpoint.ts @@ -0,0 +1,265 @@ +import { Redis } from "ioredis"; +import { isEmpty } from "lodash"; +import { Db } from "mongodb"; +import uuid from "uuid/v4"; + +import { dotize } from "coral-common/utils/dotize"; +import { tenants as collection } from "coral-server/services/mongodb/collections"; + +import { GQLWEBHOOK_EVENT_NAME } from "coral-server/graph/schema/__generated__/types"; + +import { generateSigningSecret, rotateSigningSecret } from "../settings"; +import { getWebhookEndpoint } from "./helpers"; +import { Endpoint, retrieveTenant } from "./tenant"; + +export async function rotateTenantWebhookEndpointSigningSecret( + mongo: Db, + id: string, + endpointID: string, + inactiveAt: Date, + now: Date +) { + return rotateSigningSecret({ + collection: collection(mongo), + filter: { id }, + path: "webhooks.endpoints", + prefix: "whsec", + id: endpointID, + inactiveAt, + now, + }); +} + +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: [generateSigningSecret("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 deleteTenantWebhookEndpointSigningSecrets( + 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; +} + +function lastUsedAtTenantSSOSigningSecret(id: string): string { + return `${id}:lastUsedSSOSigningSecret`; +} + +/** + * updateLastUsedAtTenantSSOSigningSecret will update the time stamp that the + * SSO key was last used at. + * + * @param redis the Redis connection to use to update the timestamp on + * @param id the ID of the Tenant + * @param kid the kid of the token that was used + * @param when the date that the token was last used at + */ +export async function updateLastUsedAtTenantSSOSigningSecret( + redis: Redis, + id: string, + kid: string, + when: Date +) { + await redis.hset( + lastUsedAtTenantSSOSigningSecret(id), + kid, + when.toISOString() + ); +} + +/** + * + * @param redis the Redis connection to use to remove the last used on. + * @param id the ID of the Tenant + * @param kid the kid of the token that is being deleted + */ +export async function deleteLastUsedAtTenantSSOSigningSecret( + redis: Redis, + id: string, + kid: string +) { + await redis.hdel(lastUsedAtTenantSSOSigningSecret(id), kid); +} + +/** + * retrieveLastUsedAtTenantSSOSigningSecrets will get the dates that the + * requested sso keys were last used on. + * + * @param redis the Redis connection to use to update the timestamp on + * @param id the ID of the Tenant + * @param kids the kids of the tokens that we want to know when they were last used + */ +export async function retrieveLastUsedAtTenantSSOSigningSecrets( + redis: Redis, + id: string, + kids: string[] +) { + const results: Array = await redis.hmget( + lastUsedAtTenantSSOSigningSecret(id), + ...kids + ); + + return results.map((lastUsedAt) => { + if (!lastUsedAt) { + return null; + } + + return new Date(lastUsedAt); + }); +} diff --git a/src/core/server/queue/index.ts b/src/core/server/queue/index.ts index 04feb428e..1d2596630 100644 --- a/src/core/server/queue/index.ts +++ b/src/core/server/queue/index.ts @@ -5,7 +5,7 @@ import { Config } from "coral-server/config"; import { I18n } from "coral-server/services/i18n"; import { JWTSigningConfig } from "coral-server/services/jwt"; import { AugmentedRedis, createRedisClient } from "coral-server/services/redis"; -import TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; import { createMailerTask, MailerQueue } from "./tasks/mailer"; import { createNotifierTask, NotifierQueue } from "./tasks/notifier"; diff --git a/src/core/server/queue/tasks/mailer/content.ts b/src/core/server/queue/tasks/mailer/content.ts index 96078193f..67803f875 100644 --- a/src/core/server/queue/tasks/mailer/content.ts +++ b/src/core/server/queue/tasks/mailer/content.ts @@ -3,8 +3,10 @@ import nunjucks, { Environment, ILoader } from "nunjucks"; import path from "path"; import { Config } from "coral-server/config"; -import TenantCache from "coral-server/services/tenant/cache"; -import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter"; +import { + TenantCache, + TenantCacheAdapter, +} from "coral-server/services/tenant/cache"; import { Tenant } from "coral-server/models/tenant"; import { EmailTemplate } from "./templates"; diff --git a/src/core/server/queue/tasks/mailer/index.ts b/src/core/server/queue/tasks/mailer/index.ts index 27d669a83..069602e43 100644 --- a/src/core/server/queue/tasks/mailer/index.ts +++ b/src/core/server/queue/tasks/mailer/index.ts @@ -4,7 +4,7 @@ import { createTimer } from "coral-server/helpers"; import logger from "coral-server/logger"; import Task from "coral-server/queue/Task"; import MailerContent from "coral-server/queue/tasks/mailer/content"; -import TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; import { createJobProcessor, diff --git a/src/core/server/queue/tasks/mailer/processor.ts b/src/core/server/queue/tasks/mailer/processor.ts index 863bac077..bb822cf4f 100644 --- a/src/core/server/queue/tasks/mailer/processor.ts +++ b/src/core/server/queue/tasks/mailer/processor.ts @@ -12,15 +12,17 @@ import { Db } from "mongodb"; import { createTransport } from "nodemailer"; import { Options } from "nodemailer/lib/smtp-connection"; -import { LanguageCode } from "coral-common/helpers/i18n/locales"; +import { LanguageCode } from "coral-common/helpers"; import { Config } from "coral-server/config"; -import { InternalError } from "coral-server/errors"; +import { WrappedInternalError } from "coral-server/errors"; import { createTimer } from "coral-server/helpers"; import logger from "coral-server/logger"; import { Tenant } from "coral-server/models/tenant"; import { I18n, translate } from "coral-server/services/i18n"; -import TenantCache from "coral-server/services/tenant/cache"; -import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter"; +import { + TenantCache, + TenantCacheAdapter, +} from "coral-server/services/tenant/cache"; export const JOB_NAME = "mailer"; @@ -254,7 +256,7 @@ export const createJobProcessor = (options: MailProcessorOptions) => { data ); } catch (e) { - throw new InternalError(e, "could not translate the message"); + throw new WrappedInternalError(e, "could not translate the message"); } log.trace( @@ -284,7 +286,7 @@ export const createJobProcessor = (options: MailProcessorOptions) => { // Create the transport based on the smtp uri. transport = createTransport(opts); } catch (e) { - throw new InternalError(e, "could not create email transport"); + throw new WrappedInternalError(e, "could not create email transport"); } // Set the transport back into the cache. @@ -303,7 +305,7 @@ export const createJobProcessor = (options: MailProcessorOptions) => { // Send the mail message. await transport.sendMail(message); } catch (e) { - throw new InternalError(e, "could not send email"); + throw new WrappedInternalError(e, "could not send email"); } log.debug({ responseTime: messageSendTimer() }, "sent the email"); diff --git a/src/core/server/queue/tasks/notifier/index.ts b/src/core/server/queue/tasks/notifier/index.ts index fe8317bf1..f6308a9e3 100644 --- a/src/core/server/queue/tasks/notifier/index.ts +++ b/src/core/server/queue/tasks/notifier/index.ts @@ -10,7 +10,7 @@ import { categories, NotificationCategory, } from "coral-server/services/notifications/categories"; -import TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; import { createJobProcessor, JOB_NAME, NotifierData } from "./processor"; diff --git a/src/core/server/queue/tasks/notifier/processor.ts b/src/core/server/queue/tasks/notifier/processor.ts index db7fe0fb3..43b641218 100644 --- a/src/core/server/queue/tasks/notifier/processor.ts +++ b/src/core/server/queue/tasks/notifier/processor.ts @@ -10,7 +10,7 @@ import { JWTSigningConfig } from "coral-server/services/jwt"; 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 TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; import { filterSuperseded, diff --git a/src/core/server/queue/tasks/rejector.ts b/src/core/server/queue/tasks/rejector.ts index a5cd9c44e..98474cbbe 100644 --- a/src/core/server/queue/tasks/rejector.ts +++ b/src/core/server/queue/tasks/rejector.ts @@ -11,7 +11,7 @@ import { import { Connection } from "coral-server/models/helpers"; import Task from "coral-server/queue/Task"; import { AugmentedRedis } from "coral-server/services/redis"; -import TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; import { rejectComment } from "coral-server/stacks"; import { GQLCOMMENT_SORT } from "coral-server/graph/schema/__generated__/types"; diff --git a/src/core/server/queue/tasks/webhook/processor.ts b/src/core/server/queue/tasks/webhook/processor.ts index d147349d1..3f99d285e 100644 --- a/src/core/server/queue/tasks/webhook/processor.ts +++ b/src/core/server/queue/tasks/webhook/processor.ts @@ -1,4 +1,3 @@ -import crypto from "crypto"; import { Redis } from "ioredis"; import { Db } from "mongodb"; @@ -6,19 +5,15 @@ import { Config } from "coral-server/config"; import { CoralEventPayload } from "coral-server/events/event"; import { createTimer } from "coral-server/helpers"; import logger from "coral-server/logger"; +import { filterExpiredSigningSecrets } from "coral-server/models/settings"; import { - filterActiveSecrets, - filterExpiredSecrets, -} from "coral-server/models/settings"; -import { - deleteEndpointSecrets, - Endpoint, + deleteTenantWebhookEndpointSigningSecrets, getWebhookEndpoint, } from "coral-server/models/tenant"; import { JobProcessor } from "coral-server/queue/Task"; -import { createFetch, FetchOptions } from "coral-server/services/fetch"; +import { createFetch, generateFetchOptions } from "coral-server/services/fetch"; import { disableWebhookEndpoint } from "coral-server/services/tenant"; -import TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; export const JOB_NAME = "webhook"; @@ -53,36 +48,7 @@ export interface WebhookDelivery { 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(","); -} - -type CoralWebhookEventPayload = CoralEventPayload & { +interface CoralWebhookEventPayload extends CoralEventPayload { /** * tenantID is the ID of the Tenant that this event originated at. */ @@ -92,28 +58,6 @@ type CoralWebhookEventPayload = CoralEventPayload & { * tenantDomain is the domain that is associated with this Tenant that this event originated at. */ readonly tenantDomain: string; -}; - -export function generateFetchOptions( - endpoint: Pick, - data: CoralWebhookEventPayload, - 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({ @@ -162,12 +106,21 @@ export function createJobProcessor({ // Get the current date. const now = new Date(); + // Generate the payload. + const payload: CoralWebhookEventPayload = { + ...event, + tenantID, + tenantDomain: tenant.domain, + }; + // Get the fetch options. - const options = generateFetchOptions( - endpoint, - { ...event, tenantID, tenantDomain: tenant.domain }, - now - ); + const options = generateFetchOptions(endpoint.signingSecrets, payload, now); + + // Add the X-Coral-Event header. + options.headers = { + ...options.headers, + "X-Coral-Event": event.type, + }; // Send the request. const timer = createTimer(); @@ -240,35 +193,34 @@ export function createJobProcessor({ tenant, endpointID ); - } else { - // TODO: (wyattjoh) maybe schedule a retry? } + // 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) { + const expiredSigningSecretKIDs = endpoint.signingSecrets + .filter(filterExpiredSigningSecrets(now)) + .map((s) => s.kid); + if (expiredSigningSecretKIDs.length > 0) { process.nextTick(() => { - deleteEndpointSecrets( + deleteTenantWebhookEndpointSigningSecrets( mongo, tenantID, endpoint.id, - expiredSigningSecrets.map((s) => s.kid) + expiredSigningSecretKIDs ) .then(() => { log.info( - { secrets: expiredSigningSecrets.length }, + { endpointID: endpoint.id, kids: expiredSigningSecretKIDs }, "removed expired secrets from endpoint" ); }) .catch((err) => { log.error( { err }, - "an error occurred when trying to remove expired secrets" + "an error occurred when trying to remove expired endpoint secrets" ); }); }); diff --git a/src/core/server/services/comments/pipeline/helpers.ts b/src/core/server/services/comments/pipeline/helpers.ts new file mode 100644 index 000000000..98871af87 --- /dev/null +++ b/src/core/server/services/comments/pipeline/helpers.ts @@ -0,0 +1,43 @@ +import { PhaseResult } from "./pipeline"; + +export function mergePhaseResult( + result: Partial, + final: Partial +) { + const { actions = [], tags = [], metadata = {} } = final; + + // If this result contained actions, then we should push it into the + // other actions. + if (result.actions) { + final.actions = [...actions, ...result.actions]; + } + + // If this result contained metadata, then we should merge it into the + // other metadata. + if (result.metadata) { + final.metadata = { ...metadata, ...result.metadata }; + } + + // If the result modified the comment body, we should replace it. + if (result.body) { + final.body = result.body; + } + + // If the result added any tags, we should push it into the existing tags. + if (result.tags && result.tags.length > 0) { + final.tags = [ + ...tags, + // Only push in tags that we haven't already added. + ...result.tags.filter((tag) => !tags.includes(tag)), + ]; + } + + // If this result contained a status, then we've finished resolving + // phases! + if (result.status) { + final.status = result.status; + return true; + } + + return false; +} diff --git a/src/core/server/services/comments/pipeline/phases/approve.ts b/src/core/server/services/comments/pipeline/phases/approve.ts index 3ba2bda1e..107a80927 100644 --- a/src/core/server/services/comments/pipeline/phases/approve.ts +++ b/src/core/server/services/comments/pipeline/phases/approve.ts @@ -20,9 +20,7 @@ export const approve: IntermediateModerationPhase = ({ // automatically approved. We will only see EXPERT // tags assigned when we are in Q&A mode, so we can // trust this simple tag type check. - if ( - tags.some((tag) => tag.type === GQLTAG.STAFF || tag.type === GQLTAG.EXPERT) - ) { + if (tags.includes(GQLTAG.STAFF) || tags.includes(GQLTAG.EXPERT)) { return { status: GQLCOMMENT_STATUS.APPROVED, }; diff --git a/src/core/server/services/comments/pipeline/phases/detectLinks.ts b/src/core/server/services/comments/pipeline/phases/detectLinks.ts index cc940c23c..eb8bd2b3e 100755 --- a/src/core/server/services/comments/pipeline/phases/detectLinks.ts +++ b/src/core/server/services/comments/pipeline/phases/detectLinks.ts @@ -32,7 +32,6 @@ export const detectLinks: IntermediateModerationPhase = ({ status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actions: [ { - userID: null, actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS, }, diff --git a/src/core/server/services/comments/pipeline/phases/external.spec.ts b/src/core/server/services/comments/pipeline/phases/external.spec.ts new file mode 100644 index 000000000..d9b6e8039 --- /dev/null +++ b/src/core/server/services/comments/pipeline/phases/external.spec.ts @@ -0,0 +1,82 @@ +import { validateResponse } from "./external"; + +describe("validateResponse", () => { + it("allows an empty response", () => { + expect(validateResponse({})).toEqual({}); + }); + it("allows a valid status response", () => { + expect(validateResponse({ status: "NONE" })).toEqual({ status: "NONE" }); + expect(validateResponse({ status: "APPROVED" })).toEqual({ + status: "APPROVED", + }); + expect(validateResponse({ status: "REJECTED" })).toEqual({ + status: "REJECTED", + }); + expect(validateResponse({ status: "SYSTEM_WITHHELD" })).toEqual({ + status: "SYSTEM_WITHHELD", + }); + }); + it("allows a valid tag response", () => { + expect(validateResponse({ tags: ["FEATURED"] })).toEqual({ + tags: ["FEATURED"], + }); + expect(validateResponse({ tags: ["STAFF"] })).toEqual({ + tags: ["STAFF"], + }); + }); + it("allows and strips unknown fields from the response", () => { + expect(validateResponse({ willFail: false })).toEqual({}); + }); + it("allows a valid action response", () => { + expect( + validateResponse({ + actions: [{ actionType: "FLAG", reason: "COMMENT_DETECTED_SPAM" }], + }) + ).toEqual({ + actions: [{ actionType: "FLAG", reason: "COMMENT_DETECTED_SPAM" }], + }); + }); + it("allows a valid action response and filters undefined fields", () => { + expect( + validateResponse({ + actions: [ + { + actionType: "FLAG", + reason: "COMMENT_DETECTED_TOXIC", + additionalDetails: "This is additional details", + metadata: { this: { is: { a: { deep: "object" } } } }, + }, + ], + }) + ).toEqual({ + actions: [ + { + actionType: "FLAG", + reason: "COMMENT_DETECTED_TOXIC", + }, + ], + }); + }); + it("disallows incorrect reasons in the actions from the response", () => { + expect(() => + validateResponse({ + actions: [{ actionType: "FLAG", reason: "COMMENT_DETECTED_NOT_REAL" }], + }) + ).toThrow(); + expect(() => + validateResponse({ + actions: [ + { actionType: "ALSO_NOT_REAL", reason: "COMMENT_DETECTED_NOT_REAL" }, + ], + }) + ).toThrow(); + }); + it("disallows incorrect tag types in the tags from the response", () => { + expect(() => validateResponse({ tags: ["NOT_REAL"] })).toThrow(); + expect(() => validateResponse({ tags: [""] })).toThrow(); + }); + it("disallows incorrect values in the status response", () => { + expect(() => validateResponse({ status: "fail" })).toThrow(); + expect(() => validateResponse({ status: "approved" })).toThrow(); + }); +}); diff --git a/src/core/server/services/comments/pipeline/phases/external.ts b/src/core/server/services/comments/pipeline/phases/external.ts new file mode 100644 index 000000000..a9fcbb2fe --- /dev/null +++ b/src/core/server/services/comments/pipeline/phases/external.ts @@ -0,0 +1,316 @@ +import Joi from "@hapi/joi"; + +import { ACTION_TYPE } from "coral-server/models/action/comment"; +import { + ExternalModerationPhase, + filterActivePhase, + filterExpiredSigningSecrets, +} from "coral-server/models/settings"; +import { deleteTenantExternalModerationPhaseSigningSecrets } from "coral-server/models/tenant"; +import { + IntermediateModerationPhase, + PhaseResult, +} from "coral-server/services/comments/pipeline"; +import { createFetch, generateFetchOptions } from "coral-server/services/fetch"; + +import { + GQLCOMMENT_BODY_FORMAT, + GQLCOMMENT_FLAG_DETECTED_REASON, + GQLCOMMENT_STATUS, + GQLTAG, + GQLUSER_ROLE, +} from "coral-server/graph/schema/__generated__/types"; + +import { mergePhaseResult } from "../helpers"; +import { IntermediateModerationPhaseContext } from "../pipeline"; + +export interface ExternalModerationRequest { + /** + * action refers to the specific operation being performed. If `NEW`, this + * is referring to a new comment being created. If `EDIT`, then this refers to + * an operation involving an edit operation on an existing Comment. + */ + action: "NEW" | "EDIT"; + + /** + * comment refers to the actual Comment data for the Comment being + * created/edited. + */ + comment: { + /** + * body refers to the actual body text of the Comment being created/edited. + */ + body: string; + + /** + * parentID is the identifier for the parent comment (if this Comment is a + * reply, null otherwise). + */ + parentID: string | null; + }; + + /** + * author refers to the User that is creating/editing the Comment. + */ + author: { + /** + * id is the identifier for this User. + */ + id: string; + + /** + * role refers to the role of this User. + */ + role: GQLUSER_ROLE; + }; + + /** + * story refers to the Story being commented on. + */ + story: { + /** + * id is the identifier for this Story. + */ + id: string; + + /** + * url is the URL for this Story. + */ + url: string; + }; + + /** + * site refers to the Site that the story being commented on belongs to. + */ + site: { + /** + * id is the identifier for this Site. + */ + id: string; + }; + + /** + * tenantID is the identifer of the Tenant that this Comment is being + * created/edited on. + */ + tenantID: string; + + /** + * tenantDomain is the domain that is associated with this Tenant that this + * Comment is being created/edited on. + */ + tenantDomain: string; +} + +export type ExternalModerationResponse = Partial< + Pick +>; + +const ExternalModerationResponseSchema = Joi.object().keys({ + actions: Joi.array().items( + Joi.object().keys({ + actionType: Joi.string().only().allow(ACTION_TYPE.FLAG).required(), + reason: Joi.string() + .only() + .allow( + GQLCOMMENT_FLAG_DETECTED_REASON.COMMENT_DETECTED_TOXIC, + GQLCOMMENT_FLAG_DETECTED_REASON.COMMENT_DETECTED_SPAM + ) + .required(), + }) + ), + status: Joi.string() + .only() + .allow(...Object.keys(GQLCOMMENT_STATUS)), + tags: Joi.array().items( + Joi.string().only().allow(GQLTAG.FEATURED, GQLTAG.STAFF).required() + ), +}); + +/** + * validate will validate the `ExternalModerationResponse`. + * + * @param body the input body that is being coerced into an `ExternalModerationResponse`. + */ +export function validateResponse(body: object): ExternalModerationResponse { + const { value, error: err } = ExternalModerationResponseSchema.validate( + body, + { + stripUnknown: true, + presence: "optional", + abortEarly: false, + } + ); + + if (err) { + throw err; + } + + return value; +} + +const fetch = createFetch({ name: "Moderation" }); + +/** + * processPhase will execute the request for moderation for this particular + * phase. + * + * @param ctx the context for the moderation request. + * @param phase the current phase associated with this request. + */ +async function processPhase( + { + mongo, + action, + comment, + htmlStripped, + author, + tenant, + story, + now, + log, + }: IntermediateModerationPhaseContext, + phase: ExternalModerationPhase +) { + // Create the crafted input payload to be used. + const request: ExternalModerationRequest = { + action, + comment: { + body: + // Depending on the selected format, the comment body could be in an + // HTML or HTML stripped format. + phase.format === GQLCOMMENT_BODY_FORMAT.HTML + ? comment.body + : htmlStripped, + // We're casting this to a `string | null` here because it's more + // actionable to get a `null` rather than an undefined value in a + // request. + parentID: comment.parentID || null, + }, + author: { + id: author.id, + role: author.role, + }, + story: { + id: story.id, + url: story.url, + }, + site: { + id: story.siteID, + }, + tenantID: tenant.id, + tenantDomain: tenant.domain, + }; + + // Craft the request options now to use. + const options = generateFetchOptions(phase.signingSecrets, request, now); + + // Send off the request, with the correct timeout. + const res = await fetch(phase.url, { + ...options, + timeout: phase.timeout, + }); + if (!res.ok) { + // The phase did not respond correctly, continue. + log.warn( + { status: res.status, phaseID: phase.id }, + "failed to get moderation response" + ); + return; + } + + // Try to parse the response. + const text = await res.text(); + + // If the moderation phase responded 204, or there was no response from + // the request, just continue. + if (res.status === 204 || text === "" || text === "{}") { + log.debug( + { status: res.status, phaseID: phase.id }, + "empty response received" + ); + return; + } + + // Try to parse the response as JSON. + const body = JSON.parse(text); + + // 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 expiredSigningSecretKIDs = phase.signingSecrets + .filter(filterExpiredSigningSecrets(now)) + .map((s) => s.kid); + if (expiredSigningSecretKIDs.length > 0) { + process.nextTick(() => { + deleteTenantExternalModerationPhaseSigningSecrets( + mongo, + tenant.id, + phase.id, + expiredSigningSecretKIDs + ) + .then(() => { + log.info( + { phaseID: phase.id, kids: expiredSigningSecretKIDs }, + "removed expired secrets from phase" + ); + }) + .catch((err) => { + log.error( + { err }, + "an error occurred when trying to remove expired phase secrets" + ); + }); + }); + } + + // Validate will throw an error if the body does not conform to the + // specification. + return validateResponse(body); +} + +export const external: IntermediateModerationPhase = async (ctx) => { + // Check to see if any custom moderation phases have been defined, if there is + // none, exit now. + if ( + !ctx.tenant.integrations.external || + ctx.tenant.integrations.external.phases.length === 0 + ) { + return; + } + + // Get the enabled phases. + const phases = ctx.tenant.integrations.external.phases.filter( + filterActivePhase() + ); + if (phases.length === 0) { + return; + } + + // Collect the response we're going to make into this partial object. + const result: Partial = {}; + + // Send the input to each of the phases. + for (const phase of phases) { + try { + // Get the response from the phase. + const response = await processPhase(ctx, phase); + if (!response) { + continue; + } + + // Merge the results in. If we're finished, return now! + const finished = mergePhaseResult(response, result); + if (finished) { + return result; + } + } catch (err) { + ctx.log.error( + { err, phaseID: phase.id }, + "failed to process custom moderation phase" + ); + } + } + + return result; +}; diff --git a/src/core/server/services/comments/pipeline/phases/index.ts b/src/core/server/services/comments/pipeline/phases/index.ts index 6507dd2cf..588bde861 100644 --- a/src/core/server/services/comments/pipeline/phases/index.ts +++ b/src/core/server/services/comments/pipeline/phases/index.ts @@ -4,6 +4,7 @@ import { approve } from "./approve"; import { commentingDisabled } from "./commentingDisabled"; import { commentLength } from "./commentLength"; import { detectLinks } from "./detectLinks"; +import { external } from "./external"; import { linkify } from "./linkify"; import { preModerate } from "./preModerate"; import { premodUser } from "./preModerateUser"; @@ -41,4 +42,5 @@ export const moderationPhases: IntermediateModerationPhase[] = [ preModerate, premodUser, premodNewCommenter, + external, ]; diff --git a/src/core/server/services/comments/pipeline/phases/premodNewCommenter.ts b/src/core/server/services/comments/pipeline/phases/premodNewCommenter.ts index d1e34a56f..5c468ea3c 100644 --- a/src/core/server/services/comments/pipeline/phases/premodNewCommenter.ts +++ b/src/core/server/services/comments/pipeline/phases/premodNewCommenter.ts @@ -30,7 +30,6 @@ export const premodNewCommenter = async ({ status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actions: [ { - userID: null, actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_NEW_COMMENTER, metadata: { diff --git a/src/core/server/services/comments/pipeline/phases/recentCommentHistory.ts b/src/core/server/services/comments/pipeline/phases/recentCommentHistory.ts index 4b0daae5e..b7bc97891 100644 --- a/src/core/server/services/comments/pipeline/phases/recentCommentHistory.ts +++ b/src/core/server/services/comments/pipeline/phases/recentCommentHistory.ts @@ -48,7 +48,6 @@ export const recentCommentHistory = async ({ status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actions: [ { - userID: null, actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_RECENT_HISTORY, metadata: { diff --git a/src/core/server/services/comments/pipeline/phases/repeatPost.ts b/src/core/server/services/comments/pipeline/phases/repeatPost.ts index 5a60a33f5..aed849391 100644 --- a/src/core/server/services/comments/pipeline/phases/repeatPost.ts +++ b/src/core/server/services/comments/pipeline/phases/repeatPost.ts @@ -63,7 +63,6 @@ export const repeatPost: IntermediateModerationPhase = async ({ status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actions: [ { - userID: null, actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_REPEAT_POST, }, diff --git a/src/core/server/services/comments/pipeline/phases/spam.ts b/src/core/server/services/comments/pipeline/phases/spam.ts index 0126f2e8f..787929b2a 100644 --- a/src/core/server/services/comments/pipeline/phases/spam.ts +++ b/src/core/server/services/comments/pipeline/phases/spam.ts @@ -105,7 +105,6 @@ export const spam: IntermediateModerationPhase = async ({ status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actions: [ { - userID: null, actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM, }, diff --git a/src/core/server/services/comments/pipeline/phases/staff.ts b/src/core/server/services/comments/pipeline/phases/staff.ts index 78cc4a3be..62ecceb29 100755 --- a/src/core/server/services/comments/pipeline/phases/staff.ts +++ b/src/core/server/services/comments/pipeline/phases/staff.ts @@ -9,16 +9,10 @@ import { GQLTAG } from "coral-server/graph/schema/__generated__/types"; // If a given user is a staff member, always approve their comment. export const staff: IntermediateModerationPhase = ({ author, - now, }): IntermediatePhaseResult | void => { if (hasStaffRole(author)) { return { - tags: [ - { - type: GQLTAG.STAFF, - createdAt: now, - }, - ], + tags: [GQLTAG.STAFF], }; } }; diff --git a/src/core/server/services/comments/pipeline/phases/tagExpertAnswers.ts b/src/core/server/services/comments/pipeline/phases/tagExpertAnswers.ts index d462a02e8..7426c5929 100644 --- a/src/core/server/services/comments/pipeline/phases/tagExpertAnswers.ts +++ b/src/core/server/services/comments/pipeline/phases/tagExpertAnswers.ts @@ -1,4 +1,4 @@ -import { CommentTag } from "coral-server/models/comment/tag"; +import { getDepth } from "coral-server/models/comment"; import { IntermediateModerationPhase, IntermediatePhaseResult, @@ -11,7 +11,6 @@ import { export const tagExpertAnswers: IntermediateModerationPhase = ({ author, - now, story, comment, }): IntermediatePhaseResult | void => { @@ -24,20 +23,12 @@ export const tagExpertAnswers: IntermediateModerationPhase = ({ story.settings.expertIDs.some((id) => id === author.id) ) { // Assign this comment an expert tag! - const tags: CommentTag[] = [ - { - type: GQLTAG.EXPERT, - createdAt: now, - }, - ]; + const tags: GQLTAG[] = [GQLTAG.EXPERT]; // If this comment is the first reply in a thread (depth of 1)... - if (comment.ancestorIDs.length === 1) { + if (getDepth(comment) === 1) { // Add the featured tag! - tags.push({ - type: GQLTAG.FEATURED, - createdAt: now, - }); + tags.push(GQLTAG.FEATURED); } return { tags }; diff --git a/src/core/server/services/comments/pipeline/phases/tagUnansweredQuestions.ts b/src/core/server/services/comments/pipeline/phases/tagUnansweredQuestions.ts index 6db1e7d2b..9789bf32b 100644 --- a/src/core/server/services/comments/pipeline/phases/tagUnansweredQuestions.ts +++ b/src/core/server/services/comments/pipeline/phases/tagUnansweredQuestions.ts @@ -31,12 +31,7 @@ export const tagUnansweredQuestions: IntermediateModerationPhase = ({ story.settings.expertIDs.every((id) => id !== comment.authorID) ) { return { - tags: [ - { - type: GQLTAG.UNANSWERED, - createdAt: now, - }, - ], + tags: [GQLTAG.UNANSWERED], }; } }; diff --git a/src/core/server/services/comments/pipeline/phases/toxic.ts b/src/core/server/services/comments/pipeline/phases/toxic.ts index 1ee663a68..06370cee2 100644 --- a/src/core/server/services/comments/pipeline/phases/toxic.ts +++ b/src/core/server/services/comments/pipeline/phases/toxic.ts @@ -131,7 +131,6 @@ export const toxic: IntermediateModerationPhase = async ({ status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actions: [ { - userID: null, actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC, }, diff --git a/src/core/server/services/comments/pipeline/phases/wordList.ts b/src/core/server/services/comments/pipeline/phases/wordList.ts index 38d9370b9..ee226da4c 100755 --- a/src/core/server/services/comments/pipeline/phases/wordList.ts +++ b/src/core/server/services/comments/pipeline/phases/wordList.ts @@ -35,7 +35,6 @@ export const wordList: IntermediateModerationPhase = ({ status: GQLCOMMENT_STATUS.REJECTED, actions: [ { - userID: null, actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD, }, @@ -53,7 +52,6 @@ export const wordList: IntermediateModerationPhase = ({ return { actions: [ { - userID: null, actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD, }, diff --git a/src/core/server/services/comments/pipeline/pipeline.ts b/src/core/server/services/comments/pipeline/pipeline.ts index f1a7db3d7..6ac3e1a1c 100644 --- a/src/core/server/services/comments/pipeline/pipeline.ts +++ b/src/core/server/services/comments/pipeline/pipeline.ts @@ -9,28 +9,52 @@ import { CreateCommentInput, RevisionMetadata, } from "coral-server/models/comment"; -import { CommentTag } from "coral-server/models/comment/tag"; import { Story } from "coral-server/models/story"; import { Tenant } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; import { AugmentedRedis } from "coral-server/services/redis"; import { Request } from "coral-server/types/express"; -import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types"; +import { + GQLCOMMENT_STATUS, + GQLTAG, +} from "coral-server/graph/schema/__generated__/types"; +import { mergePhaseResult } from "./helpers"; import { moderationPhases } from "./phases"; export type ModerationAction = Omit< CreateActionInput, - "commentID" | "commentRevisionID" | "storyID" | "siteID" + "commentID" | "commentRevisionID" | "storyID" | "siteID" | "userID" >; export interface PhaseResult { + /** + * actions are moderation actions that are added to the comment revision. + */ actions: ModerationAction[]; + + /** + * status when provided decides and terminates the moderation process by + * setting the status of the comment. + */ status: GQLCOMMENT_STATUS; + + /** + * metadata should be added to the comment revision when it is created/edited. + */ metadata: RevisionMetadata; + + /** + * body when returned should replace the comment body as it is currently. + */ body: string; - tags: CommentTag[]; + + /** + * tags should be added to the comment when it is created. Tags are not added + * when a comment is edited. + */ + tags: GQLTAG[]; } export interface ModerationPhaseContextInput { @@ -64,7 +88,7 @@ export type IntermediatePhaseResult = Partial | void; export interface IntermediateModerationPhaseContext extends ModerationPhaseContext { metadata: RevisionMetadata; - tags: CommentTag[]; + tags: GQLTAG[]; } export type IntermediateModerationPhase = ( @@ -109,46 +133,10 @@ export const compose = ( metadata: final.metadata, }); if (result) { - // If this result contained actions, then we should push it into the - // other actions. - const { actions } = result; - if (actions) { - final.actions.push(...actions); - } - - // If this result contained metadata, then we should merge it into the - // other metadata. - const { metadata } = result; - if (metadata) { - final.metadata = { - ...final.metadata, - ...metadata, - }; - } - - // If the result modified the comment body, we should replace it. - const { body } = result; - if (body) { - final.body = body; - } - - // If the result added any tags, we should push it into the existing tags. - const { tags } = result; - if (tags && tags.length > 0) { - final.tags.push( - // Only push in tags that we haven't already added. - ...tags.filter( - ({ type }) => !final.tags.some((tag) => tag.type === type) - ) - ); - } - - // If this result contained a status, then we've finished resolving - // phases! - const { status } = result; - if (status) { - final.status = status; - break; + // Merge the results in. If we're finished, break now! + const finished = mergePhaseResult(result, final); + if (finished) { + return final; } } } diff --git a/src/core/server/services/events/comments.ts b/src/core/server/services/events/comments.ts index 47cec8d45..d547d1b81 100644 --- a/src/core/server/services/events/comments.ts +++ b/src/core/server/services/events/comments.ts @@ -11,6 +11,7 @@ import { CoralEventPublisherBroker } from "coral-server/events/publisher"; import { Comment, CommentModerationQueueCounts, + getDepth, hasPublishedStatus, } from "coral-server/models/comment"; @@ -42,7 +43,7 @@ export async function publishCommentReplyCreated( broker: CoralEventPublisherBroker, comment: Pick ) { - if (comment.ancestorIDs.length > 0 && hasPublishedStatus(comment)) { + if (getDepth(comment) > 0 && hasPublishedStatus(comment)) { await CommentReplyCreatedCoralEvent.publish(broker, { ancestorIDs: comment.ancestorIDs, commentID: comment.id, diff --git a/src/core/server/services/fetch/fetch.ts b/src/core/server/services/fetch/fetch.ts index 005839111..6137d965f 100644 --- a/src/core/server/services/fetch/fetch.ts +++ b/src/core/server/services/fetch/fetch.ts @@ -5,20 +5,15 @@ import fetch, { RequestInit, Response } from "node-fetch"; import { URL } from "url"; import { version } from "coral-common/version"; +import { + generateSignatures, + SigningSecret, +} from "coral-server/models/settings"; 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 @@ -27,7 +22,45 @@ export type FetchOptions = RequestInit & { timeout?: number; }; -export const createFetch = ({ name }: CreateFetchOptions): Fetch => { +export interface CreateFetchOptions { + /** + * name is the string that is attached to the `User-Agent` header as: + * + * `Coral ${name}/${version}` + */ + name: string; + + /** + * options to provide defaults for requests made using this fetcher. + */ + options?: Omit; +} + +export function generateFetchOptions( + signingSecrets: SigningSecret[], + data: object, + now: Date +): FetchOptions { + // Serialize the body and signature to include in the request. + const body = JSON.stringify(data, null, 2); + const signature = generateSignatures(signingSecrets, body, now); + + const headers: Record = { + "Content-Type": "application/json", + "X-Coral-Signature": signature, + }; + + return { + method: "POST", + headers, + body, + }; +} + +export const createFetch = ({ + name, + options: { headers: defaultBaseHeaders = {}, ...defaultOptions } = {}, +}: CreateFetchOptions): Fetch => { // Create HTTP agents to improve connection performance. const agents = { https: new https.Agent({ @@ -46,6 +79,7 @@ export const createFetch = ({ name }: CreateFetchOptions): Fetch => { // overridden). const defaultHeaders = { "User-Agent": `Coral ${capitalize(name)}/${version}`, + ...defaultBaseHeaders, }; // Return the actual fetcher that just uses fetch under the hood. @@ -69,10 +103,17 @@ export const createFetch = ({ name }: CreateFetchOptions): Fetch => { ...defaultHeaders, ...headers, }, + // Limit response sizes to 2MB of response data. 1e6B is 1MB. + size: 2e6, + // Do not follow redirects automatically, and do not error if we + // encounter one. We'll treat the response from the request as the + // endpoints final response. + redirect: "manual", // Attach the controller signal to abort the request after the timeout // is reached. signal: abort.controller.signal, // Merge in the passed options. + ...defaultOptions, ...options, }); diff --git a/src/core/server/services/migrate/manager.ts b/src/core/server/services/migrate/manager.ts index 6a0f73057..8fdff7f17 100644 --- a/src/core/server/services/migrate/manager.ts +++ b/src/core/server/services/migrate/manager.ts @@ -14,7 +14,7 @@ import { startMigration, } from "coral-server/models/migration"; import { I18n } from "coral-server/services/i18n"; -import TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; import { FailedMigrationDetectedError, diff --git a/src/core/server/services/migrate/migrations/1582929716101_sso_secrets.ts b/src/core/server/services/migrate/migrations/1582929716101_sso_secrets.ts new file mode 100644 index 000000000..8e61be6a9 --- /dev/null +++ b/src/core/server/services/migrate/migrations/1582929716101_sso_secrets.ts @@ -0,0 +1,17 @@ +import { Db } from "mongodb"; + +import Migration from "coral-server/services/migrate/migration"; +import collections from "coral-server/services/mongodb/collections"; + +export default class extends Migration { + public async up(mongo: Db, tenantID: string) { + await collections.tenants(mongo).updateOne( + { id: tenantID }, + { + $rename: { + "auth.integrations.sso.keys": "auth.integrations.sso.signingSecrets", + }, + } + ); + } +} diff --git a/src/core/server/services/mongodb/index.ts b/src/core/server/services/mongodb/index.ts index d3552eb28..19356f6a7 100644 --- a/src/core/server/services/mongodb/index.ts +++ b/src/core/server/services/mongodb/index.ts @@ -1,7 +1,7 @@ import { Db, MongoClient } from "mongodb"; import { Config } from "coral-server/config"; -import { InternalError } from "coral-server/errors"; +import { WrappedInternalError } from "coral-server/errors"; export async function createMongoClient(config: Config): Promise { try { @@ -10,7 +10,7 @@ export async function createMongoClient(config: Config): Promise { ignoreUndefined: true, }); } catch (err) { - throw new InternalError(err, "could not connect to mongodb"); + throw new WrappedInternalError(err, "could not connect to mongodb"); } } diff --git a/src/core/server/services/redis/index.ts b/src/core/server/services/redis/index.ts index 1f8080eb7..76df946fe 100644 --- a/src/core/server/services/redis/index.ts +++ b/src/core/server/services/redis/index.ts @@ -1,7 +1,7 @@ import RedisClient, { Pipeline, Redis } from "ioredis"; import { Config } from "coral-server/config"; -import { InternalError } from "coral-server/errors"; +import { WrappedInternalError } from "coral-server/errors"; import logger from "coral-server/logger"; export interface AugmentedRedisCommands { @@ -52,7 +52,7 @@ export function createRedisClient(config: Config, lazyConnect = false): Redis { return redis; } catch (err) { - throw new InternalError(err, "could not connect to redis"); + throw new WrappedInternalError(err, "could not connect to redis"); } } @@ -73,6 +73,6 @@ export async function createAugmentedRedisClient( return redis; } catch (err) { - throw new InternalError(err, "could not connect to redis"); + throw new WrappedInternalError(err, "could not connect to redis"); } } diff --git a/src/core/server/services/stories/scraper/scraper.ts b/src/core/server/services/stories/scraper/scraper.ts index 56b2fa9ef..e921037ee 100644 --- a/src/core/server/services/stories/scraper/scraper.ts +++ b/src/core/server/services/stories/scraper/scraper.ts @@ -1,4 +1,3 @@ -import Logger from "bunyan"; import cheerio from "cheerio"; import authorScraper from "metascraper-author"; import descriptionScraper from "metascraper-description"; @@ -12,7 +11,7 @@ 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 { createFetch, FetchOptions } from "coral-server/services/fetch"; import { GQLStoryMetadata } from "coral-server/graph/schema/__generated__/types"; @@ -27,15 +26,21 @@ export type Rule = Record< > >; +interface ScrapeOptions { + url: string; + timeout: number; + size: number; + customUserAgent?: string; + proxyURL?: string; +} + class Scraper { private readonly rules: Rule[]; - private readonly log: Logger; - private readonly fetch: Fetch; + private readonly log = logger.child({ taskName: "scraper" }, true); + private readonly fetch = createFetch({ name: "Scraper" }); constructor(rules: Rule[]) { - this.fetch = createFetch({ name: "Scraper" }); this.rules = rules; - this.log = logger.child({ taskName: "scraper" }, true); } public parse(url: string, html: string): GQLStoryMetadata { @@ -83,15 +88,15 @@ class Scraper { }; } - public async download( - url: string, - timeout: number, - customUserAgent?: string, - proxyURL?: string - ) { + public async download({ + url, + timeout, + customUserAgent, + proxyURL, + }: ScrapeOptions) { const log = this.log.child({ storyURL: url }, true); - const options: FetchOptions = { timeout }; + const options: FetchOptions = { method: "GET", timeout }; if (customUserAgent) { options.headers = { ...options.headers, @@ -131,22 +136,14 @@ class Scraper { } public async scrape( - url: string, - abortAfterMilliseconds: number, - customUserAgent?: string, - proxyURL?: string + options: ScrapeOptions ): Promise { - const html = await this.download( - url, - abortAfterMilliseconds, - customUserAgent, - proxyURL - ); + const html = await this.download(options); if (!html) { return null; } - return this.parse(url, html); + return this.parse(options.url, html); } } @@ -195,14 +192,16 @@ 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 timeout = (config.get("scrape_timeout") as unknown) as number; + const size = config.get("scrape_max_response_size"); // Get the metadata from the scraped html. - const metadata = await scraper.scrape( - storyURL, + const metadata = await scraper.scrape({ + url: storyURL, timeout, - tenant.stories.scraping.customUserAgent, - tenant.stories.scraping.proxyURL - ); + size, + customUserAgent: tenant.stories.scraping.customUserAgent, + proxyURL: tenant.stories.scraping.proxyURL, + }); if (!metadata) { throw new Error("story at specified url not found"); } diff --git a/src/core/server/services/tenant/cache/adapter.ts b/src/core/server/services/tenant/cache/adapter.ts index 1797ff98f..1955386bf 100644 --- a/src/core/server/services/tenant/cache/adapter.ts +++ b/src/core/server/services/tenant/cache/adapter.ts @@ -1,4 +1,4 @@ -import TenantCache from "coral-server/services/tenant/cache"; +import TenantCache from "./cache"; export type DeconstructionFn = (tenantID: string, value: T) => Promise; @@ -8,7 +8,7 @@ export type DeconstructionFn = (tenantID: string, value: T) => Promise; * tenants are enabled, this acts as a map to store entries, and will * automatically invalidate tenants that have been updated. */ -export class TenantCacheAdapter { +export default class TenantCacheAdapter { private readonly cache = new Map(); private readonly tenantCache: TenantCache; diff --git a/src/core/server/services/tenant/cache/cache.ts b/src/core/server/services/tenant/cache/cache.ts new file mode 100644 index 000000000..771ac6daf --- /dev/null +++ b/src/core/server/services/tenant/cache/cache.ts @@ -0,0 +1,376 @@ +import DataLoader from "dataloader"; +import { EventEmitter } from "events"; +import { Redis } from "ioredis"; +import { Db } from "mongodb"; +import { v4 as uuid } from "uuid"; + +import { Config } from "coral-server/config"; +import logger from "coral-server/logger"; +import { + countTenants, + retrieveAllTenants, + retrieveManyTenants, + retrieveManyTenantsByDomain, + Tenant, +} from "coral-server/models/tenant"; + +const TENANT_CACHE_CHANNEL = "TENANT_CACHE_CHANNEL"; + +enum EVENTS { + UPDATE = "UPDATE", + DELETE = "DELETE", +} + +type UpdateSubscribeCallback = (tenant: Tenant) => void; +type DeleteSubscribeCallback = (tenantID: string, tenantDomain: string) => void; + +type Message = UpdateMessage | DeleteMessage; + +interface DeleteMessage { + event: EVENTS.DELETE; + tenantID: string; + tenantDomain: string; + clientApplicationID: string; +} + +interface UpdateMessage { + event: EVENTS.UPDATE; + tenant: Tenant; + clientApplicationID: string; +} + +/** + * MessageData is a type that is used to select only the data parts of the + * message. + */ +type MessageData = Omit; + +// TenantCache provides an interface for retrieving tenant stored in local +// memory rather than grabbing it from the database every single call. +export default class TenantCache { + /** + * tenantsByID reference the tenants that have been cached/retrieved by ID. + */ + private readonly tenantsByID: DataLoader | null>; + + /** + * tenantsByDomain reference the tenants that have been cached/retrieved by + * Domain. + */ + private readonly tenantsByDomain: DataLoader | null>; + + /** + * tenantCountCache stores all the id's of all the Tenant's that have crossed + * it. + */ + private readonly tenantCountCache = new Set(); + + /** + * primed is true when the cache has already been fully primed. + */ + private primed = false; + + /** + * Create a new client application ID. This prevents duplicated messages + * generated by this application from being handled as external messages + * as we should have already processed it. + */ + private readonly clientApplicationID = uuid(); + + private readonly mongo: Db; + private readonly emitter = new EventEmitter(); + + /** + * cachingEnabled is true when tenant caching has been enabled. + */ + public readonly cachingEnabled: boolean; + + constructor(mongo: Db, subscriber: Redis, config: Config) { + this.cachingEnabled = !config.get("disable_tenant_caching"); + if (!this.cachingEnabled) { + logger.warn("tenant caching is disabled"); + } else { + logger.debug("tenant caching is enabled"); + } + + // Save the Db reference. + this.mongo = mongo; + + // Configure the data loaders. + this.tenantsByID = new DataLoader( + async (ids) => { + logger.debug({ ids: ids.length }, "now loading tenants"); + const tenants = await retrieveManyTenants(this.mongo, ids); + logger.debug( + { tenants: tenants.filter((t) => t !== null).length }, + "loaded tenants" + ); + + tenants + .filter((t) => t !== null) + .forEach((t: Readonly) => this.tenantCountCache.add(t.id)); + + return tenants; + }, + { + cache: this.cachingEnabled, + } + ); + + this.tenantsByDomain = new DataLoader( + async (domains) => { + logger.debug({ domains: domains.length }, "now loading tenants"); + const tenants = await retrieveManyTenantsByDomain(this.mongo, domains); + logger.debug( + { tenants: tenants.filter((t) => t !== null).length }, + "loaded tenants" + ); + + tenants + .filter((t) => t !== null) + .forEach((t: Readonly) => this.tenantCountCache.add(t.id)); + + return tenants; + }, + { + cache: this.cachingEnabled, + } + ); + + // We don't need updates if we aren't synced to tenant updates. + if (this.cachingEnabled) { + // Attach to messages on this connection so we can receive updates when + // the tenant are changed. + subscriber.on("message", this.onMessage); + + // Subscribe to tenant notifications. + subscriber.subscribe(TENANT_CACHE_CHANNEL); + } + } + + /** + * count will return the number of Tenant's. + */ + public async count(): Promise { + if (!this.cachingEnabled) { + return countTenants(this.mongo); + } + + if (!this.primed) { + await this.primeAll(); + } + + return this.tenantCountCache.size; + } + + /** + * primeAll will load all the tenants into the cache on startup. + */ + public async primeAll() { + if (!this.cachingEnabled) { + logger.debug("tenants not primed, caching disabled"); + return; + } + + // Grab all the tenants for this node. + const tenants = await retrieveAllTenants(this.mongo); + + // Clear out all the items in the cache. + this.tenantsByID.clearAll(); + this.tenantsByDomain.clearAll(); + this.tenantCountCache.clear(); + + // Prime the cache with each of these tenants. + tenants.forEach((tenant) => { + this.tenantsByID.prime(tenant.id, tenant); + this.tenantsByDomain.prime(tenant.domain, tenant); + this.tenantCountCache.add(tenant.id); + }); + + logger.debug({ tenants: tenants.length }, "primed all tenants"); + this.primed = true; + } + + /** + * Symbol.asyncIterator implements the asyncIterator interface for the + * TenantCache. This allows you to use the TenantCache as a asyncIterator with + * a `for await (const tenant of tenants) {}` pattern to iterate over all the + * tenant's on the cache. If the cache is cacheable, and not primed, the cache + * will be primed at the first async iteration process. If caching is + * disabled, then the tenants will bne loaded on demand and not persisted + * after the iteration. + */ + public async *[Symbol.asyncIterator]() { + // If the cache isn't primed, and caching is enabled, then prime the cache + // now, as this will increase performance dramatically. + if (!this.primed && this.cachingEnabled) { + await this.primeAll(); + } + + // Copy the tenant count cache to prevent race conditions related to + // clearing during iteration. + const cache = new Set(this.tenantCountCache); + + // If the tenant's are primed in the cache, then just use the count cache as + // the iteration source. + if (this.primed) { + for (const tenantID of cache) { + const tenant = await this.tenantsByID.load(tenantID); + if (!tenant) { + continue; + } + + yield tenant; + } + + return; + } + + // Caching must be disabled, so just grab all the tenants for this node and + // iterate through each of them as we handle it. + const tenants = await retrieveAllTenants(this.mongo); + for (const tenant of tenants) { + yield tenant; + } + } + + private onUpdateMessage({ tenant }: MessageData) { + // Update the tenant cache. + this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant); + this.tenantsByDomain.clear(tenant.domain).prime(tenant.domain, tenant); + this.tenantCountCache.add(tenant.id); + + // Publish the event for the connected listeners. + this.emitter.emit(EVENTS.UPDATE, tenant); + } + + private onDeleteMessage({ + tenantID, + tenantDomain, + }: MessageData) { + // Delete the tenant in the local cache. + this.tenantsByID.clear(tenantID); + this.tenantsByDomain.clear(tenantDomain); + this.tenantCountCache.delete(tenantID); + + // Publish the event for the connected listeners. + this.emitter.emit(EVENTS.DELETE, tenantID, tenantDomain); + } + + /** + * onMessage is fired every time the client gets a subscription event. + */ + private onMessage = async (channel: string, data: string): Promise => { + // Only do things when the message is for tenant. + if (channel !== TENANT_CACHE_CHANNEL) { + return; + } + + try { + // Parse the message (which is JSON). + const message: Message = JSON.parse(data); + + // Extract some known parameters. + const { clientApplicationID } = message; + + // Check to see if this was the update issued by this instance. + if (clientApplicationID === this.clientApplicationID) { + // It was, so just return here, we already updated/handled it. + return; + } + + const log = logger.child({ eventName: message.event }, true); + log.debug("received tenant message"); + + // Send the message to the correct handler. + switch (message.event) { + case EVENTS.UPDATE: + return this.onUpdateMessage(message); + case EVENTS.DELETE: + return this.onDeleteMessage(message); + default: + log.warn("received unknown event"); + return; + } + } catch (err) { + logger.error( + { err }, + "an error occurred while trying to handle a message" + ); + } + }; + + public async retrieveByID(id: string): Promise | null> { + return this.tenantsByID.load(id); + } + + public async retrieveByDomain( + domain: string + ): Promise | null> { + return this.tenantsByDomain.load(domain); + } + + /** + * This allows you to subscribe to new Tenant updates. This will also return + * a function that when called, unsubscribes you from updates. + * + * @param updateCallback the function to be called when there is an updated Tenant. + * @param deleteCallback the function to be called when a tenant needs to be purged + */ + public subscribe( + updateCallback: UpdateSubscribeCallback, + deleteCallback: DeleteSubscribeCallback + ) { + this.emitter.on(EVENTS.UPDATE, updateCallback); + this.emitter.on(EVENTS.DELETE, deleteCallback); + + // Return the unsubscribe function. + return () => { + this.emitter.removeListener(EVENTS.UPDATE, updateCallback); + this.emitter.removeListener(EVENTS.DELETE, deleteCallback); + }; + } + + private async publish(tenantID: string, conn: Redis, message: Message) { + const subscribers = await conn.publish( + TENANT_CACHE_CHANNEL, + JSON.stringify(message) + ); + logger.debug( + { tenantID, subscribers, eventName: message.event }, + "updated tenant in cache" + ); + } + + /** + * update will update the value for Tenant in the local cache and publish + * a change notification that will be used to keep the other nodes in sync. + * + * @param conn a redis connection used to publish the change notification + * @param tenant the updated Tenant object + */ + public async update(conn: Redis, tenant: Tenant): Promise { + // Process the tenant update on this node. + this.onUpdateMessage({ tenant }); + + // Notify the other nodes about the tenant change. + await this.publish(tenant.id, conn, { + event: EVENTS.UPDATE, + tenant, + clientApplicationID: this.clientApplicationID, + }); + } + + public async delete(conn: Redis, tenantID: string, tenantDomain: string) { + // Process the tenant update on this node. + this.onDeleteMessage({ tenantID, tenantDomain }); + + // Notify the other nodes about the tenant change. + await this.publish(tenantID, conn, { + event: EVENTS.DELETE, + tenantID, + tenantDomain, + clientApplicationID: this.clientApplicationID, + }); + } +} diff --git a/src/core/server/services/tenant/cache/index.ts b/src/core/server/services/tenant/cache/index.ts index 771ac6daf..2f2a604a5 100644 --- a/src/core/server/services/tenant/cache/index.ts +++ b/src/core/server/services/tenant/cache/index.ts @@ -1,376 +1,2 @@ -import DataLoader from "dataloader"; -import { EventEmitter } from "events"; -import { Redis } from "ioredis"; -import { Db } from "mongodb"; -import { v4 as uuid } from "uuid"; - -import { Config } from "coral-server/config"; -import logger from "coral-server/logger"; -import { - countTenants, - retrieveAllTenants, - retrieveManyTenants, - retrieveManyTenantsByDomain, - Tenant, -} from "coral-server/models/tenant"; - -const TENANT_CACHE_CHANNEL = "TENANT_CACHE_CHANNEL"; - -enum EVENTS { - UPDATE = "UPDATE", - DELETE = "DELETE", -} - -type UpdateSubscribeCallback = (tenant: Tenant) => void; -type DeleteSubscribeCallback = (tenantID: string, tenantDomain: string) => void; - -type Message = UpdateMessage | DeleteMessage; - -interface DeleteMessage { - event: EVENTS.DELETE; - tenantID: string; - tenantDomain: string; - clientApplicationID: string; -} - -interface UpdateMessage { - event: EVENTS.UPDATE; - tenant: Tenant; - clientApplicationID: string; -} - -/** - * MessageData is a type that is used to select only the data parts of the - * message. - */ -type MessageData = Omit; - -// TenantCache provides an interface for retrieving tenant stored in local -// memory rather than grabbing it from the database every single call. -export default class TenantCache { - /** - * tenantsByID reference the tenants that have been cached/retrieved by ID. - */ - private readonly tenantsByID: DataLoader | null>; - - /** - * tenantsByDomain reference the tenants that have been cached/retrieved by - * Domain. - */ - private readonly tenantsByDomain: DataLoader | null>; - - /** - * tenantCountCache stores all the id's of all the Tenant's that have crossed - * it. - */ - private readonly tenantCountCache = new Set(); - - /** - * primed is true when the cache has already been fully primed. - */ - private primed = false; - - /** - * Create a new client application ID. This prevents duplicated messages - * generated by this application from being handled as external messages - * as we should have already processed it. - */ - private readonly clientApplicationID = uuid(); - - private readonly mongo: Db; - private readonly emitter = new EventEmitter(); - - /** - * cachingEnabled is true when tenant caching has been enabled. - */ - public readonly cachingEnabled: boolean; - - constructor(mongo: Db, subscriber: Redis, config: Config) { - this.cachingEnabled = !config.get("disable_tenant_caching"); - if (!this.cachingEnabled) { - logger.warn("tenant caching is disabled"); - } else { - logger.debug("tenant caching is enabled"); - } - - // Save the Db reference. - this.mongo = mongo; - - // Configure the data loaders. - this.tenantsByID = new DataLoader( - async (ids) => { - logger.debug({ ids: ids.length }, "now loading tenants"); - const tenants = await retrieveManyTenants(this.mongo, ids); - logger.debug( - { tenants: tenants.filter((t) => t !== null).length }, - "loaded tenants" - ); - - tenants - .filter((t) => t !== null) - .forEach((t: Readonly) => this.tenantCountCache.add(t.id)); - - return tenants; - }, - { - cache: this.cachingEnabled, - } - ); - - this.tenantsByDomain = new DataLoader( - async (domains) => { - logger.debug({ domains: domains.length }, "now loading tenants"); - const tenants = await retrieveManyTenantsByDomain(this.mongo, domains); - logger.debug( - { tenants: tenants.filter((t) => t !== null).length }, - "loaded tenants" - ); - - tenants - .filter((t) => t !== null) - .forEach((t: Readonly) => this.tenantCountCache.add(t.id)); - - return tenants; - }, - { - cache: this.cachingEnabled, - } - ); - - // We don't need updates if we aren't synced to tenant updates. - if (this.cachingEnabled) { - // Attach to messages on this connection so we can receive updates when - // the tenant are changed. - subscriber.on("message", this.onMessage); - - // Subscribe to tenant notifications. - subscriber.subscribe(TENANT_CACHE_CHANNEL); - } - } - - /** - * count will return the number of Tenant's. - */ - public async count(): Promise { - if (!this.cachingEnabled) { - return countTenants(this.mongo); - } - - if (!this.primed) { - await this.primeAll(); - } - - return this.tenantCountCache.size; - } - - /** - * primeAll will load all the tenants into the cache on startup. - */ - public async primeAll() { - if (!this.cachingEnabled) { - logger.debug("tenants not primed, caching disabled"); - return; - } - - // Grab all the tenants for this node. - const tenants = await retrieveAllTenants(this.mongo); - - // Clear out all the items in the cache. - this.tenantsByID.clearAll(); - this.tenantsByDomain.clearAll(); - this.tenantCountCache.clear(); - - // Prime the cache with each of these tenants. - tenants.forEach((tenant) => { - this.tenantsByID.prime(tenant.id, tenant); - this.tenantsByDomain.prime(tenant.domain, tenant); - this.tenantCountCache.add(tenant.id); - }); - - logger.debug({ tenants: tenants.length }, "primed all tenants"); - this.primed = true; - } - - /** - * Symbol.asyncIterator implements the asyncIterator interface for the - * TenantCache. This allows you to use the TenantCache as a asyncIterator with - * a `for await (const tenant of tenants) {}` pattern to iterate over all the - * tenant's on the cache. If the cache is cacheable, and not primed, the cache - * will be primed at the first async iteration process. If caching is - * disabled, then the tenants will bne loaded on demand and not persisted - * after the iteration. - */ - public async *[Symbol.asyncIterator]() { - // If the cache isn't primed, and caching is enabled, then prime the cache - // now, as this will increase performance dramatically. - if (!this.primed && this.cachingEnabled) { - await this.primeAll(); - } - - // Copy the tenant count cache to prevent race conditions related to - // clearing during iteration. - const cache = new Set(this.tenantCountCache); - - // If the tenant's are primed in the cache, then just use the count cache as - // the iteration source. - if (this.primed) { - for (const tenantID of cache) { - const tenant = await this.tenantsByID.load(tenantID); - if (!tenant) { - continue; - } - - yield tenant; - } - - return; - } - - // Caching must be disabled, so just grab all the tenants for this node and - // iterate through each of them as we handle it. - const tenants = await retrieveAllTenants(this.mongo); - for (const tenant of tenants) { - yield tenant; - } - } - - private onUpdateMessage({ tenant }: MessageData) { - // Update the tenant cache. - this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant); - this.tenantsByDomain.clear(tenant.domain).prime(tenant.domain, tenant); - this.tenantCountCache.add(tenant.id); - - // Publish the event for the connected listeners. - this.emitter.emit(EVENTS.UPDATE, tenant); - } - - private onDeleteMessage({ - tenantID, - tenantDomain, - }: MessageData) { - // Delete the tenant in the local cache. - this.tenantsByID.clear(tenantID); - this.tenantsByDomain.clear(tenantDomain); - this.tenantCountCache.delete(tenantID); - - // Publish the event for the connected listeners. - this.emitter.emit(EVENTS.DELETE, tenantID, tenantDomain); - } - - /** - * onMessage is fired every time the client gets a subscription event. - */ - private onMessage = async (channel: string, data: string): Promise => { - // Only do things when the message is for tenant. - if (channel !== TENANT_CACHE_CHANNEL) { - return; - } - - try { - // Parse the message (which is JSON). - const message: Message = JSON.parse(data); - - // Extract some known parameters. - const { clientApplicationID } = message; - - // Check to see if this was the update issued by this instance. - if (clientApplicationID === this.clientApplicationID) { - // It was, so just return here, we already updated/handled it. - return; - } - - const log = logger.child({ eventName: message.event }, true); - log.debug("received tenant message"); - - // Send the message to the correct handler. - switch (message.event) { - case EVENTS.UPDATE: - return this.onUpdateMessage(message); - case EVENTS.DELETE: - return this.onDeleteMessage(message); - default: - log.warn("received unknown event"); - return; - } - } catch (err) { - logger.error( - { err }, - "an error occurred while trying to handle a message" - ); - } - }; - - public async retrieveByID(id: string): Promise | null> { - return this.tenantsByID.load(id); - } - - public async retrieveByDomain( - domain: string - ): Promise | null> { - return this.tenantsByDomain.load(domain); - } - - /** - * This allows you to subscribe to new Tenant updates. This will also return - * a function that when called, unsubscribes you from updates. - * - * @param updateCallback the function to be called when there is an updated Tenant. - * @param deleteCallback the function to be called when a tenant needs to be purged - */ - public subscribe( - updateCallback: UpdateSubscribeCallback, - deleteCallback: DeleteSubscribeCallback - ) { - this.emitter.on(EVENTS.UPDATE, updateCallback); - this.emitter.on(EVENTS.DELETE, deleteCallback); - - // Return the unsubscribe function. - return () => { - this.emitter.removeListener(EVENTS.UPDATE, updateCallback); - this.emitter.removeListener(EVENTS.DELETE, deleteCallback); - }; - } - - private async publish(tenantID: string, conn: Redis, message: Message) { - const subscribers = await conn.publish( - TENANT_CACHE_CHANNEL, - JSON.stringify(message) - ); - logger.debug( - { tenantID, subscribers, eventName: message.event }, - "updated tenant in cache" - ); - } - - /** - * update will update the value for Tenant in the local cache and publish - * a change notification that will be used to keep the other nodes in sync. - * - * @param conn a redis connection used to publish the change notification - * @param tenant the updated Tenant object - */ - public async update(conn: Redis, tenant: Tenant): Promise { - // Process the tenant update on this node. - this.onUpdateMessage({ tenant }); - - // Notify the other nodes about the tenant change. - await this.publish(tenant.id, conn, { - event: EVENTS.UPDATE, - tenant, - clientApplicationID: this.clientApplicationID, - }); - } - - public async delete(conn: Redis, tenantID: string, tenantDomain: string) { - // Process the tenant update on this node. - this.onDeleteMessage({ tenantID, tenantDomain }); - - // Notify the other nodes about the tenant change. - await this.publish(tenantID, conn, { - event: EVENTS.DELETE, - tenantID, - tenantDomain, - clientApplicationID: this.clientApplicationID, - }); - } -} +export { default as TenantCache } from "./cache"; +export { default as TenantCacheAdapter } from "./adapter"; diff --git a/src/core/server/services/tenant/externalModerationPhases.ts b/src/core/server/services/tenant/externalModerationPhases.ts new file mode 100644 index 000000000..0b6e298bf --- /dev/null +++ b/src/core/server/services/tenant/externalModerationPhases.ts @@ -0,0 +1,322 @@ +import { Redis } from "ioredis"; +import { DateTime } from "luxon"; +import { Db } from "mongodb"; + +import { Config } from "coral-server/config"; +import { getExternalModerationPhase } from "coral-server/models/settings"; +import { + createTenantExternalModerationPhase, + CreateTenantExternalModerationPhaseInput, + deleteTenantExternalModerationPhase, + rotateTenantExternalModerationPhaseSigningSecret, + Tenant, + updateTenantExternalModerationPhase, + UpdateTenantExternalModerationPhaseInput, +} from "coral-server/models/tenant"; + +import { TenantCache } from "./cache"; + +interface ExternalModerationPhaseInput { + url: string; + timeout: number; +} + +export function validateExternalModerationPhaseInput( + config: Config, + input: ExternalModerationPhaseInput +) { + // 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}`); + } + + // Check to see if the timeout value is within range. + if (input.timeout < 100) { + throw new Error("timeout value too low"); + } else if (input.timeout > 10000) { + throw new Error("timeout value too high"); + } +} + +export async function createExternalModerationPhase( + mongo: Db, + redis: Redis, + config: Config, + cache: TenantCache, + tenant: Tenant, + input: CreateTenantExternalModerationPhaseInput, + now: Date +) { + // Validate the input. + validateExternalModerationPhaseInput(config, input); + + // Looks good in create this, send it off to be created. + const result = await createTenantExternalModerationPhase( + mongo, + tenant.id, + input, + now + ); + if (!result.tenant) { + throw new Error("could not create the tenant phase, tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, result.tenant); + + return { + phase: result.phase, + settings: result.tenant, + }; +} + +export async function updateExternalModerationPhase( + mongo: Db, + redis: Redis, + config: Config, + cache: TenantCache, + tenant: Tenant, + phaseID: string, + input: UpdateTenantExternalModerationPhaseInput +) { + // Find the phase. + if (!tenant.integrations.external) { + throw new Error( + "referenced phase was not found on tenant, none configured" + ); + } + + let phase = getExternalModerationPhase(tenant.integrations.external, phaseID); + if (!phase) { + throw new Error("referenced phase was not found on tenant"); + } + + // Extract the input. + const { url = phase.url, timeout = phase.timeout } = input; + + // Validate the input. + validateExternalModerationPhaseInput(config, { + url, + timeout, + }); + + const updatedTenant = await updateTenantExternalModerationPhase( + mongo, + tenant.id, + phaseID, + input + ); + if (!updatedTenant) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + // Find the updated phase. + phase = getExternalModerationPhase( + // We know that `external` is provided because we already verified it earlier. + updatedTenant.integrations.external!, + phaseID + ); + if (!phase) { + throw new Error("referenced phase was not found on tenant"); + } + + return phase; +} + +export async function deleteExternalModerationPhase( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + phaseID: string +) { + // Find the phase. + if (!tenant.integrations.external) { + throw new Error( + "referenced phase was not found on tenant, none configured" + ); + } + const phase = getExternalModerationPhase( + tenant.integrations.external, + phaseID + ); + if (!phase) { + throw new Error("referenced phase was not found on tenant"); + } + + const updatedTenant = await deleteTenantExternalModerationPhase( + mongo, + tenant.id, + phaseID + ); + if (!updatedTenant) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + return phase; +} + +export async function enableExternalModerationPhase( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + phaseID: string +) { + // Find the phase. + if (!tenant.integrations.external) { + throw new Error( + "referenced phase was not found on tenant, none configured" + ); + } + + let phase = getExternalModerationPhase(tenant.integrations.external, phaseID); + if (!phase) { + throw new Error("referenced phase was not found on tenant"); + } + + // Phase is already enabled. + if (phase.enabled === true) { + return phase; + } + + const updatedTenant = await updateTenantExternalModerationPhase( + mongo, + tenant.id, + phaseID, + { enabled: true } + ); + if (!updatedTenant) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + // Find the updated phase. + phase = getExternalModerationPhase( + // We know that `external` is provided because we already verified it earlier. + updatedTenant.integrations.external!, + phaseID + ); + if (!phase) { + throw new Error("referenced phase was not found on tenant"); + } + + return phase; +} + +export async function disableExternalModerationPhase( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + phaseID: string +) { + // Find the phase. + if (!tenant.integrations.external) { + throw new Error( + "referenced phase was not found on tenant, none configured" + ); + } + let phase = getExternalModerationPhase(tenant.integrations.external, phaseID); + if (!phase) { + throw new Error("referenced phase was not found on tenant"); + } + + // Phase is already disabled. + if (phase.enabled === false) { + return phase; + } + + const updatedTenant = await updateTenantExternalModerationPhase( + mongo, + tenant.id, + phaseID, + { enabled: false } + ); + if (!updatedTenant) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + // Find the updated phase. + phase = getExternalModerationPhase( + // We know that `external` is provided because we already verified it earlier. + updatedTenant.integrations.external!, + phaseID + ); + if (!phase) { + throw new Error("referenced phase was not found on tenant"); + } + + return phase; +} + +export async function rotateExternalModerationPhaseSigningSecret( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + phaseID: string, + inactiveIn: number, + now: Date +) { + // Find the phase. + if (!tenant.integrations.external) { + throw new Error( + "referenced phase was not found on tenant, none configured" + ); + } + let phase = getExternalModerationPhase(tenant.integrations.external, phaseID); + if (!phase) { + throw new Error("referenced phase was not found on tenant"); + } + + if (inactiveIn < 0 || inactiveIn > 86400) { + throw new Error(`invalid inactiveIn passed: ${inactiveIn}`); + } + + // Compute the inactiveAt dates for the current active secrets. + const inactiveAt = + inactiveIn === 0 + ? now + : DateTime.fromJSDate(now).plus({ seconds: inactiveIn }).toJSDate(); + + // Rotate the secrets. + const updatedTenant = await rotateTenantExternalModerationPhaseSigningSecret( + mongo, + tenant.id, + phaseID, + inactiveAt, + now + ); + if (!updatedTenant) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + // Find the updated endpoint. + phase = getExternalModerationPhase( + // We know that `external` is provided because we already verified it earlier. + updatedTenant.integrations.external!, + phaseID + ); + if (!phase) { + throw new Error("referenced endpoint was not found on tenant"); + } + + return phase; +} diff --git a/src/core/server/services/tenant/index.ts b/src/core/server/services/tenant/index.ts index 36a1b843c..f85e6ffc6 100644 --- a/src/core/server/services/tenant/index.ts +++ b/src/core/server/services/tenant/index.ts @@ -1,2 +1,4 @@ export * from "./tenant"; export * from "./sso"; +export * from "./externalModerationPhases"; +export * from "./webhookEndpoints"; diff --git a/src/core/server/services/tenant/sso.ts b/src/core/server/services/tenant/sso.ts index 660df6c0a..3532e13ea 100644 --- a/src/core/server/services/tenant/sso.ts +++ b/src/core/server/services/tenant/sso.ts @@ -3,18 +3,20 @@ import { DateTime } from "luxon"; import { Db } from "mongodb"; import { - createTenantSSOKey, - deactivateTenantSSOKey, - deleteLastUsedAtTenantSSOKey, - deleteTenantSSOKey, + deactivateTenantSSOSigningSecret, + deleteLastUsedAtTenantSSOSigningSecret, + deleteTenantSSOSigningSecret, + rotateTenantSSOSigningSecret, Tenant, } from "coral-server/models/tenant"; -import TenantCache from "./cache"; +import { TenantCache } from "./cache"; /** * regenerateSSOKey will regenerate the Single Sign-On key for the specified * Tenant and notify all other Tenant's connected that the Tenant was updated. + * + * DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0. */ export async function regenerateSSOKey( mongo: Db, @@ -24,10 +26,17 @@ export async function regenerateSSOKey( now: Date ) { // Regeneration is the same as rotating but with a specific 30 day window. - return rotateSSOKey(mongo, redis, cache, tenant, 30 * 24 * 60 * 60, now); + return rotateSSOSigningSecret( + mongo, + redis, + cache, + tenant, + 30 * 24 * 60 * 60, + now + ); } -export async function rotateSSOKey( +export async function rotateSSOSigningSecret( mongo: Db, redis: Redis, cache: TenantCache, @@ -35,35 +44,16 @@ export async function rotateSSOKey( inactiveIn: number, now: Date ) { - // Deprecate the old Tenant SSO key if it exists. - if (tenant.auth.integrations.sso.keys.length > 0) { - // Get the old keys that are not deprecated. - const keysToDeprecate = tenant.auth.integrations.sso.keys.filter((key) => { - return !key.rotatedAt; - }); + const inactiveAt = DateTime.fromJSDate(now) + .plus({ seconds: inactiveIn }) + .toJSDate(); - // Check to see if there are keys to deprecate. - if (keysToDeprecate.length > 0) { - const deprecateAt = DateTime.fromJSDate(now) - .plus({ seconds: inactiveIn }) - .toJSDate(); - - // Deprecate all the keys that are associated on the tenant that haven't - // been done. - for (const key of keysToDeprecate) { - await deactivateTenantSSOKey( - mongo, - tenant.id, - key.kid, - deprecateAt, - now - ); - } - } - } - - // Create the new SSOKey. - const updatedTenant = await createTenantSSOKey(mongo, tenant.id, now); + const updatedTenant = await rotateTenantSSOSigningSecret( + mongo, + tenant.id, + inactiveAt, + now + ); if (!updatedTenant) { return null; } @@ -74,7 +64,7 @@ export async function rotateSSOKey( return updatedTenant; } -export async function deactivateSSOKey( +export async function deactivateSSOSigningSecret( mongo: Db, redis: Redis, cache: TenantCache, @@ -82,13 +72,15 @@ export async function deactivateSSOKey( kid: string, now: Date ) { - const key = tenant.auth.integrations.sso.keys.find((k) => k.kid === kid); + const key = tenant.auth.integrations.sso.signingSecrets.find( + (k) => k.kid === kid + ); if (!key) { throw new Error("specified kid not found on tenant"); } // Deactivate the sso key now. - const updatedTenant = await deactivateTenantSSOKey( + const updatedTenant = await deactivateTenantSSOSigningSecret( mongo, tenant.id, kid, @@ -105,26 +97,32 @@ export async function deactivateSSOKey( return updatedTenant; } -export async function deleteSSOKey( +export async function deleteSSOSigningSecret( mongo: Db, redis: Redis, cache: TenantCache, tenant: Tenant, kid: string ) { - const key = tenant.auth.integrations.sso.keys.find((k) => k.kid === kid); + const key = tenant.auth.integrations.sso.signingSecrets.find( + (k) => k.kid === kid + ); if (!key) { throw new Error("specified kid not found on tenant"); } // Deactivate the sso key now. - const updatedTenant = await deleteTenantSSOKey(mongo, tenant.id, kid); + const updatedTenant = await deleteTenantSSOSigningSecret( + mongo, + tenant.id, + kid + ); if (!updatedTenant) { return null; } // Remove the last used date entry from the Redis hash. - await deleteLastUsedAtTenantSSOKey(redis, tenant.id, kid); + await deleteLastUsedAtTenantSSOSigningSecret(redis, tenant.id, kid); // Update the tenant cache. await cache.update(redis, updatedTenant); diff --git a/src/core/server/services/tenant/tenant.ts b/src/core/server/services/tenant/tenant.ts index b37f92197..673b14881 100644 --- a/src/core/server/services/tenant/tenant.ts +++ b/src/core/server/services/tenant/tenant.ts @@ -1,6 +1,5 @@ import { Redis } from "ioredis"; import { isUndefined, lowerCase, uniqBy } from "lodash"; -import { DateTime } from "luxon"; import { Db } from "mongodb"; import { URL } from "url"; @@ -13,18 +12,11 @@ import { createTenant, createTenantAnnouncement, CreateTenantInput, - createTenantWebhookEndpoint, - CreateTenantWebhookEndpointInput, deleteTenantAnnouncement, - deleteTenantWebhookEndpoint, disableTenantFeatureFlag, enableTenantFeatureFlag, - getWebhookEndpoint, - rollTenantWebhookEndpointSecret, Tenant, updateTenant, - updateTenantWebhookEndpoint, - UpdateTenantWebhookEndpointInput, } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; @@ -34,10 +26,9 @@ import { GQLFEATURE_FLAG, GQLSettingsInput, GQLSettingsWordListInput, - GQLWEBHOOK_EVENT_NAME, } from "coral-server/graph/schema/__generated__/types"; -import TenantCache from "./cache"; +import TenantCache from "./cache/cache"; export type UpdateTenant = GQLSettingsInput; @@ -165,258 +156,6 @@ 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, @@ -484,6 +223,7 @@ export async function createAnnouncement( throw new Error("tenant not found"); } await cache.update(redis, updated); + return updated; } diff --git a/src/core/server/services/tenant/webhookEndpoints.ts b/src/core/server/services/tenant/webhookEndpoints.ts new file mode 100644 index 000000000..4aad8e25b --- /dev/null +++ b/src/core/server/services/tenant/webhookEndpoints.ts @@ -0,0 +1,281 @@ +import { Redis } from "ioredis"; +import { DateTime } from "luxon"; +import { Db } from "mongodb"; + +import { Config } from "coral-server/config"; +import { + createTenantWebhookEndpoint, + CreateTenantWebhookEndpointInput, + deleteTenantWebhookEndpoint, + getWebhookEndpoint, + rotateTenantWebhookEndpointSigningSecret, + Tenant, + updateTenantWebhookEndpoint, + UpdateTenantWebhookEndpointInput, +} from "coral-server/models/tenant"; + +import { GQLWEBHOOK_EVENT_NAME } from "coral-server/graph/schema/__generated__/types"; + +import { TenantCache } from "./cache"; + +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 rotateWebhookEndpointSigningSecret( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + endpointID: string, + inactiveIn: number, + now: Date +) { + // Find the endpoint. + let endpoint = getWebhookEndpoint(tenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + if (inactiveIn < 0 || inactiveIn > 86400) { + throw new Error(`invalid inactiveIn passed: ${inactiveIn}`); + } + + // Compute the inactiveAt dates for the current active secrets. + const inactiveAt = + inactiveIn === 0 + ? now + : DateTime.fromJSDate(now).plus({ seconds: inactiveIn }).toJSDate(); + + // Rotate the secrets. + const updatedTenant = await rotateTenantWebhookEndpointSigningSecret( + 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. + endpoint = getWebhookEndpoint(updatedTenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + return endpoint; +} diff --git a/src/core/server/stacks/createComment.ts b/src/core/server/stacks/createComment.ts index a6804d6ba..a7b82a626 100644 --- a/src/core/server/stacks/createComment.ts +++ b/src/core/server/stacks/createComment.ts @@ -23,6 +23,7 @@ import { retrieveComment, } from "coral-server/models/comment"; import { + getDepth, hasAncestors, hasPublishedStatus, } from "coral-server/models/comment/helpers"; @@ -91,7 +92,7 @@ const markCommentAsAnswered = async ( // If we are the export on this story... story.settings.expertIDs.some((id) => id === author.id) && // And this is the first reply (depth of 1)... - comment.ancestorIDs.length === 1 + getDepth(comment) === 1 ) { // We need to mark the parent question as answered. // - Remove the unanswered tag. @@ -170,14 +171,14 @@ export default async function create( try { // Run the comment through the moderation phases. result = await processForModeration({ - action: "NEW", log, mongo, redis, config, - nudge, - story, + action: "NEW", tenant, + story, + nudge, comment: { ...input, ancestorIDs }, author, req, @@ -221,7 +222,8 @@ export default async function create( { ...input, siteID: story.siteID, - tags, + // Remap the tags to include the createdAt. + tags: tags.map((tag) => ({ type: tag, createdAt: now })), body, status, ancestorIDs, @@ -277,10 +279,11 @@ export default async function create( ...action, commentID: comment.id, commentRevisionID: revision.id, - - // Store the Story ID on the action. storyID: story.id, siteID: story.siteID, + + // All these actions are created by the system. + userID: null, }) ), now diff --git a/src/core/server/stacks/editComment.ts b/src/core/server/stacks/editComment.ts index 339492680..e4829856f 100644 --- a/src/core/server/stacks/editComment.ts +++ b/src/core/server/stacks/editComment.ts @@ -103,13 +103,13 @@ export default async function edit( // Run the comment through the moderation phases. const { body, status, metadata, actions } = await processForModeration({ - action: "EDIT", log, mongo, redis, config, - story, + action: "EDIT", tenant, + story, comment: { ...originalStaleComment, ...input, @@ -161,6 +161,9 @@ export default async function edit( commentRevisionID: result.revision.id, storyID: story.id, siteID: story.siteID, + + // All these actions are created by the system. + userID: null, }) ), now diff --git a/src/core/server/types/express.ts b/src/core/server/types/express.ts index 4f9559b1a..879d2ec2e 100644 --- a/src/core/server/types/express.ts +++ b/src/core/server/types/express.ts @@ -4,7 +4,7 @@ import { 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 TenantCache from "coral-server/services/tenant/cache"; +import { TenantCache } from "coral-server/services/tenant/cache"; export interface CoralRequest { id: string; diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 929e76fa3..eefe05f15 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -145,6 +145,7 @@ configure-sideBarNavigation-general = General configure-sideBarNavigation-authentication = Authentication configure-sideBarNavigation-moderation = Moderation configure-sideBarNavigation-organization = Organization +configure-sideBarNavigation-moderationPhases = Moderation Phases configure-sideBarNavigation-advanced = Advanced configure-sideBarNavigation-email = Email configure-sideBarNavigation-bannedAndSuspectWords = Banned and Suspect Words @@ -158,7 +159,116 @@ configure-onOffField-off = Off configure-radioButton-allow = Allow configure-radioButton-dontAllow = Don't allow +### Moderation Phases + +configure-moderationPhases-generatedAt = KEY GENERATED AT: + { DATETIME($date, year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric") } +configure-moderationPhases-phaseNotFound = External moderation phase not found +configure-moderationPhases-experimentalFeature = + The custom moderation phases feature is currently in active development. + Please contact us with any feedback or requests. +configure-moderationPhases-header-title = Moderation Phases +configure-moderationPhases-description = + Configure a external moderation phase to automate some moderation + actions. Moderation requests will be JSON encoded and signed. To + learn more about moderation requests, visit our docs. +configure-moderationPhases-addExternalModerationPhaseButton = + Add external moderation phase +configure-moderationPhases-moderationPhases = Moderation Phases +configure-moderationPhases-name = Name +configure-moderationPhases-status = Status +configure-moderationPhases-noExternalModerationPhases = + There are no external moderation phases configured, add one above. +configure-moderationPhases-enabledModerationPhase = Enabled +configure-moderationPhases-disableModerationPhase = Disabled +configure-moderationPhases-detailsButton = Details keyboard_arrow_right +configure-moderationPhases-addExternalModerationPhase = Add external moderation phase +configure-moderationPhases-updateExternalModerationPhaseButton = Update details +configure-moderationPhases-cancelButton = Cancel +configure-moderationPhases-format = Comment Body Format +configure-moderationPhases-endpointURL = Callback URL +configure-moderationPhases-timeout = Timeout +configure-moderationPhases-timeout-details = + The time that Coral will wait for your moderation response in milliseconds. +configure-moderationPhases-format-details = + The format that Coral will send the comment body in. By default, Coral will + send the comment in the original HTML encoded format. If "Plain Text" is + selected, then the HTML stripped version will be sent instead. +configure-moderationPhases-format-html = HTML +configure-moderationPhases-format-plain = Plain Text +configure-moderationPhases-endpointURL-details = + The URL that Coral moderation requests will be POST'ed to. The provided URL + must respond within the designated timeout or the decision of the moderation + action will be skipped. +configure-moderationPhases-configureExternalModerationPhase = + Configure external moderation phase +configure-moderationPhases-phaseDetails = Phase details +onfigure-moderationPhases-status = Status +configure-moderationPhases-signingSecret = Signing secret +configure-moderationPhases-signingSecretDescription = + The following signing secret is used to sign request payloads sent + to the URL. To learn more about webhook signing, visit our docs. +configure-moderationPhases-phaseStatus = Phase status +configure-moderationPhases-status = Status +configure-moderationPhases-signingSecret = Signing secret +configure-moderationPhases-signingSecretDescription = + The following signing secret is used to sign request payloads sent to the URL. + To learn more about webhook signing, visit our docs. +configure-moderationPhases-dangerZone = Danger Zone +configure-moderationPhases-rotateSigningSecret = Rotate signing secret +configure-moderationPhases-rotateSigningSecretDescription = + Rotating the signing secret will allow to you to safely replace a signing + secret used in production with a delay. +configure-moderationPhases-rotateSigningSecretButton = Rotate signing secret + +configure-moderationPhases-disableExternalModerationPhase = + Disable external moderation phase +configure-moderationPhases-disableExternalModerationPhaseDescription = + This external moderation phase is current enabled. By disabling, no new + moderation queries will be sent to the URL provided. +configure-moderationPhases-disableExternalModerationPhaseButton = Disable phase +configure-moderationPhases-enableExternalModerationPhase = + Enable external moderation phase +configure-moderationPhases-enableExternalModerationPhaseDescription = + This external moderation phase is currently disabled. By enabling, new + moderation queries will be sent to the URL provided. +configure-moderationPhases-enableExternalModerationPhaseButton = Enable phase +configure-moderationPhases-deleteExternalModerationPhase = + Delete external moderation phase +configure-moderationPhases-deleteExternalModerationPhaseDescription = + Deleting this external moderation phase will stop any new moderation queries + from being sent to this URL and will remove all the associated settings. +configure-moderationPhases-deleteExternalModerationPhaseButton = Delete phase +configure-moderationPhases-rotateSigningSecret = Rotate signing secret +configure-moderationPhases-rotateSigningSecretHelper = + After it expires, signatures will no longer be generated with the old secret. +configure-moderationPhases-expiresOldSecret = + Expire the old secret +configure-moderationPhases-expiresOldSecretImmediately = + Immediately +configure-moderationPhases-expiresOldSecretHoursFromNow = + { $hours -> + [1] 1 hour + *[other] { $hours } hours + } from now +configure-moderationPhases-rotateSigningSecretSuccessUseNewSecret = + External moderation phase signing secret has been rotated. Please ensure you + update your integrations to use the new secret below. +configure-moderationPhases-confirmDisable = + Disabling this external moderation phase will stop any new moderation queries + from being sent to this URL. Are you sure you want to continue? +configure-moderationPhases-confirmEnable = + Enabling the external moderation phase will start to send moderation queries + to this URL. Are you sure you want to continue? +configure-moderationPhases-confirmDelete = + Deleting this external moderation phase will stop any new moderation queries + from being sent to this URL and will remove all the associated settings. Are + you sure you want to continue? + ### Webhooks + +configure-webhooks-generatedAt = KEY GENERATED AT: + { DATETIME($date, year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric") } configure-webhooks-experimentalFeature = The webhook feature is currently in active development. Events may be added or removed. Please contact us with any feedback or requests.