[CORL-687] Webhooks (#2738)

* feat: initial webhook impl

* feat: added support for key rotation

* feat: harmonized fetcher

* feat: added expired secrets cleaning

* feat: event system refactor

* feat: added story event

* feat: simplfiied webhook handler

* feat: added ref's to locations where user events can be added

* feat: added UI to support webhooks

* fix: renaming some Webhook -> WebhookEndpoint

* fix: review comments to adjuist flow

* feat: added localizations

* fix: linting, updated snapshots

* fix: adapted for new fluent

* fix: rearranged folders

* fix: linting

* feat: added webhooks documentation

* feat: improved toc generation

* feat: added some tests to webhooks

* fix: chain transition hooks

* feat: added tests around webhook ui

* fix: renamed events

* fix: adjusted circle markdown linting

* fix: adjusted doctoc script call

* review: review fixes

* review: review comments

* review: adjusted signing secret confirmation

* review: adjusted styles to harmonize button usage

* fix: updated snapshots and tests

* review: move form out of webhooks

Moved the form out of the webhooks by relocating the layout used for the
route associated with the configure routes.

* fix: fixed bugs and snapshots with tests

* feat: revised slack message format to use block api

* fix: fixed a small text bug

Co-authored-by: Vinh <vinh@vinh.tech>
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
This commit is contained in:
Wyatt Johnson
2020-02-18 13:25:48 -05:00
committed by GitHub
co-authored by Vinh Kim Gardner
parent 34ba2da88d
commit e42c2b925d
137 changed files with 5633 additions and 1020 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ export type GraphMiddlewareOptions = Pick<
| "pubsub"
| "tenantCache"
| "metrics"
| "notifierQueue"
| "broker"
>;
export const graphQLHandler = ({
+2 -2
View File
@@ -15,9 +15,9 @@ import { HTMLErrorHandler } from "coral-server/app/middleware/error";
import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
import { createPassport } from "coral-server/app/middleware/passport";
import { Config } from "coral-server/config";
import CoralEventListenerBroker from "coral-server/events/publisher";
import logger from "coral-server/logger";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
import { I18n } from "coral-server/services/i18n";
import { JWTSigningConfig } from "coral-server/services/jwt";
@@ -41,7 +41,6 @@ export interface AppOptions {
mailerQueue: MailerQueue;
metrics?: Metrics;
mongo: Db;
notifierQueue: NotifierQueue;
parent: Express;
persistedQueriesRequired: boolean;
persistedQueryCache: PersistedQueryCache;
@@ -52,6 +51,7 @@ export interface AppOptions {
signingConfig: JWTSigningConfig;
tenantCache: TenantCache;
migrationManager: MigrationManager;
broker: CoralEventListenerBroker;
}
/**
@@ -1,7 +1,7 @@
import fetch from "node-fetch";
import { URL } from "url";
import { ensureNoEndSlash } from "coral-common/utils";
import { createFetch } from "coral-server/services/fetch";
/**
* Configuration that Coral is expecting.
@@ -25,6 +25,12 @@ interface DiscoveryRawConfiguration {
jwks_uri: string;
}
/**
* fetch provides a single source for managing the fetching operations for
* discovery.
*/
const fetch = createFetch({ name: "OIDC" });
/**
* discover will discover the configuration for the issuer.
*
@@ -5,7 +5,7 @@ import { Db } from "mongodb";
import { validate } from "coral-server/app/request/body";
import { IntegrationDisabled, TokenInvalidError } from "coral-server/errors";
import { SSOAuthIntegration, SSOKey } from "coral-server/models/settings";
import { Secret, SSOAuthIntegration } from "coral-server/models/settings";
import { Tenant } from "coral-server/models/tenant";
import {
retrieveUserWithProfile,
@@ -169,7 +169,7 @@ export function getRelevantSSOKeys(
tokenString: string,
now: Date,
kid?: string
): SSOKey[] {
): Secret[] {
// Collect all the current valid keys.
const keys = integration.keys.filter(k => {
if (k.inactiveAt && now >= k.inactiveAt) {
+15
View File
@@ -0,0 +1,15 @@
# events
This is the events package for Coral.
## Adding new events
You can add new events by adding to the `events.ts` file. Each event must export
a `{ eventName }Payload` type and a `{ eventName }` Coral Event.
## Adding new event listeners
You can add a new event listener by adding to the `listeners/` folder. These
events must implement the `CoralEventListener` abstract class. You can then
register this listener in the `src/core/server/index.ts` file by registering
it on the broker.
+51
View File
@@ -0,0 +1,51 @@
import uuid from "uuid/v4";
import logger from "coral-server/logger";
import { CoralEventPublisherBroker } from "./publisher";
import { CoralEventType } from "./types";
export interface CoralEventPayload<
T extends CoralEventType = CoralEventType,
U extends {} = {}
> {
/**
* id is the identifier for this specific event. Every copy of this unique
* event will share the same identifier.
*/
readonly id: string;
/**
* type identifies this particular event.
*/
readonly type: T;
/**
* data stores the underlying content of the event.
*/
readonly data: Readonly<U>;
/**
* createdAt is the date that this event was published at.
*/
readonly createdAt: Date;
}
export function createCoralEvent<T extends CoralEventPayload>(type: T["type"]) {
return {
publish: async (broker: CoralEventPublisherBroker, data: T["data"]) => {
const event: CoralEventPayload = {
id: uuid(),
createdAt: new Date(),
data,
type,
};
logger.trace(
{ eventType: event.type, eventID: event.id },
"publishing event"
);
await broker.emit(event);
},
};
}
+87
View File
@@ -0,0 +1,87 @@
import {
CommentCreatedInput,
CommentEnteredModerationQueueInput,
CommentFeaturedInput,
CommentLeftModerationQueueInput,
CommentReleasedInput,
CommentReplyCreatedInput,
CommentStatusUpdatedInput,
} from "coral-server/graph/resolvers/Subscription";
import { CoralEventPayload, createCoralEvent } from "./event";
import { CoralEventType } from "./types";
export type CommentEnteredModerationQueueCoralEventPayload = CoralEventPayload<
CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE,
CommentEnteredModerationQueueInput
>;
export const CommentEnteredModerationQueueCoralEvent = createCoralEvent<
CommentEnteredModerationQueueCoralEventPayload
>(CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE);
export type CommentLeftModerationQueueCoralEventPayload = CoralEventPayload<
CoralEventType.COMMENT_LEFT_MODERATION_QUEUE,
CommentLeftModerationQueueInput
>;
export const CommentLeftModerationQueueCoralEvent = createCoralEvent<
CommentLeftModerationQueueCoralEventPayload
>(CoralEventType.COMMENT_LEFT_MODERATION_QUEUE);
export type CommentStatusUpdatedCoralEventPayload = CoralEventPayload<
CoralEventType.COMMENT_STATUS_UPDATED,
CommentStatusUpdatedInput
>;
export const CommentStatusUpdatedCoralEvent = createCoralEvent<
CommentStatusUpdatedCoralEventPayload
>(CoralEventType.COMMENT_STATUS_UPDATED);
export type CommentReplyCreatedCoralEventPayload = CoralEventPayload<
CoralEventType.COMMENT_REPLY_CREATED,
CommentReplyCreatedInput
>;
export const CommentReplyCreatedCoralEvent = createCoralEvent<
CommentReplyCreatedCoralEventPayload
>(CoralEventType.COMMENT_REPLY_CREATED);
export type CommentCreatedCoralEventPayload = CoralEventPayload<
CoralEventType.COMMENT_CREATED,
CommentCreatedInput
>;
export const CommentCreatedCoralEvent = createCoralEvent<
CommentCreatedCoralEventPayload
>(CoralEventType.COMMENT_CREATED);
export type CommentFeaturedCoralEventPayload = CoralEventPayload<
CoralEventType.COMMENT_FEATURED,
CommentFeaturedInput
>;
export const CommentFeaturedCoralEvent = createCoralEvent<
CommentFeaturedCoralEventPayload
>(CoralEventType.COMMENT_FEATURED);
export type CommentReleasedCoralEventPayload = CoralEventPayload<
CoralEventType.COMMENT_RELEASED,
CommentReleasedInput
>;
export const CommentReleasedCoralEvent = createCoralEvent<
CommentReleasedCoralEventPayload
>(CoralEventType.COMMENT_RELEASED);
export type StoryCreatedCoralEventPayload = CoralEventPayload<
CoralEventType.STORY_CREATED,
{
storyID: string;
storyURL: string;
}
>;
export const StoryCreatedCoralEvent = createCoralEvent<
StoryCreatedCoralEventPayload
>(CoralEventType.STORY_CREATED);
+2
View File
@@ -0,0 +1,2 @@
export * from "./types";
export * from "./events";
@@ -0,0 +1,49 @@
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
import { categories } from "coral-server/services/notifications/categories";
import {
CommentFeaturedCoralEventPayload,
CommentReplyCreatedCoralEventPayload,
CommentStatusUpdatedCoralEventPayload,
} from "../events";
import { CoralEventListener, CoralEventPublisherFactory } from "../publisher";
import { CoralEventType } from "../types";
export type NotifierCoralEventListenerPayloads =
| CommentFeaturedCoralEventPayload
| CommentStatusUpdatedCoralEventPayload
| CommentReplyCreatedCoralEventPayload;
export class NotifierCoralEventListener
implements CoralEventListener<NotifierCoralEventListenerPayloads> {
public readonly name = "notifier";
private readonly queue: NotifierQueue;
constructor(queue: NotifierQueue) {
this.queue = queue;
}
/**
* events are the events that this listener handles. These are parsed from the
* notification categories.
*/
public readonly events = categories.reduce(
(events, category) => {
for (const event of category.events) {
if (!events.includes(event)) {
events.push(event);
}
}
return events;
},
[] as CoralEventType[]
);
public initialize: CoralEventPublisherFactory<
NotifierCoralEventListenerPayloads
> = ({ tenant: { id } }) => async input => {
await this.queue.add({ tenantID: id, input });
};
}
+210
View File
@@ -0,0 +1,210 @@
import striptags from "striptags";
import { reconstructTenantURL } from "coral-server/app/url";
import GraphContext from "coral-server/graph/context";
import logger from "coral-server/logger";
import { getLatestRevision } from "coral-server/models/comment";
import { getStoryTitle, getURLWithCommentID } from "coral-server/models/story";
import { createFetch } from "coral-server/services/fetch";
import { GQLMODERATION_QUEUE } from "coral-server/graph/schema/__generated__/types";
import {
CommentEnteredModerationQueueCoralEventPayload,
CommentFeaturedCoralEventPayload,
} from "../events";
import { CoralEventListener, CoralEventPublisherFactory } from "../publisher";
import { CoralEventType } from "../types";
type SlackCoralEventListenerPayloads =
| CommentFeaturedCoralEventPayload
| CommentEnteredModerationQueueCoralEventPayload;
type Trigger = "reported" | "pending" | "featured";
export class SlackCoralEventListener
implements CoralEventListener<SlackCoralEventListenerPayloads> {
public readonly name = "slack";
public readonly events = [
CoralEventType.COMMENT_FEATURED,
CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE,
];
private readonly fetch = createFetch({ name: "slack" });
private payloadTrigger(
payload: SlackCoralEventListenerPayloads
): Trigger | null {
switch (payload.type) {
case CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE:
if (payload.data.queue === GQLMODERATION_QUEUE.REPORTED) {
return "reported";
} else if (payload.data.queue === GQLMODERATION_QUEUE.PENDING) {
return "pending";
}
break;
case CoralEventType.COMMENT_FEATURED:
return "featured";
}
return null;
}
/**
* postMessage will prepare and send the incoming Slack webhook.
*
* @param ctx context of the request
* @param message the message prefix for the request
* @param payload payload for the event that occurred
* @param hookURL url to the Slack webhook that we should send the message to
*/
private async postMessage(
{ loaders, config, tenant, req }: GraphContext,
message: string,
payload: SlackCoralEventListenerPayloads,
hookURL: string
) {
// Get the comment.
const comment = await loaders.Comments.comment.load(payload.data.commentID);
if (!comment || !comment.authorID) {
return;
}
// Get the story.
const story = await loaders.Stories.story.load(payload.data.storyID);
if (!story) {
return;
}
// Get the author.
const author = await loaders.Users.user.load(comment.authorID);
if (!author) {
return;
}
// Get some properties about the event.
const storyTitle = getStoryTitle(story);
const moderateLink = reconstructTenantURL(
config,
tenant,
req,
`/admin/moderate/comment/${comment.id}`
);
const commentLink = getURLWithCommentID(story.url, comment.id);
// Replace HTML link breaks with newlines.
const body = striptags(getLatestRevision(comment).body);
// Send the post to the Slack URL. We don't wrap this in a try/catch because
// it's handled in the calling function.
const res = await this.fetch(hookURL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `${message} on *<${story.url}|${storyTitle}>*`,
},
},
{ type: "divider" },
{
type: "section",
text: {
type: "plain_text",
text: body,
},
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `Authored by *${author.username}* | <${moderateLink}|Go to Moderation> | <${commentLink}|See Comment>`,
},
],
},
{ type: "divider" },
],
}),
});
// Check that the request was completed successfully.
if (!res.ok) {
throw new Error(`slack returned non-200 status code: ${res.status}`);
}
}
private getMessage(trigger: Trigger): string {
switch (trigger) {
case "featured":
return "This comment has been featured";
case "pending":
return "This comment is pending";
case "reported":
return "This comment has been reported";
default:
throw new Error("invalid trigger");
}
}
public initialize: CoralEventPublisherFactory<
SlackCoralEventListenerPayloads
> = ctx => async payload => {
const {
tenant: { id: tenantID, slack },
} = ctx;
if (
// If slack is not defined,
!slack ||
// Or there are no slack channels,
slack.channels.length === 0 ||
// Or each channel isn't enabled or configured right.
slack.channels.every(c => !c.enabled || !c.hookURL)
) {
// Exit out then.
return;
}
// Get the trigger that is associated with this payload.
const trigger = this.payloadTrigger(payload);
if (!trigger) {
return;
}
// For each channel that is enabled with configuration.
for (const channel of slack.channels) {
if (!channel.enabled || !channel.hookURL) {
continue;
}
if (
// If featured comments are, and it's a featured comment,
(channel.triggers.featuredComments && trigger === "featured") ||
// Or reported comments are, and it's a reported comment,
(channel.triggers.reportedComments && trigger === "reported") ||
// Or pending comments are, and it's a pending comment,
(channel.triggers.pendingComments && trigger === "pending")
) {
try {
// Post the message to slack.
await this.postMessage(
ctx,
this.getMessage(trigger),
payload,
channel.hookURL
);
} catch (err) {
logger.error(
{ err, tenantID, payload, channel },
"could not post the comment to slack"
);
}
}
}
};
}
@@ -0,0 +1,74 @@
import { createSubscriptionChannelName } from "coral-server/graph/resolvers/Subscription/helpers";
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
import {
CommentCreatedCoralEventPayload,
CommentEnteredModerationQueueCoralEventPayload,
CommentFeaturedCoralEventPayload,
CommentLeftModerationQueueCoralEventPayload,
CommentReleasedCoralEventPayload,
CommentReplyCreatedCoralEventPayload,
CommentStatusUpdatedCoralEventPayload,
} from "../events";
import { CoralEventListener, CoralEventPublisherFactory } from "../publisher";
import { CoralEventType } from "../types";
type SubscriptionCoralEventListenerPayloads =
| CommentEnteredModerationQueueCoralEventPayload
| CommentLeftModerationQueueCoralEventPayload
| CommentStatusUpdatedCoralEventPayload
| CommentReplyCreatedCoralEventPayload
| CommentCreatedCoralEventPayload
| CommentFeaturedCoralEventPayload
| CommentReleasedCoralEventPayload;
export class SubscriptionCoralEventListener
implements CoralEventListener<SubscriptionCoralEventListenerPayloads> {
public readonly name = "subscription";
public readonly events = [
CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE,
CoralEventType.COMMENT_LEFT_MODERATION_QUEUE,
CoralEventType.COMMENT_STATUS_UPDATED,
CoralEventType.COMMENT_REPLY_CREATED,
CoralEventType.COMMENT_CREATED,
CoralEventType.COMMENT_FEATURED,
CoralEventType.COMMENT_RELEASED,
];
private translate(
type: SubscriptionCoralEventListenerPayloads["type"]
): SUBSCRIPTION_CHANNELS {
switch (type) {
case CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE:
return SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE;
case CoralEventType.COMMENT_LEFT_MODERATION_QUEUE:
return SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE;
case CoralEventType.COMMENT_STATUS_UPDATED:
return SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED;
case CoralEventType.COMMENT_REPLY_CREATED:
return SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED;
case CoralEventType.COMMENT_CREATED:
return SUBSCRIPTION_CHANNELS.COMMENT_CREATED;
case CoralEventType.COMMENT_FEATURED:
return SUBSCRIPTION_CHANNELS.COMMENT_FEATURED;
case CoralEventType.COMMENT_RELEASED:
return SUBSCRIPTION_CHANNELS.COMMENT_RELEASED;
}
}
private trigger(
tenantID: string,
type: SubscriptionCoralEventListenerPayloads["type"]
) {
return createSubscriptionChannelName(tenantID, this.translate(type));
}
public initialize: CoralEventPublisherFactory<
SubscriptionCoralEventListenerPayloads
> = ({ clientID, pubsub, tenant: { id } }) => async ({ type, data }) => {
await pubsub.publish(this.trigger(id, type), {
...data,
clientID,
});
};
}
@@ -0,0 +1,85 @@
import logger from "coral-server/logger";
import { WebhookQueue } from "coral-server/queue/tasks/webhook";
import { GQLWEBHOOK_EVENT_NAME } from "coral-server/graph/schema/__generated__/types";
import { StoryCreatedCoralEventPayload } from "../events";
import { CoralEventListener, CoralEventPublisherFactory } from "../publisher";
import { CoralEventType } from "../types";
export type WebhookCoralEventListenerPayloads = StoryCreatedCoralEventPayload;
export class WebhookCoralEventListener
implements CoralEventListener<WebhookCoralEventListenerPayloads> {
public readonly name = "webhook";
public readonly events = [CoralEventType.STORY_CREATED];
private readonly queue: WebhookQueue;
constructor(queue: WebhookQueue) {
this.queue = queue;
}
public initialize: CoralEventPublisherFactory<
WebhookCoralEventListenerPayloads
> = ({ id: contextID, tenant }) => async event => {
const log = logger.child(
{
tenantID: tenant.id,
contextID,
eventType: event.type,
},
true
);
// Based on the incoming event, determine which endpoints we should send.
const endpoints = tenant.webhooks.endpoints.filter(endpoint => {
// If the endpoint is disabled, don't include it.
if (!endpoint.enabled) {
return false;
}
// If all notifications have been enabled for this endpoint, include it.
if (endpoint.all) {
return true;
}
// If this event name is specifically listed, include it. We have to do
// some nasty casting here to address the fact that the types don't
// technically overlap.
if (
endpoint.events.includes(
(event.type as unknown) as GQLWEBHOOK_EVENT_NAME
)
) {
return true;
}
return false;
});
// Log some important details.
if (endpoints.length === 0) {
log.debug("no endpoints matched for event");
return;
}
log.debug(
{ endpoints: endpoints.length },
"matched endpoints that will receive event"
);
// For each of these endpoints that need a delivery of these notifications,
// queue up the job that will send it.
await Promise.all(
endpoints.map(endpoint =>
this.queue.add({
tenantID: tenant.id,
contextID,
endpointID: endpoint.id,
event,
})
)
);
};
}
+125
View File
@@ -0,0 +1,125 @@
/* eslint-disable max-classes-per-file */
import GraphContext from "coral-server/graph/context";
import logger from "coral-server/logger";
import { CoralEventPayload } from "./event";
import { CoralEventType } from "./types";
export type CoralEventPublisher<T extends CoralEventPayload = any> = (
payload: T
) => Promise<void>;
export type CoralEventPublisherFactory<T extends CoralEventPayload = any> = (
ctx: GraphContext
) => CoralEventPublisher<T>;
export abstract class CoralEventListener<T extends CoralEventPayload = any> {
/**
* name is the name of the listener used for identification in logs.
*/
public abstract readonly name: string;
/**
* events is the array of event types that this listener should listen for.
*/
public abstract readonly events: CoralEventType[];
/**
* initialize is a function that when
*/
public abstract initialize: CoralEventPublisherFactory<T>;
}
export class CoralEventPublisherBroker {
private readonly ctx: GraphContext;
private readonly events: Set<CoralEventType>;
private readonly listeners: CoralEventListener[];
private registry?: Map<CoralEventType, CoralEventPublisher[]>;
constructor(
ctx: GraphContext,
events: Set<CoralEventType>,
listeners: CoralEventListener[]
) {
this.ctx = ctx;
this.events = events;
this.listeners = listeners;
}
private initialize() {
const registry = new Map<CoralEventType, CoralEventPublisher[]>();
// Iterate over the listeners to initialize them.
for (const listener of this.listeners) {
// Initialize this listener.
const publisher = listener.initialize(this.ctx);
// Associate the publisher with each of the events.
for (const event of listener.events) {
// Get the current publishers associated with this event.
const publishers = registry.get(event) || [];
// Add this publisher to the array.
publishers.push(publisher);
// Update this item in the registry.
registry.set(event, publishers);
}
}
return registry;
}
public emit = (payload: CoralEventPayload) => {
// Check to see if this event is even registered.
if (!this.events.has(payload.type)) {
return;
}
// Lazily create the registry.
if (!this.registry) {
this.registry = this.initialize();
}
// Get the current publishers for this event. We can assert that this is
// found because the event was checked in the above events set. If the event
// did not exist in the events set, then it does not have an associated
// registry entry.
const publishers = this.registry.get(payload.type)!;
// Begin resolving these publishers.
return Promise.all(publishers.map(publisher => publisher(payload)));
};
}
export default class CoralEventListenerBroker {
private readonly events = new Set<CoralEventType>();
private readonly listeners: CoralEventListener[] = [];
public instance = (ctx: GraphContext) =>
new CoralEventPublisherBroker(ctx, this.events, this.listeners);
public register(listener: CoralEventListener) {
if (listener.events.length === 0) {
logger.warn(
{ listenerName: listener.name },
"listener was registered without any events"
);
return;
}
logger.trace(
{ listenerName: listener.name, listenerEvents: listener.events },
"registering listener for events"
);
// Add this listener to this listener set.
this.listeners.push(listener);
// Add each event to the set of registered events.
for (const event of listener.events) {
this.events.add(event);
}
}
}
+10
View File
@@ -0,0 +1,10 @@
export enum CoralEventType {
COMMENT_ENTERED_MODERATION_QUEUE = "COMMENT_ENTERED_MODERATION_QUEUE",
COMMENT_LEFT_MODERATION_QUEUE = "COMMENT_LEFT_MODERATION_QUEUE",
COMMENT_STATUS_UPDATED = "COMMENT_STATUS_UPDATED",
COMMENT_REPLY_CREATED = "COMMENT_REPLY_CREATED",
COMMENT_CREATED = "COMMENT_CREATED",
COMMENT_FEATURED = "COMMENT_FEATURED",
COMMENT_RELEASED = "COMMENT_RELEASED",
STORY_CREATED = "STORY_CREATED",
}
+6 -20
View File
@@ -4,21 +4,18 @@ import uuid from "uuid";
import { LanguageCode } from "coral-common/helpers/i18n/locales";
import { Config } from "coral-server/config";
import {
createPublisher,
Publisher,
} from "coral-server/graph/subscriptions/publisher";
import CoralEventListenerBroker, {
CoralEventPublisherBroker,
} from "coral-server/events/publisher";
import logger, { Logger } from "coral-server/logger";
import { PersistedQuery } from "coral-server/models/queries";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
import { I18n } from "coral-server/services/i18n";
import { JWTSigningConfig } from "coral-server/services/jwt";
import { AugmentedRedis } from "coral-server/services/redis";
import createSlackPublisher from "coral-server/services/slack/publisher";
import TenantCache from "coral-server/services/tenant/cache";
import { Request } from "coral-server/types/express";
@@ -41,16 +38,17 @@ export interface GraphContextOptions {
i18n: I18n;
mailerQueue: MailerQueue;
mongo: Db;
notifierQueue: NotifierQueue;
pubsub: RedisPubSub;
redis: AugmentedRedis;
scraperQueue: ScraperQueue;
tenant: Tenant;
tenantCache: TenantCache;
broker: CoralEventListenerBroker;
}
export default class GraphContext {
public readonly config: Config;
public readonly broker: CoralEventPublisherBroker;
public readonly disableCaching: boolean;
public readonly i18n: I18n;
public readonly id: string;
@@ -61,7 +59,6 @@ export default class GraphContext {
public readonly mongo: Db;
public readonly mutators: ReturnType<typeof mutators>;
public readonly now: Date;
public readonly publisher: Publisher;
public readonly pubsub: RedisPubSub;
public readonly redis: AugmentedRedis;
public readonly scraperQueue: ScraperQueue;
@@ -100,18 +97,7 @@ export default class GraphContext {
this.signingConfig = options.signingConfig;
this.clientID = options.clientID;
this.publisher = createPublisher({
pubsub: this.pubsub,
slackPublisher: createSlackPublisher(
this.mongo,
this.config,
this.tenant
),
notifierQueue: options.notifierQueue,
tenantID: this.tenant.id,
clientID: this.clientID,
});
this.broker = options.broker.instance(this);
this.loaders = loaders(this);
this.mutators = mutators(this);
}
+8 -1
View File
@@ -81,7 +81,14 @@ const primeStoriesFromConnection = (ctx: GraphContext) => (
export default (ctx: GraphContext) => ({
findOrCreate: new DataLoader(
createManyBatchLoadFn((input: FindOrCreateStory) =>
findOrCreate(ctx.mongo, ctx.tenant, input, ctx.scraperQueue, ctx.now)
findOrCreate(
ctx.mongo,
ctx.tenant,
ctx.broker,
input,
ctx.scraperQueue,
ctx.now
)
),
{
// TODO: (wyattjoh) see if there's something we can do to improve the cache key
+2 -2
View File
@@ -12,7 +12,7 @@ export const Actions = (ctx: GraphContext) => ({
ctx.mongo,
ctx.redis,
ctx.config,
ctx.publisher,
ctx.broker,
ctx.tenant,
input.commentID,
input.commentRevisionID,
@@ -24,7 +24,7 @@ export const Actions = (ctx: GraphContext) => ({
ctx.mongo,
ctx.redis,
ctx.config,
ctx.publisher,
ctx.broker,
ctx.tenant,
input.commentID,
input.commentRevisionID,
+11 -16
View File
@@ -45,7 +45,7 @@ export const Comments = (ctx: GraphContext) => ({
ctx.mongo,
ctx.redis,
ctx.config,
ctx.publisher,
ctx.broker,
ctx.tenant,
ctx.user!,
{ authorID: ctx.user!.id, ...comment },
@@ -68,7 +68,7 @@ export const Comments = (ctx: GraphContext) => ({
ctx.mongo,
ctx.redis,
ctx.config,
ctx.publisher,
ctx.broker,
ctx.tenant,
ctx.user!,
{
@@ -92,7 +92,7 @@ export const Comments = (ctx: GraphContext) => ({
createReaction(
ctx.mongo,
ctx.redis,
ctx.publisher,
ctx.broker,
ctx.tenant,
ctx.user!,
{
@@ -102,7 +102,7 @@ export const Comments = (ctx: GraphContext) => ({
ctx.now
),
removeReaction: ({ commentID }: GQLRemoveCommentReactionInput) =>
removeReaction(ctx.mongo, ctx.redis, ctx.publisher, ctx.tenant, ctx.user!, {
removeReaction(ctx.mongo, ctx.redis, ctx.broker, ctx.tenant, ctx.user!, {
commentID,
}),
createDontAgree: ({
@@ -113,7 +113,7 @@ export const Comments = (ctx: GraphContext) => ({
createDontAgree(
ctx.mongo,
ctx.redis,
ctx.publisher,
ctx.broker,
ctx.tenant,
ctx.user!,
{
@@ -128,14 +128,9 @@ export const Comments = (ctx: GraphContext) => ({
ctx.now
),
removeDontAgree: ({ commentID }: GQLRemoveCommentDontAgreeInput) =>
removeDontAgree(
ctx.mongo,
ctx.redis,
ctx.publisher,
ctx.tenant,
ctx.user!,
{ commentID }
),
removeDontAgree(ctx.mongo, ctx.redis, ctx.broker, ctx.tenant, ctx.user!, {
commentID,
}),
createFlag: ({
commentID,
commentRevisionID,
@@ -145,7 +140,7 @@ export const Comments = (ctx: GraphContext) => ({
createFlag(
ctx.mongo,
ctx.redis,
ctx.publisher,
ctx.broker,
ctx.tenant,
ctx.user!,
{
@@ -179,7 +174,7 @@ export const Comments = (ctx: GraphContext) => ({
ctx.mongo,
ctx.redis,
ctx.config,
ctx.publisher,
ctx.broker,
ctx.tenant,
commentID,
commentRevisionID,
@@ -190,7 +185,7 @@ export const Comments = (ctx: GraphContext) => ({
)
.then(comment => {
// Publish that the comment was featured.
publishCommentFeatured(ctx.publisher, comment);
publishCommentFeatured(ctx.broker, comment);
// Return it to the next step.
return comment;
+55 -1
View File
@@ -2,19 +2,33 @@ import GraphContext from "coral-server/graph/context";
import { Tenant } from "coral-server/models/tenant";
import {
createAnnouncement,
createWebhookEndpoint,
deleteAnnouncement,
deleteWebhookEndpoint,
disableFeatureFlag,
disableWebhookEndpoint,
enableFeatureFlag,
enableWebhookEndpoint,
regenerateSSOKey,
rotateWebhookEndpointSecret,
update,
updateWebhookEndpoint,
} from "coral-server/services/tenant";
import {
GQLCreateAnnouncementInput,
GQLCreateWebhookEndpointInput,
GQLDeleteWebhookEndpointInput,
GQLDisableWebhookEndpointInput,
GQLEnableWebhookEndpointInput,
GQLFEATURE_FLAG,
GQLRotateWebhookEndpointSecretInput,
GQLUpdateSettingsInput,
GQLUpdateWebhookEndpointInput,
} from "coral-server/graph/schema/__generated__/types";
import { WithoutMutationID } from "./util";
export const Settings = ({
mongo,
redis,
@@ -23,7 +37,9 @@ export const Settings = ({
config,
now,
}: GraphContext) => ({
update: (input: GQLUpdateSettingsInput): Promise<Tenant | null> =>
update: (
input: WithoutMutationID<GQLUpdateSettingsInput>
): Promise<Tenant | null> =>
update(mongo, redis, tenantCache, config, tenant, input.settings),
regenerateSSOKey: (): Promise<Tenant | null> =>
regenerateSSOKey(mongo, redis, tenantCache, tenant, now),
@@ -35,4 +51,42 @@ export const Settings = ({
createAnnouncement(mongo, redis, tenantCache, tenant, input, now),
deleteAnnouncement: () =>
deleteAnnouncement(mongo, redis, tenantCache, tenant),
createWebhookEndpoint: (
input: WithoutMutationID<GQLCreateWebhookEndpointInput>
) =>
createWebhookEndpoint(
mongo,
redis,
config,
tenantCache,
tenant,
input,
now
),
enableWebhookEndpoint: (
input: WithoutMutationID<GQLEnableWebhookEndpointInput>
) => enableWebhookEndpoint(mongo, redis, tenantCache, tenant, input.id),
disableWebhookEndpoint: (
input: WithoutMutationID<GQLDisableWebhookEndpointInput>
) => disableWebhookEndpoint(mongo, redis, tenantCache, tenant, input.id),
updateWebhookEndpoint: ({
id,
...input
}: WithoutMutationID<GQLUpdateWebhookEndpointInput>) =>
updateWebhookEndpoint(mongo, redis, config, tenantCache, tenant, id, input),
deleteWebhookEndpoint: (
input: WithoutMutationID<GQLDeleteWebhookEndpointInput>
) => deleteWebhookEndpoint(mongo, redis, tenantCache, tenant, input.id),
rotateWebhookEndpointSecret: (
input: WithoutMutationID<GQLRotateWebhookEndpointSecretInput>
) =>
rotateWebhookEndpointSecret(
mongo,
redis,
tenantCache,
tenant,
input.id,
input.inactiveIn,
now
),
});
@@ -32,6 +32,7 @@ export const Stories = (ctx: GraphContext) => ({
create(
ctx.mongo,
ctx.tenant,
ctx.broker,
ctx.config,
input.story.id,
input.story.url,
@@ -86,7 +86,7 @@ export const storyModerationInputResolver = (
*
* @param source the source of the type, not used
* @param args the args of the type, not used
* @param ctx the TenantContext that will be used to get the shared counts
* @param ctx the GraphContext that will be used to get the shared counts
*/
export const sharedModerationInputResolver = async (
source: any,
@@ -106,7 +106,7 @@ export const sharedModerationInputResolver = async (
*
* @param source the source of the payload, not used
* @param args the args of the payload containing potentially a Story ID
* @param ctx the TenantContext for which we can use to retrieve the shared data
* @param ctx the GraphContext for which we can use to retrieve the shared data
*/
export const moderationQueuesResolver: QueryToModerationQueuesResolver = async (
source,
+54 -2
View File
@@ -33,9 +33,13 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
user: await ctx.mutators.Users.updateNotificationSettings(input),
clientMutationId,
}),
updateSettings: async (source, { input }, ctx) => ({
updateSettings: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
settings: await ctx.mutators.Settings.update(input),
clientMutationId: input.clientMutationId,
clientMutationId,
}),
createCommentReaction: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comments.createReaction(input),
@@ -252,4 +256,52 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
site: await ctx.mutators.Sites.update(input),
clientMutationId: input.clientMutationId,
}),
createWebhookEndpoint: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
...(await ctx.mutators.Settings.createWebhookEndpoint(input)),
clientMutationId,
}),
updateWebhookEndpoint: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
endpoint: await ctx.mutators.Settings.updateWebhookEndpoint(input),
clientMutationId,
}),
disableWebhookEndpoint: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
endpoint: await ctx.mutators.Settings.disableWebhookEndpoint(input),
clientMutationId,
}),
enableWebhookEndpoint: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
endpoint: await ctx.mutators.Settings.enableWebhookEndpoint(input),
clientMutationId,
}),
deleteWebhookEndpoint: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
endpoint: await ctx.mutators.Settings.deleteWebhookEndpoint(input),
clientMutationId,
}),
rotateWebhookEndpointSecret: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
endpoint: await ctx.mutators.Settings.rotateWebhookEndpointSecret(input),
clientMutationId,
}),
};
+3
View File
@@ -1,3 +1,5 @@
import { getWebhookEndpoint } from "coral-server/models/tenant";
import { GQLQueryTypeResolver } from "coral-server/graph/schema/__generated__/types";
import { moderationQueuesResolver } from "./ModerationQueues";
@@ -25,4 +27,5 @@ export const Query: Required<GQLQueryTypeResolver<void>> = {
ctx.loaders.Stories.activeStories(limit),
sites: (source, args, ctx) => ctx.loaders.Sites.connection(args),
site: (source, { id }, ctx) => (id ? ctx.loaders.Sites.site.load(id) : null),
webhookEndpoint: (source, { id }, ctx) => getWebhookEndpoint(ctx.tenant, id),
};
@@ -2,7 +2,7 @@ import * as settings from "coral-server/models/settings";
import { GQLSSOAuthIntegrationTypeResolver } from "coral-server/graph/schema/__generated__/types";
function getActiveSSOKey(keys: settings.SSOKey[]) {
function getActiveSSOKey(keys: settings.Secret[]) {
// Any key that has been rotated cannot be the active key.
return keys.find(key => !key.rotatedAt);
}
@@ -6,6 +6,7 @@ import {
import {
GQLFEATURE_FLAG,
GQLSettingsTypeResolver,
GQLWEBHOOK_EVENT_NAME,
} from "coral-server/graph/schema/__generated__/types";
const filterValidFeatureFlags = () => {
@@ -27,4 +28,5 @@ export const Settings: GQLSettingsTypeResolver<Tenant> = {
const sites = await ctx.loaders.Sites.connection({});
return sites.edges.length > 1;
},
webhookEvents: () => Object.values(GQLWEBHOOK_EVENT_NAME),
};
@@ -17,3 +17,13 @@ export const Subscription: GQLSubscriptionTypeResolver = {
commentFeatured,
commentReleased,
};
export { CommentFeaturedInput } from "./commentFeatured";
export { CommentCreatedInput } from "./commentCreated";
export {
CommentEnteredModerationQueueInput,
} from "./commentEnteredModerationQueue";
export { CommentLeftModerationQueueInput } from "./commentLeftModerationQueue";
export { CommentReleasedInput } from "./commentReleased";
export { CommentReplyCreatedInput } from "./commentReplyCreated";
export { CommentStatusUpdatedInput } from "./commentStatusUpdated";
@@ -0,0 +1,10 @@
import * as tenant from "coral-server/models/tenant";
import { GQLWebhookEndpointTypeResolver } from "coral-server/graph/schema/__generated__/types";
export const WebhookEndpoint: GQLWebhookEndpointTypeResolver<
tenant.Endpoint
> = {
signingSecret: ({ signingSecrets }) =>
signingSecrets[signingSecrets.length - 1],
};
+2
View File
@@ -50,6 +50,7 @@ import { User } from "./User";
import { UsernameHistory } from "./UsernameHistory";
import { UsernameStatus } from "./UsernameStatus";
import { UserStatus } from "./UserStatus";
import { WebhookEndpoint } from "./WebhookEndpoint";
const Resolvers: GQLResolver = {
ApproveCommentPayload,
@@ -101,6 +102,7 @@ const Resolvers: GQLResolver = {
UserStatus,
Settings,
SlackConfiguration,
WebhookEndpoint,
};
export default Resolvers;
+338 -1
View File
@@ -1002,7 +1002,7 @@ type SlackChannel {
triggers are the filters of types of comments that will be sent to
the correlated channel
"""
triggers: SlackChannelTriggers
triggers: SlackChannelTriggers!
}
################################################################################
@@ -1204,6 +1204,79 @@ type StaffConfiguration {
label: String!
}
type WebhookDelivery {
success: Boolean!
status: Int!
statusText: String!
request: String!
response: String!
createdAt: Time!
}
enum WEBHOOK_EVENT_NAME {
STORY_CREATED
}
"""
TODO: merge with SSOKey with PR #2732
"""
type Secret {
"""
secret is the actual underlying secret used to verify the tokens with.
"""
secret: String!
"""
createdAt is the date that the key was created at.
"""
createdAt: Time!
}
type WebhookEndpoint {
"""
id is the unique identifier for this specific endpoint.
"""
id: ID!
"""
enabled when true will enable events to be sent to this endpoint.
"""
enabled: Boolean!
"""
url is the URL that we will POST event data to.
"""
url: String!
"""
signingSecret is the current secret used to sign the events sent out.
"""
signingSecret: Secret!
"""
deliveries store the deliveries for each event sent for the last 50 events.
"""
deliveries: [WebhookDelivery!]!
"""
all is true when all events are subscribed to.
"""
all: Boolean!
"""
events are the specific event names that this endpoint is configured to send
for.
"""
events: [WEBHOOK_EVENT_NAME!]!
}
type WebhookConfiguration {
"""
endpoints is all the configured endpoints that should receive events.
"""
endpoints: [WebhookEndpoint!]!
}
"""
NewCommenterConfiguration specifies the features that apply to new commenters
"""
@@ -1266,6 +1339,16 @@ type Settings {
"""
domain: String! @auth(roles: [ADMIN])
"""
webhooks store the webhook configuration.
"""
webhooks: WebhookConfiguration! @auth(roles: [ADMIN])
"""
webhookEvents returns all the events that can trigger webhooks.
"""
webhookEvents: [WEBHOOK_EVENT_NAME!]! @auth(roles: [ADMIN])
"""
staticURI if configured, is the static URI used to serve static files from.
"""
@@ -2891,6 +2974,11 @@ type Query {
activeStories(limit: Int = 10 @constraint(max: 25)): [Story!]!
@auth(roles: [ADMIN])
@rate(max: 2, seconds: 1)
"""
webhookEndpint will return a specific WebhookEndpoint if it exists.
"""
webhookEndpoint(id: ID!): WebhookEndpoint @auth(roles: [ADMIN])
}
################################################################################
@@ -4768,6 +4856,212 @@ type DeleteModeratorNotePayload {
user: User!
}
##################
# createWebhookEndpoint
##################
input CreateWebhookEndpointInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
url is the URL that Coral will POST event data to.
"""
url: String!
"""
all is true when all events are subscribed to.
"""
all: Boolean!
"""
events are the specific event names that this endpoint is configured to send
for.
"""
events: [WEBHOOK_EVENT_NAME!]!
}
type CreateWebhookEndpointPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
endpoint is the endpoint that we just created.
"""
endpoint: WebhookEndpoint!
"""
settings is the updated settings also containing the new endpoint.
"""
settings: Settings!
}
##################
# updateWebhookEndpoint
##################
input UpdateWebhookEndpointInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
id is the ID of the WebhookEndpoint being updated.
"""
id: ID!
"""
url is the URL that Coral will POST event data to.
"""
url: String
"""
all is true when all events are subscribed to.
"""
all: Boolean
"""
events are the specific event names that this endpoint is configured to send
for.
"""
events: [WEBHOOK_EVENT_NAME!]
}
type UpdateWebhookEndpointPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
endpoint is the endpoint that we just created.
"""
endpoint: WebhookEndpoint!
}
##################
# rotateWebhookEndpointSecret
##################
input RotateWebhookEndpointSecretInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
id is the ID of the WebhookEndpoint being updated.
"""
id: ID!
"""
inactiveIn is the number of seconds that the current active Secret should be
kept active.
"""
inactiveIn: Int!
}
type RotateWebhookEndpointSecretPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
endpoint is the endpoint that we just updated.
"""
endpoint: WebhookEndpoint
}
##################
# disableWebhookEndpoint
##################
input DisableWebhookEndpointInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
id is the ID of the WebhookEndpoint being disabled.
"""
id: ID!
}
type DisableWebhookEndpointPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
endpoint is the endpoint that we just disabled.
"""
endpoint: WebhookEndpoint
}
##################
# enableWebhookEndpoint
##################
input EnableWebhookEndpointInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
id is the ID of the WebhookEndpoint being enabled.
"""
id: ID!
}
type EnableWebhookEndpointPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
endpoint is the endpoint that we just enabled.
"""
endpoint: WebhookEndpoint
}
##################
# deleteWebhookEndpoint
##################
input DeleteWebhookEndpointInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
id is the ID of the WebhookEndpoint being deleted.
"""
id: ID!
}
type DeleteWebhookEndpointPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
endpoint is the endpoint that we just deleted.
"""
endpoint: WebhookEndpoint
}
##################
# setEmail
##################
@@ -5899,6 +6193,49 @@ type Mutation {
createSite(input: CreateSiteInput!): CreateSitePayload! @auth(roles: [ADMIN])
updateSite(input: UpdateSiteInput!): UpdateSitePayload! @auth(roles: [ADMIN])
"""
createWebhookEndpoint will create a new WebhookEndpoint.
"""
createWebhookEndpoint(
input: CreateWebhookEndpointInput!
): CreateWebhookEndpointPayload! @auth(roles: [ADMIN])
"""
updateWebhookEndpoint will update a WebhookEndpoint.
"""
updateWebhookEndpoint(
input: UpdateWebhookEndpointInput!
): UpdateWebhookEndpointPayload! @auth(roles: [ADMIN])
"""
enableWebhookEndpoint will enable a WebhookEndpoint to recieve new events.
"""
enableWebhookEndpoint(
input: EnableWebhookEndpointInput!
): EnableWebhookEndpointPayload! @auth(roles: [ADMIN])
"""
disableWebhookEndpoint will disable a WebhookEndpoint from recieving new
events.
"""
disableWebhookEndpoint(
input: DisableWebhookEndpointInput!
): DisableWebhookEndpointPayload! @auth(roles: [ADMIN])
"""
deleteWebhookEndpoint will delete a WebhookEndpoint.
"""
deleteWebhookEndpoint(
input: DeleteWebhookEndpointInput!
): DeleteWebhookEndpointPayload! @auth(roles: [ADMIN])
"""
rotateWebhookEndpointSecret will roll the current active secret to a new key.
"""
rotateWebhookEndpointSecret(
input: RotateWebhookEndpointSecretInput!
): RotateWebhookEndpointSecretPayload! @auth(roles: [ADMIN])
}
##################
@@ -1,57 +0,0 @@
import { RedisPubSub } from "graphql-redis-subscriptions";
import { createSubscriptionChannelName } from "coral-server/graph/resolvers/Subscription/helpers";
import { SUBSCRIPTION_INPUT } from "coral-server/graph/resolvers/Subscription/types";
import logger from "coral-server/logger";
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
import { SlackPublisher } from "coral-server/services/slack/publisher";
export type Publisher = (input: SUBSCRIPTION_INPUT) => Promise<void>;
export interface PublisherOptions {
pubsub: RedisPubSub;
slackPublisher: SlackPublisher;
notifierQueue: NotifierQueue;
tenantID: string;
clientID?: string;
}
/**
* createPublisher will create a new Publisher that can be used to send events
* over the pubsub broker to facilitate live updates and notifications.
*
* TODO: Update
*
* @param options options object
* @param options.pubsub the pubsub broker to be used to facilitate the publish action
* @param options.slackPublisher the slack publisher instance
* @param options.notifierQueue the queue
* @param options.tenantID the ID of the Tenant where the event will be published with
* @param options.clientID the ID of the client to de-duplicate mutation responses
*/
export const createPublisher = ({
pubsub,
slackPublisher,
notifierQueue,
tenantID,
clientID,
}: PublisherOptions): Publisher => async input => {
const { channel, payload } = input;
logger.trace({ channel, tenantID, clientID }, "publishing event");
// Start the publishing operation out to all affected subscribers.
await Promise.all([
// Publish to the underlying pubsub system for subscriptions.
pubsub.publish(createSubscriptionChannelName(tenantID, channel), {
...payload,
clientID,
}),
slackPublisher(channel, payload),
// Notify the notifications queue so we can offload notification processing
// to it.
notifierQueue.add({ tenantID, input }),
]);
};
+21 -1
View File
@@ -37,6 +37,11 @@ import {
} from "coral-server/services/redis";
import TenantCache from "coral-server/services/tenant/cache";
import { NotifierCoralEventListener } from "./events/listeners/notifier";
import { SlackCoralEventListener } from "./events/listeners/slack";
import { SubscriptionCoralEventListener } from "./events/listeners/subscription";
import { WebhookCoralEventListener } from "./events/listeners/webhook";
import CoralEventListenerBroker from "./events/publisher";
import { isInstalled } from "./services/tenant";
export interface ServerOptions {
@@ -108,6 +113,12 @@ class Server {
// migrationManager is the manager for performing migrations on Coral.
private migrationManager: MigrationManager;
/**
* broker stores a reference to all of the listeners that can be used in
* conjunction with an event to publish activity occurring inside Coral.
*/
private broker: CoralEventListenerBroker;
constructor(options: ServerOptions) {
this.parentApp = express();
@@ -190,6 +201,7 @@ class Server {
this.tasks = await createQueue({
config: this.config,
mongo: this.mongo,
redis: this.redis,
tenantCache: this.tenantCache,
i18n: this.i18n,
signingConfig: this.signingConfig,
@@ -201,6 +213,13 @@ class Server {
createRedisClient(this.config)
);
// Setup the broker.
this.broker = new CoralEventListenerBroker();
this.broker.register(new NotifierCoralEventListener(this.tasks.notifier));
this.broker.register(new SlackCoralEventListener());
this.broker.register(new SubscriptionCoralEventListener());
this.broker.register(new WebhookCoralEventListener(this.tasks.webhook));
// Setup the metrics collectors.
collectDefaultMetrics({ timeout: 5000 });
}
@@ -233,6 +252,7 @@ class Server {
this.tasks.mailer.process();
this.tasks.scraper.process();
this.tasks.notifier.process();
this.tasks.webhook.process();
// Start up the cron job processors.
this.scheduledTasks = startScheduledTasks({
@@ -323,6 +343,7 @@ class Server {
const options: AppOptions = {
parent,
broker: this.broker,
pubsub: this.pubsub,
mongo: this.mongo,
redis: this.redis,
@@ -333,7 +354,6 @@ class Server {
i18n: this.i18n,
mailerQueue: this.tasks.mailer,
scraperQueue: this.tasks.scraper,
notifierQueue: this.tasks.notifier,
disableClientRoutes,
persistedQueryCache: this.persistedQueryCache,
persistedQueriesRequired:
+2
View File
@@ -0,0 +1,2 @@
export * from "./settings";
export * from "./secret";
+44
View File
@@ -0,0 +1,44 @@
export interface Secret {
/**
* kid is the identifier for the key used when verifying tokens issued by the
* provider.
*/
kid: string;
/**
* secret is the actual underlying secret used to verify the tokens with.
*/
secret: string;
/**
* createdAt is the date that the key was created at.
*/
createdAt: Date;
/**
* rotatedAt is the time that the token was rotated out.
*/
rotatedAt?: Date;
/**
* inactiveAt is the date that the token can no longer be used to validate
* tokens.
*/
inactiveAt?: Date;
}
export function isSecretExpired({ inactiveAt }: Secret, now = new Date()) {
if (inactiveAt && inactiveAt <= now) {
return true;
}
return false;
}
export function filterExpiredSecrets(now = new Date()) {
return (secret: Secret) => isSecretExpired(secret, now);
}
export function filterActiveSecrets(now = new Date()) {
return (secret: Secret) => !isSecretExpired(secret, now);
}
@@ -13,6 +13,8 @@ import {
GQLSettings,
} from "coral-server/graph/schema/__generated__/types";
import { Secret } from "./secret";
export type LiveConfiguration = Omit<GQLLiveConfiguration, "configurable">;
export type EmailConfiguration = GQLEmailConfiguration;
@@ -38,40 +40,11 @@ export type FacebookAuthIntegration = Omit<
"callbackURL" | "redirectURL"
>;
export interface SSOKey {
/**
* kid is the identifier for the key used when verifying tokens issued by the
* provider.
*/
kid: string;
/**
* secret is the actual underlying secret used to verify the tokens with.
*/
secret: string;
/**
* createdAt is the date that the key was created at.
*/
createdAt: Date;
/**
* rotatedAt is the time that the token was rotated out.
*/
rotatedAt?: Date;
/**
* inactiveAt is the date that the token can no longer be used to validate
* tokens.
*/
inactiveAt?: Date;
}
export interface SSOAuthIntegration {
enabled: boolean;
allowRegistration: boolean;
targetFilter: GQLAuthenticationTargetFilter;
keys: SSOKey[];
keys: Secret[];
}
/**
+41 -19
View File
@@ -93,24 +93,27 @@ export interface UpsertStoryInput {
siteID: string;
}
export interface UpsertStoryResult {
story: Story;
wasUpserted: boolean;
}
export async function upsertStory(
mongo: Db,
tenantID: string,
{ id = uuid.v4(), url, siteID }: UpsertStoryInput,
now = new Date()
) {
): Promise<UpsertStoryResult> {
// Create the story, optionally sourcing the id from the input, additionally
// porting in the tenantID.
const update: { $setOnInsert: Story } = {
$setOnInsert: {
id,
url,
siteID,
tenantID,
createdAt: now,
commentCounts: createEmptyRelatedCommentCounts(),
settings: {},
},
const story: Story = {
id,
url,
tenantID,
siteID,
createdAt: now,
commentCounts: createEmptyRelatedCommentCounts(),
settings: {},
};
try {
@@ -121,18 +124,26 @@ export async function upsertStory(
url,
tenantID,
},
update,
{ $setOnInsert: story },
{
// Create the object if it doesn't already exist.
upsert: true,
// False to return the updated document instead of the original
// document.
returnOriginal: false,
// True to return the original document instead of the updated document.
// This will ensure that when an upsert operation adds a new Story, it
// should return null.
returnOriginal: true,
}
);
return result.value || null;
return {
// The story will either be found (via `result.value`) or upserted (via
// `story`).
story: result.value || story,
// The story was upserted if the value isn't provided.
wasUpserted: !result.value,
};
} catch (err) {
// Evaluate the error, if it is in regards to violating the unique index,
// then return a duplicate Story error.
@@ -172,13 +183,18 @@ export interface FindOrCreateStoryInput {
url?: string;
}
export interface FindOrCreateStoryResult {
story: Story | null;
wasUpserted: boolean;
}
export async function findOrCreateStory(
mongo: Db,
tenantID: string,
{ id, url }: FindOrCreateStoryInput,
siteID: string | null,
now = new Date()
) {
): Promise<FindOrCreateStoryResult> {
if (id) {
if (url && siteID) {
// The URL was specified, this is an upsert operation.
@@ -194,8 +210,14 @@ export async function findOrCreateStory(
);
}
// The URL and siteID were not specified, this is a lookup operation.
return retrieveStory(mongo, tenantID, id);
// The URL was not specified, this is a lookup operation.
const story = await retrieveStory(mongo, tenantID, id);
// Return the result object.
return {
story,
wasUpserted: false,
};
}
// The ID was not specified, this is an upsert operation. Check to see that
+10 -3
View File
@@ -9,7 +9,7 @@ import {
GQLStaffConfiguration,
} from "coral-server/graph/schema/__generated__/types";
import { SSOKey } from "../settings";
import { Secret } from "../settings";
import { Tenant } from "./tenant";
export const getDefaultReactionConfiguration = (
@@ -39,12 +39,12 @@ export function generateRandomString(size: number, drift = 5) {
.toString("hex");
}
export function generateSSOKey(createdAt: Date): SSOKey {
export function generateSecret(prefix: string, createdAt: Date): Secret {
// Generate a new key. We generate a key of minimum length 32 up to 37 bytes,
// as 16 was the minimum length recommended.
//
// Reference: https://security.stackexchange.com/a/96176
const secret = generateRandomString(32, 5);
const secret = prefix + "_" + generateRandomString(32, 5);
const kid = generateRandomString(8, 3);
return { kid, secret, createdAt };
@@ -67,3 +67,10 @@ export function hasFeatureFlag(
return false;
}
export function getWebhookEndpoint(
tenant: Pick<Tenant, "webhooks">,
endpointID: string
) {
return tenant.webhooks.endpoints.find(e => e.id === endpointID) || null;
}
+308 -7
View File
@@ -9,7 +9,8 @@ import TIME from "coral-common/time";
import { DeepPartial, Omit, Sub } from "coral-common/types";
import { isBeforeDate } from "coral-common/utils";
import { dotize } from "coral-common/utils/dotize";
import { Settings } from "coral-server/models/settings";
import logger from "coral-server/logger";
import { Secret, Settings } from "coral-server/models/settings";
import { I18n } from "coral-server/services/i18n";
import { tenants as collection } from "coral-server/services/mongodb/collections";
@@ -18,12 +19,14 @@ import {
GQLFEATURE_FLAG,
GQLMODERATION_MODE,
GQLSettings,
GQLWEBHOOK_EVENT_NAME,
} from "coral-server/graph/schema/__generated__/types";
import {
generateSSOKey,
generateSecret,
getDefaultReactionConfiguration,
getDefaultStaffConfiguration,
getWebhookEndpoint,
} from "./helpers";
/**
@@ -38,6 +41,49 @@ export interface TenantResource {
readonly tenantID: string;
}
export interface Endpoint {
/**
* id is the unique identifier for this specific endpoint.
*/
id: string;
/**
* enabled when true will enable events to be sent to this endpoint.
*/
enabled: boolean;
/**
* url is the URL that we will POST event data to.
*/
url: string;
/**
* signingSecret is the secret used to sign the events sent out.
*/
signingSecrets: Secret[];
/**
* all when true indicates that all events should trigger.
*/
all: boolean;
/**
* events is the array of events that will trigger the delivery of an
* event.
*/
events: GQLWEBHOOK_EVENT_NAME[];
/**
* createdAt is the date that this endpoint was created.
*/
createdAt: Date;
/**
* modifiedAt is the date that this Endpoint was last modified at.
*/
modifiedAt?: Date;
}
export interface TenantSettings
extends Pick<GQLSettings, "domain" | "organization"> {
readonly id: string;
@@ -51,6 +97,16 @@ export interface TenantSettings
* featureFlags is the set of flags enabled on this Tenant.
*/
featureFlags?: GQLFEATURE_FLAG[];
/**
* webhooks stores the configurations for this Tenant's webhook rules.
*/
webhooks: {
/**
* endpoints is all the configured endpoints that should receive events.
*/
endpoints: Endpoint[];
};
}
/**
@@ -112,6 +168,9 @@ export async function createTenant(
enabled: false,
},
editCommentWindowLength: 30 * TIME.SECOND,
webhooks: {
endpoints: [],
},
charCount: {
enabled: false,
},
@@ -138,7 +197,7 @@ export async function createTenant(
stream: true,
},
// TODO: [CORL-754] (wyattjoh) remove this in favor of generating this when needed
keys: [generateSSOKey(now)],
keys: [generateSecret("ssosec", now)],
},
oidc: {
enabled: false,
@@ -294,9 +353,11 @@ export async function updateTenant(
{ id },
// Only update fields that have been updated.
{ $set },
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
return result.value || null;
@@ -309,7 +370,7 @@ export async function updateTenant(
*/
export async function createTenantSSOKey(mongo: Db, id: string, now: Date) {
// Construct the new key.
const key = generateSSOKey(now);
const key = generateSecret("ssosec", now);
// Update the Tenant with this new key.
const result = await collection(mongo).findOneAndUpdate(
@@ -466,3 +527,243 @@ export function retrieveAnnouncementIfEnabled(
}
return null;
}
export async function rollTenantWebhookEndpointSecret(
mongo: Db,
id: string,
endpointID: string,
inactiveAt: Date,
now: Date
) {
// Create the new secret.
const secret = generateSecret("whsec", now);
// Update the Tenant with this new secret.
let result = await collection(mongo).findOneAndUpdate(
{ id },
{
$push: { "webhooks.endpoints.$[endpoint].signingSecrets": secret },
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
arrayFilters: [
// Select the endpoint we're updating.
{ "endpoint.id": endpointID },
],
}
);
if (!result.value) {
return null;
}
// Grab the endpoint we just modified.
const endpoint = getWebhookEndpoint(result.value, endpointID);
if (!endpoint) {
return null;
}
// Get the secrets we need to deactivate...
const secretKIDsToDeprecate = endpoint.signingSecrets
// By excluding the last one (the one we just pushed)...
.splice(0, endpoint.signingSecrets.length - 1)
// And only finding keys that have not been rotated yet.
.filter(s => !s.rotatedAt)
// And get their kid's.
.map(s => s.kid);
if (secretKIDsToDeprecate.length > 0) {
logger.trace(
{ kids: secretKIDsToDeprecate },
"deprecating old signingSecrets"
);
// Deactivate the old keys.
result = await collection(mongo).findOneAndUpdate(
{ id },
{
$set: {
"webhooks.endpoints.$[endpoint].signingSecrets.$[signingSecret].inactiveAt": inactiveAt,
"webhooks.endpoints.$[endpoint].signingSecrets.$[signingSecret].rotatedAt": now,
},
},
{
arrayFilters: [
// Select the endpoint we're updating.
{ "endpoint.id": endpointID },
// Select any signing secrets with the given ids.
{ "signingSecret.kid": { $in: secretKIDsToDeprecate } },
],
}
);
}
return result.value;
}
export interface CreateTenantWebhookEndpointInput {
url: string;
all: boolean;
events: GQLWEBHOOK_EVENT_NAME[];
}
export async function createTenantWebhookEndpoint(
mongo: Db,
id: string,
input: CreateTenantWebhookEndpointInput,
now: Date
) {
// Create the new endpoint.
const endpoint: Endpoint = {
...input,
id: uuid(),
enabled: true,
signingSecrets: [generateSecret("whsec", now)],
createdAt: now,
};
// Update the Tenant with this new endpoint.
const result = await collection(mongo).findOneAndUpdate(
{ id },
{ $push: { "webhooks.endpoints": endpoint } },
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return {
endpoint: null,
tenant: null,
};
}
throw new Error("update failed for an unexpected reason");
}
return {
endpoint,
tenant: result.value,
};
}
export interface UpdateTenantWebhookEndpointInput {
enabled?: boolean;
url?: string;
all?: boolean;
events?: GQLWEBHOOK_EVENT_NAME[];
}
export async function updateTenantWebhookEndpoint(
mongo: Db,
id: string,
endpointID: string,
update: UpdateTenantWebhookEndpointInput
) {
const $set = dotize(
{ "webhooks.endpoints.$[endpoint]": update },
{ embedArrays: true }
);
// Check to see if there is any updates that will be made.
if (isEmpty($set)) {
// No updates need to be made, abort here and just return the tenant.
return retrieveTenant(mongo, id);
}
// Perform the actual update operation.
const result = await collection(mongo).findOneAndUpdate(
{ id },
{ $set },
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
arrayFilters: [{ "endpoint.id": endpointID }],
}
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return null;
}
const endpoint = getWebhookEndpoint(tenant, endpointID);
if (!endpoint) {
throw new Error(
`endpoint not found with id: ${endpointID} on tenant: ${id}`
);
}
throw new Error("update failed for an unexpected reason");
}
return result.value;
}
export async function deleteEndpointSecrets(
mongo: Db,
id: string,
endpointID: string,
kids: string[]
) {
const result = await collection(mongo).findOneAndUpdate(
{ id },
{
$pull: {
"webhooks.endpoints.$[endpoint].signingSecrets": { kid: { $in: kids } },
},
},
{ returnOriginal: false, arrayFilters: [{ "endpoint.id": endpointID }] }
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return null;
}
const endpoint = getWebhookEndpoint(tenant, endpointID);
if (!endpoint) {
throw new Error(
`endpoint not found with id: ${endpointID} on tenant: ${id}`
);
}
throw new Error("update failed for an unexpected reason");
}
return result.value;
}
export async function deleteTenantWebhookEndpoint(
mongo: Db,
id: string,
endpointID: string
) {
const result = await collection(mongo).findOneAndUpdate(
{ id },
{
$pull: {
"webhooks.endpoints": { id: endpointID },
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return null;
}
throw new Error("update failed for an unexpected reason");
}
return result.value;
}
+10 -6
View File
@@ -576,7 +576,7 @@ export async function findOrCreateUser(
const user = await findOrCreateUserInput(tenantID, input, now);
try {
await collection(mongo).findOneAndUpdate(
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
profiles: {
@@ -588,12 +588,18 @@ export async function findOrCreateUser(
},
{ $setOnInsert: user },
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
// True to return the original document instead of the updated document.
// This will ensure that when an upsert operation adds a new User, it
// should return null.
returnOriginal: true,
upsert: true,
}
);
return {
user: result.value || user,
wasUpserted: !result.value,
};
} catch (err) {
// Evaluate the error, if it is in regards to violating the unique index,
// then return a duplicate User error.
@@ -607,8 +613,6 @@ export async function findOrCreateUser(
throw err;
}
return user;
}
export type CreateUserInput = FindOrCreateUserInput;
+4 -2
View File
@@ -4,9 +4,11 @@ import Logger from "bunyan";
import TIME from "coral-common/time";
import logger from "coral-server/logger";
export interface TaskOptions<T, U = any> {
export type JobProcessor<T, U = void> = (job: Job<T>) => Promise<U>;
export interface TaskOptions<T, U = void> {
jobName: string;
jobProcessor: (job: Job<T>) => Promise<U>;
jobProcessor: JobProcessor<T, U>;
jobOptions?: Queue.JobOptions;
queue: Queue.QueueOptions;
}
+6
View File
@@ -1,4 +1,5 @@
import Queue from "bull";
import { Redis } from "ioredis";
import { Db } from "mongodb";
import { Config } from "coral-server/config";
@@ -10,6 +11,7 @@ import TenantCache from "coral-server/services/tenant/cache";
import { createMailerTask, MailerQueue } from "./tasks/mailer";
import { createNotifierTask, NotifierQueue } from "./tasks/notifier";
import { createScraperTask, ScraperQueue } from "./tasks/scraper";
import { createWebhookTask, WebhookQueue } from "./tasks/webhook";
const createQueueOptions = async (
config: Config
@@ -47,12 +49,14 @@ export interface QueueOptions {
tenantCache: TenantCache;
i18n: I18n;
signingConfig: JWTSigningConfig;
redis: Redis;
}
export interface TaskQueue {
mailer: MailerQueue;
scraper: ScraperQueue;
notifier: NotifierQueue;
webhook: WebhookQueue;
}
export async function createQueue(options: QueueOptions): Promise<TaskQueue> {
@@ -67,11 +71,13 @@ export async function createQueue(options: QueueOptions): Promise<TaskQueue> {
mailerQueue: mailer,
...options,
});
const webhook = createWebhookTask(queueOptions, options);
// Return the tasks + client.
return {
mailer,
scraper,
notifier,
webhook,
};
}
+15 -19
View File
@@ -1,10 +1,8 @@
import Queue from "bull";
import { groupBy } from "lodash";
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
import logger from "coral-server/logger";
import { CoralEventType } from "coral-server/events";
import Task from "coral-server/queue/Task";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { JWTSigningConfig } from "coral-server/services/jwt";
@@ -29,34 +27,32 @@ interface Options {
* that could be sent to users.
*/
export class NotifierQueue {
private registry: Record<SUBSCRIPTION_CHANNELS, NotificationCategory[]>;
private task: Task<NotifierData>;
constructor(queue: Queue.QueueOptions, options: Options) {
const registry = new Map<CoralEventType, NotificationCategory[]>();
// Notification categories have been grouped by their event name so that
// each event emitted need only access the associated notification once.
this.registry = groupBy(categories, "event") as Record<
SUBSCRIPTION_CHANNELS,
NotificationCategory[]
>;
for (const category of categories) {
for (const event of category.events as CoralEventType[]) {
let handlers = registry.get(event);
if (!handlers) {
handlers = [];
}
handlers.push(category);
registry.set(event, handlers);
}
}
this.task = new Task({
jobName: JOB_NAME,
jobProcessor: createJobProcessor({ registry: this.registry, ...options }),
jobProcessor: createJobProcessor({ registry, ...options }),
queue,
});
}
public async add(data: NotifierData) {
// Get all the handlers that are active for this channel.
const c = this.registry[data.input.channel];
if (!c || c.length === 0) {
logger.debug(
{ channel: data.input.channel },
"no notifications registered on this channel"
);
return;
}
return this.task.add(data);
}
@@ -1,10 +1,11 @@
import { SUBSCRIPTION_INPUT } from "coral-server/graph/resolvers/Subscription/types";
import { GQLDIGEST_FREQUENCY } from "coral-server/graph/schema/__generated__/types";
import { CoralEventPayload } from "coral-server/events/event";
import logger from "coral-server/logger";
import { NotificationCategory } from "coral-server/services/notifications/categories";
import NotificationContext from "coral-server/services/notifications/context";
import { Notification } from "coral-server/services/notifications/notification";
import { GQLDIGEST_FREQUENCY } from "coral-server/graph/schema/__generated__/types";
import { MailerQueue } from "../mailer";
import { DigestibleTemplate } from "../mailer/templates";
import { CategoryNotification } from "./processor";
@@ -52,11 +53,11 @@ export const filterSuperseded = (
export const handleHandlers = async (
ctx: NotificationContext,
categories: NotificationCategory[],
input: SUBSCRIPTION_INPUT
payload: CoralEventPayload
): Promise<CategoryNotification[]> => {
const notifications: Array<CategoryNotification | null> = await Promise.all(
categories.map(async category => {
const notification = await category.process(ctx, input.payload);
const notification = await category.process(ctx, payload);
if (!notification) {
return null;
}
@@ -2,10 +2,8 @@ import { Job } from "bull";
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import {
SUBSCRIPTION_CHANNELS,
SUBSCRIPTION_INPUT,
} from "coral-server/graph/resolvers/Subscription/types";
import { CoralEventType } from "coral-server/events";
import { NotifierCoralEventListenerPayloads } from "coral-server/events/listeners/notifier";
import logger from "coral-server/logger";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { JWTSigningConfig } from "coral-server/services/jwt";
@@ -27,14 +25,14 @@ export const JOB_NAME = "notifications";
*/
export interface NotifierData {
tenantID: string;
input: SUBSCRIPTION_INPUT;
input: NotifierCoralEventListenerPayloads;
}
interface Options {
mailerQueue: MailerQueue;
mongo: Db;
config: Config;
registry: Record<SUBSCRIPTION_CHANNELS, NotificationCategory[]>;
registry: Map<CoralEventType, NotificationCategory[]>;
tenantCache: TenantCache;
signingConfig: JWTSigningConfig;
}
@@ -83,7 +81,7 @@ export const createJobProcessor = ({
try {
// Get all the handlers that are active for this channel.
const categories = registry[input.channel];
const categories = registry.get(input.type);
if (!categories || categories.length === 0) {
return;
}
@@ -0,0 +1,22 @@
import Queue from "bull";
import Task from "coral-server/queue/Task";
import {
createJobProcessor,
JOB_NAME,
WebhookData,
WebhookProcessorOptions,
} from "./processor";
export type WebhookQueue = Task<WebhookData>;
export const createWebhookTask = (
queue: Queue.QueueOptions,
options: WebhookProcessorOptions
) =>
new Task({
jobName: JOB_NAME,
jobProcessor: createJobProcessor(options),
queue,
});
@@ -0,0 +1,262 @@
import crypto from "crypto";
import { Redis } from "ioredis";
import { Db } from "mongodb";
import getNow from "performance-now";
import { Config } from "coral-server/config";
import { CoralEventPayload } from "coral-server/events/event";
import logger from "coral-server/logger";
import {
filterActiveSecrets,
filterExpiredSecrets,
} from "coral-server/models/settings";
import {
deleteEndpointSecrets,
Endpoint,
getWebhookEndpoint,
} from "coral-server/models/tenant";
import { JobProcessor } from "coral-server/queue/Task";
import { createFetch, FetchOptions } from "coral-server/services/fetch";
import { disableWebhookEndpoint } from "coral-server/services/tenant";
import TenantCache from "coral-server/services/tenant/cache";
export const JOB_NAME = "webhook";
// The count of failures on a webhook delivery before we disable the endpoint.
const MAXIMUM_FAILURE_COUNT = 10;
// The number of webhook attempts that should be retained for debugging.
const MAXIMUM_EVENT_ATTEMPTS_LOG_SIZE = 50;
export interface WebhookProcessorOptions {
config: Config;
mongo: Db;
redis: Redis;
tenantCache: TenantCache;
}
export interface WebhookData {
contextID: string;
endpointID: string;
tenantID: string;
event: CoralEventPayload;
}
export interface WebhookDelivery {
id: string;
name: string;
success: boolean;
status: number;
statusText: string;
request: string;
response: string;
createdAt: Date;
}
/**
* generateSignature will generate a signature used to assist clients to
* validate that the request came from Coral.
*
* @param secret the secret used to sign the body with
* @param body the body to use when signing
*/
export function generateSignature(secret: string, body: string) {
return crypto
.createHmac("sha256", secret)
.update(body)
.digest()
.toString("hex");
}
export function generateSignatures(
endpoint: Pick<Endpoint, "signingSecrets">,
body: string,
now: Date
) {
// For each of the signatures, we only want to sign the body with secrets that
// are still active.
return endpoint.signingSecrets
.filter(filterActiveSecrets(now))
.map(({ secret }) => generateSignature(secret, body))
.map(signature => `sha256=${signature}`)
.join(",");
}
export function generateFetchOptions(
endpoint: Pick<Endpoint, "signingSecrets">,
data: CoralEventPayload,
now: Date
): FetchOptions {
// Serialize the body and signature to include in the request.
const body = JSON.stringify(data, null, 2);
const signature = generateSignatures(endpoint, body, now);
const headers: Record<string, any> = {
"Content-Type": "application/json",
"X-Coral-Event": data.type,
"X-Coral-Signature": signature,
};
return {
method: "POST",
headers,
body,
};
}
export function createJobProcessor({
mongo,
tenantCache,
redis,
}: WebhookProcessorOptions): JobProcessor<WebhookData> {
// Create the fetcher that will orchestrate sending the actual webhooks.
const fetch = createFetch({ name: "Webhook" });
return async job => {
const { tenantID, endpointID, contextID, event } = job.data;
const log = logger.child(
{
eventID: event.id,
contextID,
jobID: job.id,
jobName: JOB_NAME,
tenantID,
endpointID,
},
true
);
// Get the referenced tenant so we can get the endpoint details.
const tenant = await tenantCache.retrieveByID(tenantID);
if (!tenant) {
log.error("referenced tenant was not found");
return;
}
// Get the referenced endpoint.
const endpoint = getWebhookEndpoint(tenant, endpointID);
if (!endpoint) {
log.error("referenced endpoint was not found");
return;
}
// If the endpoint is disabled, don't bother processing it.
if (!endpoint.enabled) {
log.warn("endpoint was disabled, skipping sending");
return;
}
// Get the current date.
const now = new Date();
// Get the fetch options.
const options = generateFetchOptions(endpoint, event, now);
// Send the request.
const startedSendingAt = getNow();
const res = await fetch(endpoint.url, options);
const took = getNow() - startedSendingAt;
if (res.ok) {
log.info(
{ took, responseStatus: res.status },
"finished sending webhook"
);
} else {
log.warn(
{ took, responseStatus: res.status },
"failed to deliver webhook"
);
}
// Grab the response from the webhook, we'll want to save this in the recent
// attempts.
const response = await res.text();
// Collect the delivery information.
const delivery: WebhookDelivery = {
id: event.id,
name: event.type,
success: res.ok,
status: res.status,
statusText: res.statusText,
// We only serialize the body as a string.
request: options.body as string,
response,
createdAt: new Date(),
};
// Record the delivery.
const endpointDeliveriesKey = `${tenantID}:endpointDeliveries:${endpointID}`;
const endpointFailuresKey = `${tenantID}:endpointFailures:${endpointID}`;
let [, , [, failuresString]] = await redis
.multi()
// Push the attempt into the list.
.rpush(endpointDeliveriesKey, JSON.stringify(delivery))
// Trim the list to the 50 most recent attempts.
.ltrim(endpointDeliveriesKey, 0, MAXIMUM_EVENT_ATTEMPTS_LOG_SIZE - 1)
// Get the current failure count.
.get(endpointFailuresKey)
// Execute the queued operations.
.exec();
let failures = failuresString ? parseInt(failuresString, 10) : null;
if (res.ok && failures && failures > 0) {
// The webhook delivery was a success, and there were previous failures.
// Remove the failures record.
await redis.del(endpointFailuresKey);
} else if (!res.ok) {
// Record the failed attempt.
failuresString = await redis.incr(endpointFailuresKey);
// If the failure count is higher than the allowed maximum, disable the
// endpoint.
failures = failuresString ? parseInt(failuresString, 10) : null;
if (failures && failures >= MAXIMUM_FAILURE_COUNT) {
log.warn(
{ failures, maxFailures: MAXIMUM_FAILURE_COUNT },
"maximum failures reached, disabling endpoint"
);
await disableWebhookEndpoint(
mongo,
redis,
tenantCache,
tenant,
endpointID
);
} else {
// TODO: (wyattjoh) maybe schedule a retry?
}
}
// Remove the expired secrets in the next tick so that it does not affect
// the sending performance of this job, and errors do not impact the
// sending.
const expiredSigningSecrets = endpoint.signingSecrets.filter(
filterExpiredSecrets(now)
);
if (expiredSigningSecrets.length > 0) {
process.nextTick(() => {
deleteEndpointSecrets(
mongo,
tenantID,
endpoint.id,
expiredSigningSecrets.map(s => s.kid)
)
.then(() => {
log.info(
{ secrets: expiredSigningSecrets.length },
"removed expired secrets from endpoint"
);
})
.catch(err => {
log.error(
{ err },
"an error occurred when trying to remove expired secrets"
);
});
});
}
};
}
+15 -15
View File
@@ -2,7 +2,7 @@ import { Db } from "mongodb";
import { Omit } from "coral-common/types";
import { CommentNotFoundError } from "coral-server/errors";
import { Publisher } from "coral-server/graph/subscriptions/publisher";
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
import {
ACTION_TYPE,
CreateActionInput,
@@ -76,7 +76,7 @@ export async function addCommentActionCounts(
async function addCommentAction(
mongo: Db,
redis: AugmentedRedis,
publisher: Publisher,
broker: CoralEventPublisherBroker,
tenant: Tenant,
input: Omit<CreateActionInput, "storyID" | "siteID">,
now = new Date()
@@ -116,7 +116,7 @@ async function addCommentAction(
});
// Publish changes to the event publisher.
await publishChanges(publisher, {
await publishChanges(broker, {
...counts,
before: oldComment,
after: updatedComment,
@@ -131,7 +131,7 @@ async function addCommentAction(
export async function removeCommentAction(
mongo: Db,
redis: AugmentedRedis,
publisher: Publisher,
broker: CoralEventPublisherBroker,
tenant: Tenant,
input: Omit<RemoveActionInput, "commentRevisionID" | "reason">
): Promise<Readonly<Comment>> {
@@ -191,7 +191,7 @@ export async function removeCommentAction(
});
// Publish changes to the event publisher.
await publishChanges(publisher, {
await publishChanges(broker, {
...counts,
before: oldComment,
after: updatedComment,
@@ -211,7 +211,7 @@ export type CreateCommentReaction = Pick<
export async function createReaction(
mongo: Db,
redis: AugmentedRedis,
publish: Publisher,
broker: CoralEventPublisherBroker,
tenant: Tenant,
author: User,
input: CreateCommentReaction,
@@ -220,7 +220,7 @@ export async function createReaction(
return addCommentAction(
mongo,
redis,
publish,
broker,
tenant,
{
actionType: ACTION_TYPE.REACTION,
@@ -237,12 +237,12 @@ export type RemoveCommentReaction = Pick<RemoveActionInput, "commentID">;
export async function removeReaction(
mongo: Db,
redis: AugmentedRedis,
publisher: Publisher,
broker: CoralEventPublisherBroker,
tenant: Tenant,
author: User,
input: RemoveCommentReaction
) {
return removeCommentAction(mongo, redis, publisher, tenant, {
return removeCommentAction(mongo, redis, broker, tenant, {
actionType: ACTION_TYPE.REACTION,
commentID: input.commentID,
userID: author.id,
@@ -257,7 +257,7 @@ export type CreateCommentDontAgree = Pick<
export async function createDontAgree(
mongo: Db,
redis: AugmentedRedis,
publish: Publisher,
broker: CoralEventPublisherBroker,
tenant: Tenant,
author: User,
input: CreateCommentDontAgree,
@@ -266,7 +266,7 @@ export async function createDontAgree(
return addCommentAction(
mongo,
redis,
publish,
broker,
tenant,
{
actionType: ACTION_TYPE.DONT_AGREE,
@@ -284,12 +284,12 @@ export type RemoveCommentDontAgree = Pick<RemoveActionInput, "commentID">;
export async function removeDontAgree(
mongo: Db,
redis: AugmentedRedis,
publisher: Publisher,
broker: CoralEventPublisherBroker,
tenant: Tenant,
author: User,
input: RemoveCommentDontAgree
) {
return removeCommentAction(mongo, redis, publisher, tenant, {
return removeCommentAction(mongo, redis, broker, tenant, {
actionType: ACTION_TYPE.DONT_AGREE,
commentID: input.commentID,
userID: author.id,
@@ -306,7 +306,7 @@ export type CreateCommentFlag = Pick<
export async function createFlag(
mongo: Db,
redis: AugmentedRedis,
publish: Publisher,
broker: CoralEventPublisherBroker,
tenant: Tenant,
author: User,
input: CreateCommentFlag,
@@ -315,7 +315,7 @@ export async function createFlag(
return addCommentAction(
mongo,
redis,
publish,
broker,
tenant,
{
actionType: ACTION_TYPE.FLAG,
@@ -1,5 +1,4 @@
import { isNil } from "lodash";
import fetch from "node-fetch";
import path from "path";
import { URL } from "url";
@@ -18,6 +17,7 @@ import {
IntermediatePhaseResult,
ModerationPhaseContext,
} from "coral-server/services/comments/pipeline";
import { createFetch } from "coral-server/services/fetch";
import {
GQLCOMMENT_FLAG_REASON,
@@ -26,6 +26,11 @@ import {
GQLPerspectiveExternalIntegration,
} from "coral-server/graph/schema/__generated__/types";
/**
* fetch is the phase hook fetcher used to communicate with the Perspective API.
*/
const fetch = createFetch({ name: "Hooks" });
export const toxic: IntermediateModerationPhase = async ({
tenant,
nudge,
+68 -90
View File
@@ -1,153 +1,131 @@
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
import { Publisher } from "coral-server/graph/subscriptions/publisher";
import { Comment, hasPublishedStatus } from "coral-server/models/comment";
import { CommentModerationQueueCounts } from "coral-server/models/comment/counts";
import {
CommentCreatedCoralEvent,
CommentEnteredModerationQueueCoralEvent,
CommentFeaturedCoralEvent,
CommentLeftModerationQueueCoralEvent,
CommentReleasedCoralEvent,
CommentReplyCreatedCoralEvent,
CommentStatusUpdatedCoralEvent,
} from "coral-server/events";
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
import {
Comment,
CommentModerationQueueCounts,
hasPublishedStatus,
} from "coral-server/models/comment";
import {
GQLCOMMENT_STATUS,
GQLMODERATION_QUEUE,
} from "coral-server/graph/schema/__generated__/types";
export function publishCommentStatusChanges(
publish: Publisher,
export async function publishCommentStatusChanges(
broker: CoralEventPublisherBroker,
oldStatus: GQLCOMMENT_STATUS,
newStatus: GQLCOMMENT_STATUS,
commentID: string,
moderatorID: string | null
) {
if (oldStatus !== newStatus) {
publish({
channel: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
payload: {
newStatus,
oldStatus,
commentID,
moderatorID,
},
await CommentStatusUpdatedCoralEvent.publish(broker, {
newStatus,
oldStatus,
commentID,
moderatorID,
});
}
}
export function publishCommentReplyCreated(
publish: Publisher,
export async function publishCommentReplyCreated(
broker: CoralEventPublisherBroker,
comment: Pick<Comment, "id" | "status" | "ancestorIDs">
) {
if (comment.ancestorIDs.length > 0 && hasPublishedStatus(comment)) {
publish({
channel: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
payload: {
ancestorIDs: comment.ancestorIDs,
commentID: comment.id,
},
await CommentReplyCreatedCoralEvent.publish(broker, {
ancestorIDs: comment.ancestorIDs,
commentID: comment.id,
});
}
}
export function publishCommentCreated(
publish: Publisher,
export async function publishCommentCreated(
broker: CoralEventPublisherBroker,
comment: Pick<Comment, "id" | "storyID" | "parentID" | "status">
) {
if (!comment.parentID && hasPublishedStatus(comment)) {
publish({
channel: SUBSCRIPTION_CHANNELS.COMMENT_CREATED,
payload: {
commentID: comment.id,
storyID: comment.storyID,
},
await CommentCreatedCoralEvent.publish(broker, {
commentID: comment.id,
storyID: comment.storyID,
});
}
}
export function publishCommentReleased(
publish: Publisher,
export async function publishCommentReleased(
broker: CoralEventPublisherBroker,
comment: Pick<Comment, "id" | "storyID" | "parentID" | "status">
) {
if (!comment.parentID && hasPublishedStatus(comment)) {
publish({
channel: SUBSCRIPTION_CHANNELS.COMMENT_RELEASED,
payload: {
commentID: comment.id,
storyID: comment.storyID,
},
await CommentReleasedCoralEvent.publish(broker, {
commentID: comment.id,
storyID: comment.storyID,
});
}
}
export function publishCommentFeatured(
publish: Publisher,
export async function publishCommentFeatured(
broker: CoralEventPublisherBroker,
comment: Pick<Comment, "id" | "status" | "storyID">
) {
if (hasPublishedStatus(comment)) {
publish({
channel: SUBSCRIPTION_CHANNELS.COMMENT_FEATURED,
payload: {
commentID: comment.id,
storyID: comment.storyID,
},
await CommentFeaturedCoralEvent.publish(broker, {
commentID: comment.id,
storyID: comment.storyID,
});
}
}
export function publishModerationQueueChanges(
publish: Publisher,
export async function publishModerationQueueChanges(
broker: CoralEventPublisherBroker,
moderationQueue: Pick<CommentModerationQueueCounts, "queues">,
comment: Pick<Comment, "id" | "storyID">
) {
if (moderationQueue.queues.pending === 1) {
publish({
channel: SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE,
payload: {
queue: GQLMODERATION_QUEUE.PENDING,
commentID: comment.id,
storyID: comment.storyID,
},
await CommentEnteredModerationQueueCoralEvent.publish(broker, {
queue: GQLMODERATION_QUEUE.PENDING,
commentID: comment.id,
storyID: comment.storyID,
});
} else if (moderationQueue.queues.pending === -1) {
publish({
channel: SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE,
payload: {
queue: GQLMODERATION_QUEUE.PENDING,
commentID: comment.id,
storyID: comment.storyID,
},
await CommentLeftModerationQueueCoralEvent.publish(broker, {
queue: GQLMODERATION_QUEUE.PENDING,
commentID: comment.id,
storyID: comment.storyID,
});
}
if (moderationQueue.queues.reported === 1) {
publish({
channel: SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE,
payload: {
queue: GQLMODERATION_QUEUE.REPORTED,
commentID: comment.id,
storyID: comment.storyID,
},
await CommentEnteredModerationQueueCoralEvent.publish(broker, {
queue: GQLMODERATION_QUEUE.REPORTED,
commentID: comment.id,
storyID: comment.storyID,
});
} else if (moderationQueue.queues.reported === -1) {
publish({
channel: SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE,
payload: {
queue: GQLMODERATION_QUEUE.REPORTED,
commentID: comment.id,
storyID: comment.storyID,
},
await CommentLeftModerationQueueCoralEvent.publish(broker, {
queue: GQLMODERATION_QUEUE.REPORTED,
commentID: comment.id,
storyID: comment.storyID,
});
}
if (moderationQueue.queues.unmoderated === 1) {
publish({
channel: SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE,
payload: {
queue: GQLMODERATION_QUEUE.UNMODERATED,
commentID: comment.id,
storyID: comment.storyID,
},
await CommentEnteredModerationQueueCoralEvent.publish(broker, {
queue: GQLMODERATION_QUEUE.UNMODERATED,
commentID: comment.id,
storyID: comment.storyID,
});
} else if (moderationQueue.queues.unmoderated === -1) {
publish({
channel: SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE,
payload: {
queue: GQLMODERATION_QUEUE.UNMODERATED,
commentID: comment.id,
storyID: comment.storyID,
},
await CommentLeftModerationQueueCoralEvent.publish(broker, {
queue: GQLMODERATION_QUEUE.UNMODERATED,
commentID: comment.id,
storyID: comment.storyID,
});
}
}
+84
View File
@@ -0,0 +1,84 @@
import http from "http";
import https from "https";
import { capitalize } from "lodash";
import fetch, { RequestInit, Response } from "node-fetch";
import { URL } from "url";
import { version } from "coral-common/version";
import abortAfter from "./abortAfter";
export type Fetch = (url: string, options?: FetchOptions) => Promise<Response>;
export interface CreateFetchOptions {
/**
* name is the string that is attached to the `User-Agent` header as:
*
* `Coral ${name}/${version}`
*/
name: string;
}
export type FetchOptions = RequestInit & {
/**
* timeout is the number of seconds that the request will wait for a response
* before timing out.
*/
timeout?: number;
};
export const createFetch = ({ name }: CreateFetchOptions): Fetch => {
// Create HTTP agents to improve connection performance.
const agents = {
https: new https.Agent({
keepAlive: true,
}),
http: new http.Agent({
keepAlive: true,
}),
};
// agent will select the correct agent to use for reusing the agent.
const agent = (url: URL) =>
url.protocol === "http:" ? agents.http : agents.https;
// defaultHeaders are the headers attached to each request (unless they are
// overridden).
const defaultHeaders = {
"User-Agent": `Coral ${capitalize(name)}/${version}`,
};
// Return the actual fetcher that just uses fetch under the hood.
return async (
url: string,
{
headers = {},
// Default to 10 seconds for the timeout.
timeout = 10000,
...options
}: FetchOptions = {}
) => {
// Abort the scrape request after the timeout is reached.
const abort = abortAfter(timeout);
try {
// Perform the actual fetch operation.
const res = await fetch(url, {
agent,
headers: {
...defaultHeaders,
...headers,
},
// Attach the controller signal to abort the request after the timeout
// is reached.
signal: abort.controller.signal,
// Merge in the passed options.
...options,
});
return res;
} finally {
clearTimeout(abort.timeout);
}
};
};
+1
View File
@@ -0,0 +1 @@
export * from "./fetch";
@@ -1,7 +1,7 @@
import { Db } from "mongodb";
import { SSOKey } from "coral-server/models/settings";
import { generateSSOKey, Tenant } from "coral-server/models/tenant";
import { Secret } from "coral-server/models/settings";
import { generateSecret, Tenant } from "coral-server/models/tenant";
import Migration from "coral-server/services/migrate/migration";
import collections from "coral-server/services/mongodb/collections";
@@ -48,14 +48,14 @@ export default class extends Migration {
}
// Store the keys in an array.
const keys: SSOKey[] = [];
const keys: Secret[] = [];
// Check to see if a key is set.
const sso = tenant.auth.integrations.sso;
if (sso.key && sso.keyGeneratedAt) {
// Create the new SSOKey based on this data.
const key = generateSSOKey(sso.keyGeneratedAt);
const key = generateSecret("ssosec", sso.keyGeneratedAt);
// Set the secret of the sso key to the secret of the current set key.
key.secret = sso.key;
@@ -0,0 +1,49 @@
import { Db } from "mongodb";
import Migration from "coral-server/services/migrate/migration";
import collections from "coral-server/services/mongodb/collections";
import { MigrationError } from "../error";
export default class extends Migration {
public async up(mongo: Db, id: string) {
await collections.tenants(mongo).updateOne(
{ id, webhooks: null },
{
$set: {
webhooks: {
endpoints: [],
},
},
}
);
}
public async test(mongo: Db, id: string) {
// Ensure that the tenant has the webhooks set.
const tenant = await collections.tenants(mongo).findOne({ id });
if (!tenant) {
throw new MigrationError(
id,
"could not find the specified tenant",
"tenants",
[id]
);
}
if (!tenant.webhooks) {
throw new MigrationError(
id,
"tenant did not have webhooks set",
"tenants",
[id]
);
}
}
public async down(mongo: Db, id: string) {
await collections
.tenants(mongo)
.updateOne({ id }, { $unset: { webhooks: "" } });
}
}
@@ -1,7 +1,7 @@
import { DateTime } from "luxon";
import { Db } from "mongodb";
import { SSOKey } from "coral-server/models/settings";
import { Secret } from "coral-server/models/settings";
import Migration from "coral-server/services/migrate/migration";
import collections from "coral-server/services/mongodb/collections";
@@ -15,16 +15,16 @@ interface OldSSOKey {
deletedAt?: Date;
}
function isOldSSOKey(key: SSOKey | OldSSOKey): key is OldSSOKey {
function isOldSSOKey(key: Secret | OldSSOKey): key is OldSSOKey {
if (!key) {
return true;
}
if ((key as SSOKey).inactiveAt) {
if ((key as Secret).inactiveAt) {
return false;
}
if ((key as SSOKey).rotatedAt) {
if ((key as Secret).rotatedAt) {
return false;
}
@@ -63,7 +63,7 @@ export default class extends Migration {
// Transform the keys into the new format.
const keys: OldSSOKey[] = tenant.auth.integrations.sso.keys.map(
(key: OldSSOKey | SSOKey): OldSSOKey =>
(key: OldSSOKey | Secret): OldSSOKey =>
!isOldSSOKey(key)
? {
kid: key.kid,
@@ -100,8 +100,8 @@ export default class extends Migration {
}
// Transform the keys into the new format.
const keys: SSOKey[] = tenant.auth.integrations.sso.keys.map(
(key): SSOKey => ({
const keys: Secret[] = tenant.auth.integrations.sso.keys.map(
(key): Secret => ({
kid: key.kid,
secret: key.secret || "<deleted>",
createdAt: key.createdAt,
@@ -8,10 +8,10 @@ import { staffReply } from "./staffReply";
* categories stores all the notification categories in a flat list.
*/
const categories: NotificationCategory[] = [
...reply,
...staffReply,
...featured,
...moderation,
reply,
staffReply,
moderation,
featured,
];
export default categories;
@@ -1,7 +1,4 @@
import {
SUBSCRIPTION_CHANNELS,
SUBSCRIPTION_INPUT,
} from "coral-server/graph/resolvers/Subscription/types";
import { CoralEventPayload } from "coral-server/events/event";
import NotificationContext from "../context";
import { Notification } from "../notification";
@@ -10,7 +7,7 @@ import { Notification } from "../notification";
* NotificationCategory define the Category that is used to define a
* Notification type.
*/
export interface NotificationCategory {
export interface NotificationCategory<T extends CoralEventPayload = any> {
/**
* name is the actual name of the notification that can be used to define the
* other category names that are superseded by this one.
@@ -23,14 +20,14 @@ export interface NotificationCategory {
*/
process: (
ctx: NotificationContext,
input: SUBSCRIPTION_INPUT["payload"]
payload: T
) => Promise<Notification | null>;
/**
* event is the subscription event that when fired, will trigger this
* events is the subscription event that when fired, will trigger this
* notification processor to be called.
*/
event: SUBSCRIPTION_CHANNELS;
events: T["type"][];
/**
* digestOrder, when provided, allows the custom ordering of notifications in
@@ -1,63 +1,58 @@
import { CommentFeaturedInput } from "coral-server/graph/resolvers/Subscription/commentFeatured";
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
import {
CommentFeaturedCoralEventPayload,
CoralEventType,
} from "coral-server/events";
import { hasPublishedStatus } from "coral-server/models/comment";
import { getStoryTitle, getURLWithCommentID } from "coral-server/models/story";
import NotificationContext from "../context";
import { Notification } from "../notification";
import { NotificationCategory } from "./category";
async function processor(
ctx: NotificationContext,
input: CommentFeaturedInput
): Promise<Notification | null> {
// Get the comment that was featured.
const comment = await ctx.comments.load(input.commentID);
if (!comment || (!hasPublishedStatus(comment) || !comment.authorID)) {
return null;
}
export const featured: NotificationCategory<
CommentFeaturedCoralEventPayload
> = {
name: "featured",
process: async (ctx, input) => {
// Get the comment that was featured.
const comment = await ctx.comments.load(input.data.commentID);
if (!comment || !hasPublishedStatus(comment) || !comment.authorID) {
return null;
}
// Get the comment's author.
const author = await ctx.users.load(comment.authorID);
if (!author) {
return null;
}
// Get the comment's author.
const author = await ctx.users.load(comment.authorID);
if (!author) {
return null;
}
// Check to see if the user has this notification type enabled.
if (!author.notifications.onFeatured) {
return null;
}
// Check to see if the user has this notification type enabled.
if (!author.notifications.onFeatured) {
return null;
}
// Get the story that this was written on.
const story = await ctx.stories.load(comment.storyID);
if (!story) {
return null;
}
// Get the story that this was written on.
const story = await ctx.stories.load(comment.storyID);
if (!story) {
return null;
}
// Generate the unsubscribe URL.
const unsubscribeURL = await ctx.generateUnsubscribeURL(author);
// Generate the unsubscribe URL.
const unsubscribeURL = await ctx.generateUnsubscribeURL(author);
return {
userID: author.id,
template: {
name: "notification/on-featured",
context: {
commentPermalink: getURLWithCommentID(story.url, comment.id),
storyTitle: getStoryTitle(story),
storyURL: story.url,
organizationName: ctx.tenant.organization.name,
organizationURL: ctx.tenant.organization.url,
unsubscribeURL,
return {
userID: author.id,
template: {
name: "notification/on-featured",
context: {
commentPermalink: getURLWithCommentID(story.url, comment.id),
storyTitle: getStoryTitle(story),
storyURL: story.url,
organizationName: ctx.tenant.organization.name,
organizationURL: ctx.tenant.organization.url,
unsubscribeURL,
},
},
},
};
}
export const featured: NotificationCategory[] = [
{
name: "featured",
process: processor,
event: SUBSCRIPTION_CHANNELS.COMMENT_FEATURED,
digestOrder: 30,
};
},
];
events: [CoralEventType.COMMENT_FEATURED],
digestOrder: 30,
};
@@ -1,84 +1,80 @@
import { CommentStatusUpdatedInput } from "coral-server/graph/resolvers/Subscription/commentStatusUpdated";
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
import {
CommentStatusUpdatedCoralEventPayload,
CoralEventType,
} from "coral-server/events";
import { hasModeratorStatus } from "coral-server/models/comment";
import { getURLWithCommentID } from "coral-server/models/story";
import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types";
import { getURLWithCommentID } from "coral-server/models/story";
import NotificationContext from "../context";
import { Notification } from "../notification";
import { NotificationCategory } from "./category";
async function processor(
ctx: NotificationContext,
input: CommentStatusUpdatedInput
): Promise<Notification | null> {
// Check to see if this comment was previously in a moderation status.
if (!hasModeratorStatus({ status: input.oldStatus })) {
return null;
}
// Load the comment in question.
const comment = await ctx.comments.load(input.commentID);
if (!comment || !comment.authorID) {
return null;
}
// Get the comment author.
const author = await ctx.users.load(comment.authorID);
if (!author) {
return null;
}
// Check to see if this user has notifications enabled.
if (!author.notifications.onModeration) {
return null;
}
// Generate the unsubscribe URL.
const unsubscribeURL = await ctx.generateUnsubscribeURL(author);
// Check to see which template we should use.
if (comment.status === GQLCOMMENT_STATUS.APPROVED) {
// Get the story that this was written on.
const story = await ctx.stories.load(comment.storyID);
if (!story) {
export const moderation: NotificationCategory<
CommentStatusUpdatedCoralEventPayload
> = {
name: "moderation",
process: async (ctx, input) => {
// Check to see if this comment was previously in a moderation status.
if (!hasModeratorStatus({ status: input.data.oldStatus })) {
return null;
}
return {
userID: author.id,
template: {
name: "notification/on-comment-approved",
context: {
commentPermalink: getURLWithCommentID(story.url, comment.id),
organizationName: ctx.tenant.organization.name,
organizationURL: ctx.tenant.organization.url,
unsubscribeURL,
},
},
};
} else if (comment.status === GQLCOMMENT_STATUS.REJECTED) {
return {
userID: author.id,
template: {
name: "notification/on-comment-rejected",
context: {
organizationName: ctx.tenant.organization.name,
organizationURL: ctx.tenant.organization.url,
unsubscribeURL,
},
},
};
}
// Load the comment in question.
const comment = await ctx.comments.load(input.data.commentID);
if (!comment || !comment.authorID) {
return null;
}
return null;
}
// Get the comment author.
const author = await ctx.users.load(comment.authorID);
if (!author) {
return null;
}
export const moderation: NotificationCategory[] = [
{
name: "moderation",
process: processor,
event: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
digestOrder: 30,
// Check to see if this user has notifications enabled.
if (!author.notifications.onModeration) {
return null;
}
// Generate the unsubscribe URL.
const unsubscribeURL = await ctx.generateUnsubscribeURL(author);
// Check to see which template we should use.
if (comment.status === GQLCOMMENT_STATUS.APPROVED) {
// Get the story that this was written on.
const story = await ctx.stories.load(comment.storyID);
if (!story) {
return null;
}
return {
userID: author.id,
template: {
name: "notification/on-comment-approved",
context: {
commentPermalink: getURLWithCommentID(story.url, comment.id),
organizationName: ctx.tenant.organization.name,
organizationURL: ctx.tenant.organization.url,
unsubscribeURL,
},
},
};
} else if (comment.status === GQLCOMMENT_STATUS.REJECTED) {
return {
userID: author.id,
template: {
name: "notification/on-comment-rejected",
context: {
organizationName: ctx.tenant.organization.name,
organizationURL: ctx.tenant.organization.url,
unsubscribeURL,
},
},
};
}
return null;
},
];
events: [CoralEventType.COMMENT_STATUS_UPDATED],
digestOrder: 30,
};
@@ -1,112 +1,95 @@
import { CommentReplyCreatedInput } from "coral-server/graph/resolvers/Subscription/commentReplyCreated";
import { CommentStatusUpdatedInput } from "coral-server/graph/resolvers/Subscription/commentStatusUpdated";
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
import {
CommentReplyCreatedCoralEventPayload,
CommentStatusUpdatedCoralEventPayload,
CoralEventType,
} from "coral-server/events";
import { hasPublishedStatus } from "coral-server/models/comment";
import { getStoryTitle, getURLWithCommentID } from "coral-server/models/story";
import NotificationContext from "../context";
import { Notification } from "../notification";
import { NotificationCategory } from "./category";
async function processor(
ctx: NotificationContext,
input: CommentReplyCreatedInput
): Promise<Notification | null> {
const comment = await ctx.comments.load(input.commentID);
if (!comment || !hasPublishedStatus(comment) || !comment.authorID) {
return null;
}
type Payloads =
| CommentReplyCreatedCoralEventPayload
| CommentStatusUpdatedCoralEventPayload;
// Check to see if this is a reply to an existing comment.
if (!comment.parentID) {
return null;
}
export const reply: NotificationCategory<Payloads> = {
name: "reply",
process: async (ctx, input) => {
const comment = await ctx.comments.load(input.data.commentID);
if (!comment || !hasPublishedStatus(comment)) {
return null;
}
// Get the parent comment.
const parent = await ctx.comments.load(comment.parentID);
if (!parent || !hasPublishedStatus(parent) || !parent.authorID) {
return null;
}
// TODO: evaluate storing a history of comment statuses so we can ensure we don't double send.
// Get the parent comment's author.
const [author, parentAuthor] = await ctx.users.loadMany([
comment.authorID,
parent.authorID,
]);
if (!author || !parentAuthor) {
return null;
}
// Check to see if this is a reply to an existing comment.
if (!comment.parentID || !comment.authorID) {
return null;
}
// Check to see if the target user has notifications enabled for this type.
if (!parentAuthor.notifications.onReply) {
return null;
}
// Get the parent comment.
const parent = await ctx.comments.load(comment.parentID);
if (!parent || !hasPublishedStatus(parent) || !parent.authorID) {
return null;
}
// Check to see if this is yourself replying to yourself, if that's the case
// don't send a notification.
if (parentAuthor.id === author.id) {
return null;
}
// Get the parent comment's author.
const [author, parentAuthor] = await ctx.users.loadMany([
comment.authorID,
parent.authorID,
]);
if (!author || !parentAuthor) {
return null;
}
// Check to see if this user is ignoring the user who replied to their
// comment.
if (parentAuthor.ignoredUsers.some(user => user.id === author.id)) {
return null;
}
// Check to see if the target user has notifications enabled for this type.
if (!parentAuthor.notifications.onReply) {
return null;
}
// Get the story that this was written on.
const story = await ctx.stories.load(comment.storyID);
if (!story) {
return null;
}
// Check to see if this is yourself replying to yourself, if that's the case
// don't send a notification.
if (parentAuthor.id === author.id) {
return null;
}
// Generate the unsubscribe URL.
const unsubscribeURL = await ctx.generateUnsubscribeURL(parentAuthor);
// Check to see if this user is ignoring the user who replied to their
// comment.
if (parentAuthor.ignoredUsers.some(user => user.id === author.id)) {
return null;
}
// The user does have notifications for replied comments enabled, queue the
// notification to be sent.
return {
userID: parentAuthor.id,
template: {
name: "notification/on-reply",
context: {
// We know that the user had a username because they wrote a comment!
authorUsername: author.username!,
commentPermalink: getURLWithCommentID(story.url, comment.id),
storyTitle: getStoryTitle(story),
storyURL: story.url,
organizationName: ctx.tenant.organization.name,
organizationURL: ctx.tenant.organization.url,
unsubscribeURL,
// Get the story that this was written on.
const story = await ctx.stories.load(comment.storyID);
if (!story) {
return null;
}
// Generate the unsubscribe URL.
const unsubscribeURL = await ctx.generateUnsubscribeURL(parentAuthor);
// The user does have notifications for replied comments enabled, queue the
// notification to be sent.
return {
userID: parentAuthor.id,
template: {
name: "notification/on-reply",
context: {
// We know that the user had a username because they wrote a comment!
authorUsername: author.username!,
commentPermalink: getURLWithCommentID(story.url, comment.id),
storyTitle: getStoryTitle(story),
storyURL: story.url,
organizationName: ctx.tenant.organization.name,
organizationURL: ctx.tenant.organization.url,
unsubscribeURL,
},
},
},
};
}
export const reply: NotificationCategory[] = [
{
name: "reply",
process: processor,
event: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
digestOrder: 30,
};
},
{
name: "reply",
process: async (ctx, input: CommentStatusUpdatedInput) => {
const comment = await ctx.comments.load(input.commentID);
if (!comment || !hasPublishedStatus(comment)) {
return null;
}
// TODO: evaluate storing a history of comment statuses so we can ensure we don't double send.
// We've checked the status, let the processing continue!
return processor(ctx, {
commentID: comment.id,
ancestorIDs: comment.ancestorIDs,
});
},
event: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
digestOrder: 30,
},
];
events: [
CoralEventType.COMMENT_STATUS_UPDATED,
CoralEventType.COMMENT_REPLY_CREATED,
],
digestOrder: 30,
};
@@ -1,114 +1,102 @@
import { CommentReplyCreatedInput } from "coral-server/graph/resolvers/Subscription/commentReplyCreated";
import { CommentStatusUpdatedInput } from "coral-server/graph/resolvers/Subscription/commentStatusUpdated";
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
import {
CommentReplyCreatedCoralEventPayload,
CommentStatusUpdatedCoralEventPayload,
CoralEventType,
} from "coral-server/events";
import { hasPublishedStatus } from "coral-server/models/comment";
import { getStoryTitle, getURLWithCommentID } from "coral-server/models/story";
import { hasStaffRole } from "coral-server/models/user/helpers";
import NotificationContext from "../context";
import { Notification } from "../notification";
import { NotificationCategory } from "./category";
async function processor(
ctx: NotificationContext,
input: CommentReplyCreatedInput
): Promise<Notification | null> {
const comment = await ctx.comments.load(input.commentID);
if (!comment || !hasPublishedStatus(comment) || !comment.authorID) {
return null;
}
type Payloads =
| CommentStatusUpdatedCoralEventPayload
| CommentReplyCreatedCoralEventPayload;
// Check to see if this is a reply to an existing comment.
if (!comment.parentID) {
return null;
}
export const staffReply: NotificationCategory<Payloads> = {
name: "staffReply",
process: async (ctx, input) => {
const comment = await ctx.comments.load(input.data.commentID);
if (!comment || !hasPublishedStatus(comment)) {
return null;
}
// Get the parent comment.
const parent = await ctx.comments.load(comment.parentID);
if (!parent || !hasPublishedStatus(parent) || !parent.authorID) {
return null;
}
// TODO: evaluate storing a history of comment statuses so we can ensure we don't double send.
// Get the parent comment's author.
const [author, parentAuthor] = await ctx.users.loadMany([
comment.authorID,
parent.authorID,
]);
if (!author || !parentAuthor) {
return null;
}
// Check to see if this is a reply to an existing comment.
if (!comment.parentID) {
return null;
}
// Check to see if the author was a staff member.
if (!hasStaffRole(author)) {
return null;
}
// Get the parent comment.
const parent = await ctx.comments.load(comment.parentID);
if (
!parent ||
!hasPublishedStatus(parent) ||
!parent.authorID ||
!comment.authorID
) {
return null;
}
// Check to see if the target user has notifications enabled for this type.
if (!parentAuthor.notifications.onStaffReplies) {
return null;
}
// Get the parent comment's author.
const [author, parentAuthor] = await ctx.users.loadMany([
comment.authorID,
parent.authorID,
]);
if (!author || !parentAuthor) {
return null;
}
// Check to see if this is yourself replying to yourself, if that's the case
// don't send a notification.
if (parentAuthor.id === author.id) {
return null;
}
// Check to see if the author was a staff member.
if (!hasStaffRole(author)) {
// This is a handler for staff replies only.
return null;
}
// Get the story that this was written on.
const story = await ctx.stories.load(comment.storyID);
if (!story) {
return null;
}
// Check to see if the target user has notifications enabled for this type.
if (!parentAuthor.notifications.onStaffReplies) {
return null;
}
// Generate the unsubscribe URL.
const unsubscribeURL = await ctx.generateUnsubscribeURL(parentAuthor);
// Check to see if this is yourself replying to yourself, if that's the case
// don't send a notification.
if (parentAuthor.id === author.id) {
return null;
}
// The user does have notifications for replied comments enabled, queue the
// notification to be sent.
return {
userID: parentAuthor.id,
template: {
name: "notification/on-staff-reply",
context: {
// We know that the user had a username because they wrote a comment!
authorUsername: author.username!,
commentPermalink: getURLWithCommentID(story.url, comment.id),
storyTitle: getStoryTitle(story),
storyURL: story.url,
organizationName: ctx.tenant.organization.name,
organizationURL: ctx.tenant.organization.url,
unsubscribeURL,
// Get the story that this was written on.
const story = await ctx.stories.load(comment.storyID);
if (!story) {
return null;
}
// Generate the unsubscribe URL.
const unsubscribeURL = await ctx.generateUnsubscribeURL(parentAuthor);
// The user does have notifications for replied comments enabled, queue the
// notification to be sent.
return {
userID: parentAuthor.id,
template: {
name: "notification/on-staff-reply",
context: {
// We know that the user had a username because they wrote a comment!
authorUsername: author.username!,
commentPermalink: getURLWithCommentID(story.url, comment.id),
storyTitle: getStoryTitle(story),
storyURL: story.url,
organizationName: ctx.tenant.organization.name,
organizationURL: ctx.tenant.organization.url,
unsubscribeURL,
},
},
},
};
}
export const staffReply: NotificationCategory[] = [
{
name: "staffReply",
process: processor,
event: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
digestOrder: 30,
supersedesCategories: ["reply"],
};
},
{
name: "staffReply",
process: async (ctx, input: CommentStatusUpdatedInput) => {
const comment = await ctx.comments.load(input.commentID);
if (!comment || !hasPublishedStatus(comment)) {
return null;
}
// TODO: evaluate storing a history of comment statuses so we can ensure we don't double send.
// We've checked the status, let the processing continue!
return processor(ctx, {
commentID: comment.id,
ancestorIDs: comment.ancestorIDs,
});
},
event: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
digestOrder: 30,
supersedesCategories: ["reply"],
},
];
events: [
CoralEventType.COMMENT_STATUS_UPDATED,
CoralEventType.COMMENT_REPLY_CREATED,
],
digestOrder: 30,
supersedesCategories: ["reply"],
};
-55
View File
@@ -1,55 +0,0 @@
import Logger from "bunyan";
import DataLoader from "dataloader";
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import { Comment, retrieveManyComments } from "coral-server/models/comment";
import { retrieveManyStories, Story } from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import { retrieveManyUsers, User } from "coral-server/models/user";
import { Request } from "coral-server/types/express";
interface Options {
mongo: Db;
tenant: Pick<Tenant, "id" | "domain">;
config: Config;
req?: Request;
}
class SlackContext {
public readonly mongo: Db;
public readonly tenant: Pick<Tenant, "id" | "domain">;
public readonly logger: Logger;
public readonly config: Config;
public readonly req?: Request;
public readonly comments: DataLoader<
string,
Readonly<Comment> | null
> = new DataLoader(commentIDs =>
retrieveManyComments(this.mongo, this.tenant.id, commentIDs)
);
public readonly stories: DataLoader<
string,
Readonly<Story> | null
> = new DataLoader(storyIDs =>
retrieveManyStories(this.mongo, this.tenant.id, storyIDs)
);
public readonly users: DataLoader<
string,
Readonly<User> | null
> = new DataLoader(userIDs =>
retrieveManyUsers(this.mongo, this.tenant.id, userIDs)
);
constructor({ mongo, tenant, config, req }: Options) {
this.mongo = mongo;
this.tenant = tenant;
this.config = config;
this.req = req;
}
}
export default SlackContext;
-3
View File
@@ -1,3 +0,0 @@
export { default as slackPublisher } from "./publisher";
export * from "./publisher";
export * from "./context";
-207
View File
@@ -1,207 +0,0 @@
import { Db } from "mongodb";
import { reconstructTenantURL } from "coral-server/app/url";
import { Config } from "coral-server/config";
import { CommentCreatedInput } from "coral-server/graph/resolvers/Subscription/commentCreated";
import { CommentEnteredModerationQueueInput } from "coral-server/graph/resolvers/Subscription/commentEnteredModerationQueue";
import { CommentFeaturedInput } from "coral-server/graph/resolvers/Subscription/commentFeatured";
import { CommentLeftModerationQueueInput } from "coral-server/graph/resolvers/Subscription/commentLeftModerationQueue";
import { CommentReleasedInput } from "coral-server/graph/resolvers/Subscription/commentReleased";
import { CommentReplyCreatedInput } from "coral-server/graph/resolvers/Subscription/commentReplyCreated";
import { CommentStatusUpdatedInput } from "coral-server/graph/resolvers/Subscription/commentStatusUpdated";
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
import logger from "coral-server/logger";
import { getLatestRevision } from "coral-server/models/comment/helpers";
import {
getStoryTitle,
getURLWithCommentID,
} from "coral-server/models/story/helpers";
import { Tenant } from "coral-server/models/tenant";
import { GQLMODERATION_QUEUE } from "coral-server/graph/schema/__generated__/types";
import SlackContext from "./context";
type Payload =
| CommentEnteredModerationQueueInput
| CommentLeftModerationQueueInput
| CommentStatusUpdatedInput
| CommentReplyCreatedInput
| CommentCreatedInput
| CommentFeaturedInput
| CommentReleasedInput;
function isFeatured(channel: SUBSCRIPTION_CHANNELS) {
return channel === SUBSCRIPTION_CHANNELS.COMMENT_FEATURED;
}
function isReported(channel: SUBSCRIPTION_CHANNELS, payload: Payload) {
return (
channel === SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE &&
(payload as CommentEnteredModerationQueueInput).queue ===
GQLMODERATION_QUEUE.REPORTED
);
}
function isPending(channel: SUBSCRIPTION_CHANNELS, payload: Payload) {
return (
channel === SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE &&
(payload as CommentEnteredModerationQueueInput).queue ===
GQLMODERATION_QUEUE.PENDING
);
}
function createModerationLink(ctx: SlackContext, commentID: string) {
return reconstructTenantURL(
ctx.config,
ctx.tenant,
ctx.req,
`/admin/moderate/comment/${commentID}`
);
}
async function postCommentToSlack(
ctx: SlackContext,
message: string,
commentID: string,
hookURL: string
) {
const comment = await ctx.comments.load(commentID);
if (comment === null || !comment.authorID) {
return;
}
const author = await ctx.users.load(comment.authorID);
if (author === null) {
return;
}
const story = await ctx.stories.load(comment.storyID);
if (story === null) {
return;
}
// Get some properties about the event.
const storyTitle = getStoryTitle(story);
const commentBody = getLatestRevision(comment).body;
const moderateLink = createModerationLink(ctx, commentID);
const commentLink = getURLWithCommentID(story.url, comment.id);
// Replace HTML link breaks with newlines.
const body = commentBody.replace(/<br\/?>/g, "\n");
const data = {
text: `${message} on *<${story.url}|${storyTitle}>*`,
attachments: [
{
text: body,
footer: `Authored by *${author.username}* | <${moderateLink}|Go to Moderation> | <${commentLink}|See Comment>`,
},
],
};
try {
// Send the post to the Slack URL.
const res = await fetch(hookURL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!res.ok) {
logger.error({ res }, "error sending Slack comment");
}
} catch (err) {
logger.error({ err }, "error sending Slack comment");
}
}
export type SlackPublisher = (
channel: SUBSCRIPTION_CHANNELS,
payload: Payload
) => Promise<void>;
function createSlackPublisher(
mongo: Db,
config: Config,
tenant: Tenant
): SlackPublisher {
if (
!tenant.slack ||
!tenant.slack.channels ||
tenant.slack.channels.length === 0
) {
return async () => {
// noop
};
}
const { channels } = tenant.slack;
return async (channel: SUBSCRIPTION_CHANNELS, payload: Payload) => {
const ctx = new SlackContext({ mongo, config, tenant });
try {
const reported = isReported(channel, payload);
const pending = isPending(channel, payload);
const featured = isFeatured(channel);
// If the comment doesn't match any filter, then we don't need to send
// anything.
if (!reported && !pending && !featured) {
return;
}
const { commentID } = payload;
for (const ch of channels) {
if (!ch) {
return;
}
if (!ch.enabled) {
return;
}
const { hookURL } = ch;
if (!hookURL) {
return;
}
const { triggers } = ch;
if (!triggers) {
return;
}
// Add ticket to add back all comments option (including approved)
if (triggers.reportedComments && reported) {
await postCommentToSlack(
ctx,
"This comment has been reported",
commentID,
hookURL
);
} else if (triggers.pendingComments && pending) {
await postCommentToSlack(
ctx,
"This comment is pending",
commentID,
hookURL
);
} else if (triggers.featuredComments && featured) {
await postCommentToSlack(
ctx,
"This comment has been featured",
commentID,
hookURL
);
}
}
} catch (err) {
logger.error(
{ err, tenantID: tenant.id, channel, payload },
"could not handle comment in Slack publisher"
);
}
};
}
export default createSlackPublisher;
+26 -4
View File
@@ -4,6 +4,8 @@ import { Db } from "mongodb";
import isNonNullArray from "coral-common/helpers/isNonNullArray";
import { Config } from "coral-server/config";
import { StoryURLInvalidError } from "coral-server/errors";
import { StoryCreatedCoralEvent } from "coral-server/events";
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
import logger from "coral-server/logger";
import {
mergeCommentActionCounts,
@@ -53,6 +55,7 @@ export type FindOrCreateStory = FindOrCreateStoryInput;
export async function findOrCreate(
mongo: Db,
tenant: Tenant,
broker: CoralEventPublisherBroker,
input: FindOrCreateStory,
scraper: ScraperQueue,
now = new Date()
@@ -70,11 +73,24 @@ export async function findOrCreate(
siteID = site.id;
}
const story = await findOrCreateStory(mongo, tenant.id, input, siteID, now);
const { story, wasUpserted } = await findOrCreateStory(
mongo,
tenant.id,
input,
siteID,
now
);
if (!story) {
return null;
}
if (wasUpserted) {
StoryCreatedCoralEvent.publish(broker, {
storyID: story.id,
storyURL: story.url,
});
}
if (tenant.stories.scraping.enabled && !story.metadata && !story.scrapedAt) {
// If the scraper has not scraped this story, and we have no metadata, we
// need to scrape it now!
@@ -160,6 +176,7 @@ export type CreateStory = Partial<
export async function create(
mongo: Db,
tenant: Tenant,
broker: CoralEventPublisherBroker,
config: Config,
storyID: string,
storyURL: string,
@@ -182,7 +199,7 @@ export async function create(
}
// Create the story in the database.
let newStory = await createStory(
let story = await createStory(
mongo,
tenant.id,
storyID,
@@ -193,10 +210,15 @@ export async function create(
if (!metadata && tenant.stories.scraping.enabled) {
// If the scraper has not scraped this story and story metadata was not
// provided, we need to scrape it now!
newStory = await scrape(mongo, config, tenant.id, newStory.id, storyURL);
story = await scrape(mongo, config, tenant.id, story.id, storyURL);
}
return newStory;
StoryCreatedCoralEvent.publish(broker, {
storyID: story.id,
storyURL: story.url,
});
return story;
}
export type UpdateStory = UpdateStoryInput;
@@ -5,19 +5,17 @@ import descriptionScraper from "metascraper-description";
import imageScraper from "metascraper-image";
import titleScraper from "metascraper-title";
import { Db } from "mongodb";
import fetch, { RequestInit } from "node-fetch";
import ProxyAgent from "proxy-agent";
import { version } from "coral-common/version";
import { Config } from "coral-server/config";
import { ScrapeFailed } from "coral-server/errors";
import logger from "coral-server/logger";
import { retrieveStory, updateStory } from "coral-server/models/story";
import { retrieveTenant } from "coral-server/models/tenant";
import { createFetch, Fetch, FetchOptions } from "coral-server/services/fetch";
import { GQLStoryMetadata } from "coral-server/graph/schema/__generated__/types";
import abortAfter from "./abortAfter";
import { modifiedScraper } from "./rules/modified";
import { publishedScraper } from "./rules/published";
import { sectionScraper } from "./rules/section";
@@ -32,8 +30,10 @@ export type Rule = Record<
class Scraper {
private readonly rules: Rule[];
private readonly log: Logger;
private readonly fetch: Fetch;
constructor(rules: Rule[]) {
this.fetch = createFetch({ name: "Scraper" });
this.rules = rules;
this.log = logger.child({ taskName: "scraper" }, true);
}
@@ -85,27 +85,25 @@ class Scraper {
public async download(
url: string,
abortAfterMilliseconds: number,
timeout: number,
customUserAgent?: string,
proxyURL?: string
) {
const log = this.log.child({ storyURL: url }, true);
// Abort the scrape request after the timeout is reached.
const { controller, timeout } = abortAfter(abortAfterMilliseconds);
const options: RequestInit = {
headers: {
"User-Agent": customUserAgent || `Talk Scraper/${version}`,
},
signal: controller.signal,
};
const options: FetchOptions = { timeout };
if (customUserAgent) {
options.headers = {
...options.headers,
"User-Agent": customUserAgent,
};
}
if (proxyURL) {
// Force the type here because there's a slight mismatch.
options.agent = (new ProxyAgent(
proxyURL
) as unknown) as RequestInit["agent"];
) as unknown) as FetchOptions["agent"];
log.debug("using proxy for scrape");
}
@@ -113,8 +111,8 @@ class Scraper {
log.debug("starting scrape of Story");
try {
const res = await fetch(url, options);
if (!res.ok || res.status !== 200) {
const res = await this.fetch(url, options);
if (!res.ok) {
log.warn(
{ statusCode: res.status, statusText: res.statusText },
"scrape failed with non-200 status code"
@@ -129,8 +127,6 @@ class Scraper {
return html;
} catch (err) {
throw new ScrapeFailed(url, err);
} finally {
clearTimeout(timeout);
}
}
@@ -198,14 +194,12 @@ export async function scrape(
// This typecast is needed because the custom `ms` format does not return the
// desired `number` type even though that's the only type it can output.
const abortAfterMilliseconds = (config.get(
"scrape_timeout"
) as unknown) as number;
const timeout = (config.get("scrape_timeout") as unknown) as number;
// Get the metadata from the scraped html.
const metadata = await scraper.scrape(
storyURL,
abortAfterMilliseconds,
timeout,
tenant.stories.scraping.customUserAgent,
tenant.stories.scraping.proxyURL
);
+260
View File
@@ -14,12 +14,19 @@ import {
createTenantAnnouncement,
CreateTenantInput,
createTenantSSOKey,
createTenantWebhookEndpoint,
CreateTenantWebhookEndpointInput,
deleteTenantAnnouncement,
deleteTenantWebhookEndpoint,
disableTenantFeatureFlag,
enableTenantFeatureFlag,
getWebhookEndpoint,
rollTenantWebhookEndpointSecret,
rotateTenantSSOKey,
Tenant,
updateTenant,
updateTenantWebhookEndpoint,
UpdateTenantWebhookEndpointInput,
} from "coral-server/models/tenant";
import { I18n } from "coral-server/services/i18n";
@@ -27,6 +34,7 @@ import {
GQLFEATURE_FLAG,
GQLSettingsInput,
GQLSettingsWordListInput,
GQLWEBHOOK_EVENT_NAME,
} from "coral-server/graph/schema/__generated__/types";
import TenantCache from "./cache";
@@ -203,6 +211,258 @@ export async function discoverOIDCConfiguration(issuerString: string) {
return discover(issuer);
}
interface WebhookEndpointInput {
url: string;
all: boolean;
events: GQLWEBHOOK_EVENT_NAME[];
}
export function validateWebhookEndpointInput(
config: Config,
input: WebhookEndpointInput
) {
// Check to see that this URL is valid and has a https:// scheme if in
// production mode.
const url = new URL(input.url);
if (config.get("env") === "production" && url.protocol !== "https:") {
throw new Error(`invalid scheme provided in production: ${url.protocol}`);
}
// Ensure that either the "all" or "events" is provided but not both.
if (input.all && input.events.length > 0) {
throw new Error("both all events and specific events were requested");
}
}
export async function createWebhookEndpoint(
mongo: Db,
redis: Redis,
config: Config,
cache: TenantCache,
tenant: Tenant,
input: CreateTenantWebhookEndpointInput,
now: Date
) {
// Validate the input.
validateWebhookEndpointInput(config, input);
// Looks good in create this, send it off to be created.
const result = await createTenantWebhookEndpoint(
mongo,
tenant.id,
input,
now
);
if (!result.tenant) {
throw new Error("could not create the tenant endpoint, tenant not found");
}
// Update the tenant cache.
await cache.update(redis, result.tenant);
return {
endpoint: result.endpoint,
settings: result.tenant,
};
}
export async function updateWebhookEndpoint(
mongo: Db,
redis: Redis,
config: Config,
cache: TenantCache,
tenant: Tenant,
endpointID: string,
input: UpdateTenantWebhookEndpointInput
) {
// Find the endpoint.
let endpoint = getWebhookEndpoint(tenant, endpointID);
if (!endpoint) {
throw new Error("referenced endpoint was not found on tenant");
}
// Extract the input.
const {
url = endpoint.url,
all = endpoint.all,
events = endpoint.events,
} = input;
// Validate the input.
validateWebhookEndpointInput(config, {
url,
all,
events,
});
const updatedTenant = await updateTenantWebhookEndpoint(
mongo,
tenant.id,
endpointID,
input
);
if (!updatedTenant) {
throw new Error("tenant not found");
}
// Update the tenant cache.
await cache.update(redis, updatedTenant);
// Find the updated endpoint.
endpoint = getWebhookEndpoint(updatedTenant, endpointID);
if (!endpoint) {
throw new Error("referenced endpoint was not found on tenant");
}
return endpoint;
}
export async function enableWebhookEndpoint(
mongo: Db,
redis: Redis,
cache: TenantCache,
tenant: Tenant,
endpointID: string
) {
// Find the endpoint.
let endpoint = getWebhookEndpoint(tenant, endpointID);
if (!endpoint) {
throw new Error("referenced endpoint was not found on tenant");
}
// Endpoint is already enabled.
if (endpoint.enabled === true) {
return endpoint;
}
const updatedTenant = await updateTenantWebhookEndpoint(
mongo,
tenant.id,
endpointID,
{ enabled: true }
);
if (!updatedTenant) {
throw new Error("tenant not found");
}
// Update the tenant cache.
await cache.update(redis, updatedTenant);
// Find the updated endpoint.
endpoint = getWebhookEndpoint(updatedTenant, endpointID);
if (!endpoint) {
throw new Error("referenced endpoint was not found on tenant");
}
return endpoint;
}
export async function disableWebhookEndpoint(
mongo: Db,
redis: Redis,
cache: TenantCache,
tenant: Tenant,
endpointID: string
) {
// Find the endpoint.
let endpoint = getWebhookEndpoint(tenant, endpointID);
if (!endpoint) {
throw new Error("referenced endpoint was not found on tenant");
}
// Endpoint is already disabled.
if (endpoint.enabled === false) {
return endpoint;
}
const updatedTenant = await updateTenantWebhookEndpoint(
mongo,
tenant.id,
endpointID,
{ enabled: false }
);
if (!updatedTenant) {
throw new Error("tenant not found");
}
// Update the tenant cache.
await cache.update(redis, updatedTenant);
// Find the updated endpoint.
endpoint = getWebhookEndpoint(updatedTenant, endpointID);
if (!endpoint) {
throw new Error("referenced endpoint was not found on tenant");
}
return endpoint;
}
export async function deleteWebhookEndpoint(
mongo: Db,
redis: Redis,
cache: TenantCache,
tenant: Tenant,
endpointID: string
) {
// Find the endpoint.
const endpoint = getWebhookEndpoint(tenant, endpointID);
if (!endpoint) {
throw new Error("referenced endpoint was not found on tenant");
}
const updatedTenant = await deleteTenantWebhookEndpoint(
mongo,
tenant.id,
endpointID
);
if (!updatedTenant) {
throw new Error("tenant not found");
}
// Update the tenant cache.
await cache.update(redis, updatedTenant);
return endpoint;
}
export async function rotateWebhookEndpointSecret(
mongo: Db,
redis: Redis,
cache: TenantCache,
tenant: Tenant,
endpointID: string,
inactiveIn: number,
now: Date
) {
// Compute the inactiveAt dates for the current active secrets.
const inactiveAt = DateTime.fromJSDate(now)
.plus({ seconds: inactiveIn })
.toJSDate();
// Rotate the secrets.
const updatedTenant = await rollTenantWebhookEndpointSecret(
mongo,
tenant.id,
endpointID,
inactiveAt,
now
);
if (!updatedTenant) {
throw new Error("tenant not found");
}
// Update the tenant cache.
await cache.update(redis, updatedTenant);
// Find the updated endpoint.
const endpoint = getWebhookEndpoint(updatedTenant, endpointID);
if (!endpoint) {
throw new Error("referenced endpoint was not found on tenant");
}
return endpoint;
}
export async function enableFeatureFlag(
mongo: Db,
redis: Redis,
+11 -8
View File
@@ -4,7 +4,15 @@ import { DateTime } from "luxon";
import { Db } from "mongodb";
import uuid from "uuid";
import { constructTenantURL } from "coral-server/app/url";
import { Config } from "coral-server/config";
import {
IntegrationDisabled,
InviteIncludesExistingUser,
InviteRequiresEmailAddresses,
InviteTokenExpired,
TokenInvalidError,
} from "coral-server/errors";
import {
createInvite,
Invite,
@@ -28,15 +36,8 @@ import {
verifyJWT,
} from "coral-server/services/jwt";
import { constructTenantURL } from "coral-server/app/url";
import {
IntegrationDisabled,
InviteIncludesExistingUser,
InviteRequiresEmailAddresses,
InviteTokenExpired,
TokenInvalidError,
} from "coral-server/errors";
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
import { validateEmail, validatePassword, validateUsername } from "../helpers";
export interface InviteToken extends Required<StandardClaims> {
@@ -324,5 +325,7 @@ export async function redeem(
now
);
// TODO: (wyattjoh) emit that a user was created
return user;
}
@@ -6,9 +6,11 @@ import htmlToText from "html-to-text";
import { kebabCase } from "lodash";
import { Db } from "mongodb";
import { getLatestRevision } from "coral-server/models/comment";
import { Comment } from "coral-server/models/comment";
import { retrieveManyStories } from "coral-server/models/story";
import { Comment, getLatestRevision } from "coral-server/models/comment";
import {
getURLWithCommentID,
retrieveManyStories,
} from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
@@ -102,15 +104,11 @@ export async function sendUserDownload(
const revision = getLatestRevision(comment);
const commentID = comment.id;
const createdAt = formatter.format(new Date(comment.createdAt));
const storyURL = story.url;
const urlBuilder = new URL(storyURL);
urlBuilder.searchParams.set("commentID", commentID);
const commentURL = urlBuilder.href;
const body = htmlToText.fromString(revision.body);
const commentURL = getURLWithCommentID(story.url, comment.id);
csv.write([commentID, createdAt, storyURL, commentURL, body]);
csv.write([comment.id, createdAt, story.url, commentURL, body]);
}
commentBatch = [];
+19 -8
View File
@@ -5,8 +5,8 @@ import {
ALLOWED_USERNAME_CHANGE_TIMEFRAME_DURATION,
COMMENT_REPEAT_POST_DURATION,
DOWNLOAD_LIMIT_TIMEFRAME_DURATION,
SCHEDULED_DELETION_WINDOW_DURATION,
} from "coral-common/constants";
import { SCHEDULED_DELETION_WINDOW_DURATION } from "coral-common/constants";
import { Config } from "coral-server/config";
import {
DuplicateEmailError,
@@ -25,10 +25,6 @@ import {
UsernameUpdatedWithinWindowError,
UserNotFoundError,
} from "coral-server/errors";
import {
GQLAuthIntegrations,
GQLUSER_ROLE,
} from "coral-server/graph/schema/__generated__/types";
import logger from "coral-server/logger";
import { Comment, retrieveComment } from "coral-server/models/comment";
import { Tenant } from "coral-server/models/tenant";
@@ -72,12 +68,16 @@ import {
import {
getLocalProfile,
hasLocalProfile,
hasStaffRole,
} from "coral-server/models/user/helpers";
import { hasStaffRole } from "coral-server/models/user/helpers";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { JWTSigningConfig, signPATString } from "coral-server/services/jwt";
import { sendConfirmationEmail } from "coral-server/services/users/auth";
import { JWTSigningConfig, signPATString } from "coral-server/services/jwt";
import {
GQLAuthIntegrations,
GQLUSER_ROLE,
} from "coral-server/graph/schema/__generated__/types";
import { AugmentedRedis } from "../redis";
import {
@@ -125,7 +125,16 @@ export async function findOrCreate(
// Validate the input.
validateFindOrCreateUserInput(input, options);
const user = await findOrCreateUser(mongo, tenant.id, input, now);
const { user, wasUpserted } = await findOrCreateUser(
mongo,
tenant.id,
input,
now
);
if (wasUpserted) {
// TODO: (wyattjoh) emit that a user was created
}
// TODO: (wyattjoh) evaluate the tenant to determine if we should send the verification email.
@@ -169,6 +178,8 @@ export async function create(
const user = await createUser(mongo, tenant.id, input, now);
// TODO: (wyattjoh) emit that a user was created
// TODO: (wyattjoh) evaluate the tenant to determine if we should send the verification email.
return user;
+3 -3
View File
@@ -1,7 +1,7 @@
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import { Publisher } from "coral-server/graph/subscriptions/publisher";
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
import { Tenant } from "coral-server/models/tenant";
import { moderate } from "coral-server/services/comments/moderation";
import { notifyPerspectiveModerationDecision } from "coral-server/services/perspective";
@@ -15,7 +15,7 @@ const approveComment = async (
mongo: Db,
redis: AugmentedRedis,
config: Config,
publisher: Publisher,
broker: CoralEventPublisherBroker,
tenant: Tenant,
commentID: string,
commentRevisionID: string,
@@ -44,7 +44,7 @@ const approveComment = async (
});
// Publish changes to the event publisher.
await publishChanges(publisher, {
await publishChanges(broker, {
...result,
...counts,
moderatorID,
+5 -5
View File
@@ -8,7 +8,7 @@ import {
CoralError,
StoryNotFoundError,
} from "coral-server/errors";
import { Publisher } from "coral-server/graph/subscriptions/publisher";
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
import logger from "coral-server/logger";
import {
encodeActionCounts,
@@ -59,7 +59,7 @@ export default async function create(
mongo: Db,
redis: AugmentedRedis,
config: Config,
publisher: Publisher,
broker: CoralEventPublisherBroker,
tenant: Tenant,
author: User,
input: CreateComment,
@@ -233,19 +233,19 @@ export default async function create(
});
// Publish changes to the event publisher.
await publishChanges(publisher, {
await publishChanges(broker, {
...counts,
after: comment,
});
// If this is a reply, publish it.
if (input.parentID) {
publishCommentReplyCreated(publisher, comment);
publishCommentReplyCreated(broker, comment);
}
// If this comment is visible (and not a reply), publish it.
if (!input.parentID && hasPublishedStatus(comment)) {
publishCommentCreated(publisher, comment);
publishCommentCreated(broker, comment);
}
return comment;
+3 -3
View File
@@ -4,7 +4,7 @@ import { Db } from "mongodb";
import { Omit } from "coral-common/types";
import { Config } from "coral-server/config";
import { CommentNotFoundError, StoryNotFoundError } from "coral-server/errors";
import { Publisher } from "coral-server/graph/subscriptions/publisher";
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
import logger from "coral-server/logger";
import {
encodeActionCounts,
@@ -57,7 +57,7 @@ export default async function edit(
mongo: Db,
redis: AugmentedRedis,
config: Config,
publisher: Publisher,
broker: CoralEventPublisherBroker,
tenant: Tenant,
author: User,
input: EditComment,
@@ -188,7 +188,7 @@ export default async function edit(
});
// Publish changes to the event publisher.
await publishChanges(publisher, {
await publishChanges(broker, {
...result,
...counts,
});
@@ -1,4 +1,4 @@
import { Publisher } from "coral-server/graph/subscriptions/publisher";
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
import {
Comment,
CommentModerationQueueCounts,
@@ -19,17 +19,17 @@ interface PublishChangesInput {
}
export default async function publishChanges(
publish: Publisher,
broker: CoralEventPublisherBroker,
input: PublishChangesInput
) {
// Publish changes.
publishModerationQueueChanges(publish, input.moderationQueue, input.after);
publishModerationQueueChanges(broker, input.moderationQueue, input.after);
// If this was a change, and it has a "before" state for the comment, process
// those updates too.
if (input.before) {
publishCommentStatusChanges(
publish,
broker,
input.before.status,
input.after.status,
input.after.id,
@@ -37,7 +37,7 @@ export default async function publishChanges(
);
if (hasModeratorStatus(input.before) && hasPublishedStatus(input.after)) {
publishCommentReleased(publish, input.after);
publishCommentReleased(broker, input.after);
}
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import { Publisher } from "coral-server/graph/subscriptions/publisher";
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
import { hasTag } from "coral-server/models/comment";
import { Tenant } from "coral-server/models/tenant";
import { removeTag } from "coral-server/services/comments";
@@ -20,7 +20,7 @@ const rejectComment = async (
mongo: Db,
redis: AugmentedRedis,
config: Config,
publisher: Publisher,
broker: CoralEventPublisherBroker,
tenant: Tenant,
commentID: string,
commentRevisionID: string,
@@ -49,7 +49,7 @@ const rejectComment = async (
});
// Publish changes to the event publisher.
await publishChanges(publisher, {
await publishChanges(broker, {
...result,
...counts,
moderatorID,