mirror of
https://github.com/wassname/talk.git
synced 2026-08-16 11:29:31 +08:00
[CORL-810] Custom Moderation Phases (#2901)
* feat: initial implementation
* feat: renamed fields from mutations
* fix: more renaming to streamline {Key,Secret}->SigningSecret
* feat: introduced WrappedInternalError
* feat: enhanced extern payload, more fetch options
- Added tenant.{id,domain} to extern payload
- Added site.id to the extern payload
- Added response size limit to fetch
- Added new SCRAPE_MAX_RESPONSE_SIZE env var for managing the size of
responses for scraping
* fix: fixed bug with scrape invocation
* feat: added more queries + mutations
- Added Query.externalModerationPhase
- Added Mutation.createExternalModerationPhase
- Added Mutation.updateExternalModerationPhase
- Added Mutation.enableExternalModerationPhase
- Added Mutation.disableExternalModerationPhase
- Added Mutation.deleteExternalModerationPhase
- Added Mutation.rotateExternalModerationPhaseSigningSecret
* feat: added secret management
* fix: linting
* fix: merge conflict fix
* feat: added UI
* fix: linting
* fix: linting
* fix: updated snapshots
* fix: improved docs
* fix: improved docs
* fix: added locales
* review: improve naming
* review: some review changes
- Switched /moderation/phase to /moderation/phases
- Fixed scrolling
- Fixed redirection
* fix: added scroll timeout for webhooks
This commit is contained in:
@@ -35,7 +35,7 @@ import { Metrics } from "coral-server/services/metrics";
|
||||
import { MigrationManager } from "coral-server/services/migrate";
|
||||
import { PersistedQueryCache } from "coral-server/services/queries";
|
||||
import { AugmentedRedis } from "coral-server/services/redis";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
|
||||
import { healthHandler, versionHandler } from "./handlers";
|
||||
import { compileTrust } from "./helpers";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FluentBundle } from "@fluent/bundle/compat";
|
||||
|
||||
import { CoralError, InternalError } from "coral-server/errors";
|
||||
import { CoralError, WrappedInternalError } from "coral-server/errors";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { ErrorRequestHandler, Request } from "coral-server/types/express";
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ErrorRequestHandler, Request } from "coral-server/types/express";
|
||||
const wrapError = (err: Error) =>
|
||||
err instanceof CoralError
|
||||
? err
|
||||
: new InternalError(err, "wrapped internal error");
|
||||
: new WrappedInternalError(err, "wrapped internal error");
|
||||
|
||||
/**
|
||||
* serializeError will return a serialized error that can be returned via the
|
||||
|
||||
@@ -7,8 +7,10 @@ import { IntegrationDisabled } from "coral-server/errors";
|
||||
import { AuthIntegrations } from "coral-server/models/settings";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter";
|
||||
import {
|
||||
TenantCache,
|
||||
TenantCacheAdapter,
|
||||
} from "coral-server/services/tenant/cache";
|
||||
import { Request } from "coral-server/types/express";
|
||||
import { Profile } from "passport";
|
||||
import { VerifyCallback } from "passport-oauth2";
|
||||
|
||||
@@ -23,8 +23,10 @@ import {
|
||||
User,
|
||||
} from "coral-server/models/user";
|
||||
import { AsymmetricSigningAlgorithm } from "coral-server/services/jwt";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter";
|
||||
import {
|
||||
TenantCache,
|
||||
TenantCacheAdapter,
|
||||
} from "coral-server/services/tenant/cache";
|
||||
import { findOrCreate } from "coral-server/services/users";
|
||||
import { validateUsername } from "coral-server/services/users/helpers";
|
||||
import { Request } from "coral-server/types/express";
|
||||
|
||||
@@ -2,10 +2,10 @@ import jwks, { JwksClient } from "jwks-rsa";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter";
|
||||
|
||||
import logger from "coral-server/logger";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { TenantCacheAdapter } from "coral-server/services/tenant/cache";
|
||||
|
||||
import { Verifier } from "../jwt";
|
||||
import {
|
||||
findOrCreateOIDCUserWithToken,
|
||||
|
||||
@@ -7,10 +7,14 @@ import { Db } from "mongodb";
|
||||
import { validate } from "coral-server/app/request/body";
|
||||
import { IntegrationDisabled, TokenInvalidError } from "coral-server/errors";
|
||||
import logger from "coral-server/logger";
|
||||
import { Secret, SSOAuthIntegration } from "coral-server/models/settings";
|
||||
import {
|
||||
filterActiveSigningSecrets,
|
||||
SigningSecret,
|
||||
SSOAuthIntegration,
|
||||
} from "coral-server/models/settings";
|
||||
import {
|
||||
Tenant,
|
||||
updateLastUsedAtTenantSSOKey,
|
||||
updateLastUsedAtTenantSSOSigningSecret,
|
||||
} from "coral-server/models/tenant";
|
||||
import {
|
||||
retrieveUserWithProfile,
|
||||
@@ -167,7 +171,7 @@ export async function findOrCreateSSOUser(
|
||||
const updateLastUsedAtKID = throttle(
|
||||
async (redis: Redis, tenantID: string, kid: string, now: Date) => {
|
||||
try {
|
||||
await updateLastUsedAtTenantSSOKey(redis, tenantID, kid, now);
|
||||
await updateLastUsedAtTenantSSOSigningSecret(redis, tenantID, kid, now);
|
||||
logger.trace({ tenantID, kid }, "updated last used tenant sso key");
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
@@ -185,23 +189,18 @@ export interface SSOVerifierOptions {
|
||||
redis: AugmentedRedis;
|
||||
}
|
||||
|
||||
export function getRelevantSSOKeys(
|
||||
export function getRelevantSSOSigningSecrets(
|
||||
integration: SSOAuthIntegration,
|
||||
tokenString: string,
|
||||
now: Date,
|
||||
kid?: string
|
||||
): Secret[] {
|
||||
): SigningSecret[] {
|
||||
// Collect all the current valid keys.
|
||||
const keys = integration.keys.filter((k) => {
|
||||
if (k.inactiveAt && now >= k.inactiveAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return k;
|
||||
});
|
||||
|
||||
// If there is only one key, that's all we can use!
|
||||
const keys = integration.signingSecrets.filter(
|
||||
filterActiveSigningSecrets(now)
|
||||
);
|
||||
if (keys.length === 1) {
|
||||
// There is only one key, that's all we can use!
|
||||
return keys;
|
||||
}
|
||||
|
||||
@@ -259,14 +258,13 @@ export class SSOVerifier implements Verifier<SSOToken> {
|
||||
throw new IntegrationDisabled("sso");
|
||||
}
|
||||
|
||||
// check to see if there is at least one key associated with this
|
||||
// integration.
|
||||
if (integration.keys.length === 0) {
|
||||
throw new Error("integration key does not exist");
|
||||
}
|
||||
|
||||
// Get the valid configurations for the given token and integration pair.
|
||||
const keys = getRelevantSSOKeys(integration, tokenString, now, kid);
|
||||
const keys = getRelevantSSOSigningSecrets(
|
||||
integration,
|
||||
tokenString,
|
||||
now,
|
||||
kid
|
||||
);
|
||||
if (keys.length === 0) {
|
||||
throw new TokenInvalidError(
|
||||
tokenString,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { v1 as uuid } from "uuid";
|
||||
|
||||
import { TenantNotFoundError } from "coral-server/errors";
|
||||
import logger from "coral-server/logger";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
export interface MiddlewareOptions {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { cspSiteMiddleware } from "coral-server/app/middleware/csp/tenant";
|
||||
import { installedMiddleware } from "coral-server/app/middleware/installed";
|
||||
import { tenantMiddleware } from "coral-server/app/middleware/tenant";
|
||||
import logger from "coral-server/logger";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
import Entrypoints, { Entrypoint } from "../helpers/entrypoints";
|
||||
|
||||
@@ -7,7 +7,7 @@ import os from "os";
|
||||
import { LOCALES } from "coral-common/helpers/i18n/locales";
|
||||
import { ensureEndSlash } from "coral-common/utils";
|
||||
|
||||
import { InternalError } from "./errors";
|
||||
import { WrappedInternalError } from "./errors";
|
||||
|
||||
// Add custom format for the mongo uri scheme.
|
||||
convict.addFormat({
|
||||
@@ -15,7 +15,7 @@ convict.addFormat({
|
||||
validate: (url: string) => {
|
||||
parseConnectionString(url, (err) => {
|
||||
if (err) {
|
||||
throw new InternalError(err, "invalid mongo-uri");
|
||||
throw new WrappedInternalError(err, "invalid mongo-uri");
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -237,6 +237,13 @@ const config = convict({
|
||||
default: false,
|
||||
env: "DISABLE_RATE_LIMITERS",
|
||||
},
|
||||
scrape_max_response_size: {
|
||||
doc: "The maximum size (in bytes) to allow for scraping responses.",
|
||||
format: Number,
|
||||
default: 10e6,
|
||||
env: "SCRAPE_MAX_RESPONSE_SIZE",
|
||||
arg: "scrapeMaxResponseSize",
|
||||
},
|
||||
scrape_timeout: {
|
||||
doc: "The request timeout (in ms) for scraping operations.",
|
||||
format: "ms",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Db } from "mongodb";
|
||||
|
||||
import { retrieveUserScheduledForDeletion } from "coral-server/models/user";
|
||||
import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
import { deleteUser } from "coral-server/services/users/delete";
|
||||
|
||||
import {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Db } from "mongodb";
|
||||
import { Config } from "coral-server/config";
|
||||
import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
import { JWTSigningConfig } from "coral-server/services/jwt";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
|
||||
import { registerAccountDeletion } from "./accountDeletion";
|
||||
import { registerNotificationDigesting } from "./notificationDigesting";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates";
|
||||
import { JWTSigningConfig } from "coral-server/services/jwt";
|
||||
import NotificationContext from "coral-server/services/notifications/context";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
|
||||
import {
|
||||
ScheduledJob,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { VError } from "verror";
|
||||
|
||||
import { CoralError, DuplicateUserError, InternalError } from ".";
|
||||
import { CoralError, DuplicateUserError, WrappedInternalError } from ".";
|
||||
|
||||
it("has the right inheritance chain", () => {
|
||||
const err = new DuplicateUserError();
|
||||
@@ -14,12 +14,12 @@ it("has the right inheritance chain", () => {
|
||||
});
|
||||
|
||||
it("provides an accurate stack", () => {
|
||||
const err = new InternalError(
|
||||
const err = new WrappedInternalError(
|
||||
new Error("this is a test"),
|
||||
"this is the reason"
|
||||
);
|
||||
|
||||
expect(err).toBeInstanceOf(InternalError);
|
||||
expect(err).toBeInstanceOf(WrappedInternalError);
|
||||
expect(err).toBeInstanceOf(CoralError);
|
||||
expect(err).toBeInstanceOf(VError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
|
||||
@@ -465,7 +465,7 @@ export class IntegrationDisabled extends CoralError {
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalError extends CoralError {
|
||||
export class WrappedInternalError extends CoralError {
|
||||
constructor(cause: Error, reason: string) {
|
||||
super({
|
||||
code: ERROR_CODES.INTERNAL_ERROR,
|
||||
@@ -476,6 +476,16 @@ export class InternalError extends CoralError {
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalError extends CoralError {
|
||||
constructor(reason: string, context?: Record<string, any>) {
|
||||
super({
|
||||
code: ERROR_CODES.INTERNAL_ERROR,
|
||||
context: { pvt: { reason, ...context } },
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalDevelopmentError extends CoralError {
|
||||
constructor(cause: Error, reason: string) {
|
||||
super({
|
||||
|
||||
@@ -19,7 +19,7 @@ import { WebhookQueue } from "coral-server/queue/tasks/webhook";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { JWTSigningConfig } from "coral-server/services/jwt";
|
||||
import { AugmentedRedis } from "coral-server/services/redis";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
import { Request } from "coral-server/types/express";
|
||||
|
||||
import loaders from "./loaders";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { merge } from "lodash";
|
||||
import {
|
||||
CoralError,
|
||||
InternalDevelopmentError,
|
||||
InternalError,
|
||||
WrappedInternalError,
|
||||
} from "coral-server/errors";
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import { getOriginalError } from "./helpers";
|
||||
@@ -71,7 +71,7 @@ function getWrappedOriginalError(
|
||||
);
|
||||
}
|
||||
|
||||
return new InternalError(originalError, "wrapped internal error");
|
||||
return new WrappedInternalError(originalError, "wrapped internal error");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import DataLoader from "dataloader";
|
||||
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import { retrieveLastUsedAtTenantSSOKeys } from "coral-server/models/tenant";
|
||||
import { retrieveLastUsedAtTenantSSOSigningSecrets } from "coral-server/models/tenant";
|
||||
import { discoverOIDCConfiguration } from "coral-server/services/tenant";
|
||||
|
||||
import { GQLDiscoveredOIDCConfiguration } from "coral-server/graph/schema/__generated__/types";
|
||||
@@ -19,7 +19,7 @@ export default (ctx: GraphContext) => ({
|
||||
cache: !ctx.disableCaching,
|
||||
}
|
||||
),
|
||||
retrieveSSOKeyLastUsedAt: new DataLoader((kids: string[]) =>
|
||||
retrieveLastUsedAtTenantSSOKeys(ctx.redis, ctx.tenant.id, kids)
|
||||
retrieveSSOSigningSecretLastUsedAt: new DataLoader((kids: string[]) =>
|
||||
retrieveLastUsedAtTenantSSOSigningSecrets(ctx.redis, ctx.tenant.id, kids)
|
||||
),
|
||||
});
|
||||
|
||||
@@ -211,12 +211,13 @@ export default (ctx: GraphContext) => ({
|
||||
// This typecast is needed because the custom `ms` format does not return
|
||||
// the desired `number` type even though that's the only type it can
|
||||
// output.
|
||||
scraper.scrape(
|
||||
scraper.scrape({
|
||||
url,
|
||||
(ctx.config.get("scrape_timeout") as unknown) as number,
|
||||
ctx.tenant.stories.scraping.customUserAgent,
|
||||
ctx.tenant.stories.scraping.proxyURL
|
||||
)
|
||||
timeout: (ctx.config.get("scrape_timeout") as unknown) as number,
|
||||
size: ctx.config.get("scrape_response_max_size"),
|
||||
customUserAgent: ctx.tenant.stories.scraping.customUserAgent,
|
||||
proxyURL: ctx.tenant.stories.scraping.proxyURL,
|
||||
})
|
||||
),
|
||||
{
|
||||
// Disable caching for the DataLoader if the Context is designed to be
|
||||
|
||||
@@ -2,34 +2,46 @@ import GraphContext from "coral-server/graph/context";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import {
|
||||
createAnnouncement,
|
||||
createExternalModerationPhase,
|
||||
createWebhookEndpoint,
|
||||
deactivateSSOKey,
|
||||
deactivateSSOSigningSecret,
|
||||
deleteAnnouncement,
|
||||
deleteSSOKey,
|
||||
deleteExternalModerationPhase,
|
||||
deleteSSOSigningSecret,
|
||||
deleteWebhookEndpoint,
|
||||
disableExternalModerationPhase,
|
||||
disableFeatureFlag,
|
||||
disableWebhookEndpoint,
|
||||
enableExternalModerationPhase,
|
||||
enableFeatureFlag,
|
||||
enableWebhookEndpoint,
|
||||
regenerateSSOKey,
|
||||
rotateSSOKey,
|
||||
rotateWebhookEndpointSecret,
|
||||
rotateExternalModerationPhaseSigningSecret,
|
||||
rotateSSOSigningSecret,
|
||||
rotateWebhookEndpointSigningSecret,
|
||||
sendSMTPTest,
|
||||
update,
|
||||
updateExternalModerationPhase,
|
||||
updateWebhookEndpoint,
|
||||
} from "coral-server/services/tenant";
|
||||
|
||||
import {
|
||||
GQLCreateAnnouncementInput,
|
||||
GQLCreateExternalModerationPhaseInput,
|
||||
GQLCreateWebhookEndpointInput,
|
||||
GQLDeactivateSSOKeyInput,
|
||||
GQLDeleteSSOKeyInput,
|
||||
GQLDeactivateSSOSigningSecretInput,
|
||||
GQLDeleteExternalModerationPhaseInput,
|
||||
GQLDeleteSSOSigningSecretInput,
|
||||
GQLDeleteWebhookEndpointInput,
|
||||
GQLDisableExternalModerationPhaseInput,
|
||||
GQLDisableWebhookEndpointInput,
|
||||
GQLEnableExternalModerationPhaseInput,
|
||||
GQLEnableWebhookEndpointInput,
|
||||
GQLFEATURE_FLAG,
|
||||
GQLRotateSSOKeyInput,
|
||||
GQLRotateWebhookEndpointSecretInput,
|
||||
GQLRotateExternalModerationPhaseSigningSecretInput,
|
||||
GQLRotateSSOSigningSecretInput,
|
||||
GQLRotateWebhookEndpointSigningSecretInput,
|
||||
GQLUpdateExternalModerationPhaseInput,
|
||||
GQLUpdateSettingsInput,
|
||||
GQLUpdateWebhookEndpointInput,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
@@ -50,20 +62,21 @@ export const Settings = ({
|
||||
input: WithoutMutationID<GQLUpdateSettingsInput>
|
||||
): Promise<Tenant | null> =>
|
||||
update(mongo, redis, tenantCache, config, tenant, input.settings),
|
||||
// DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
|
||||
regenerateSSOKey: (): Promise<Tenant | null> =>
|
||||
regenerateSSOKey(mongo, redis, tenantCache, tenant, now),
|
||||
rotateSSOKey: ({ inactiveIn }: GQLRotateSSOKeyInput) =>
|
||||
rotateSSOKey(mongo, redis, tenantCache, tenant, inactiveIn, now),
|
||||
deactivateSSOKey: ({ kid }: GQLDeactivateSSOKeyInput) =>
|
||||
deactivateSSOKey(mongo, redis, tenantCache, tenant, kid, now),
|
||||
deleteSSOKey: ({ kid }: GQLDeleteSSOKeyInput) =>
|
||||
deleteSSOKey(mongo, redis, tenantCache, tenant, kid),
|
||||
rotateSSOSigningSecret: ({ inactiveIn }: GQLRotateSSOSigningSecretInput) =>
|
||||
rotateSSOSigningSecret(mongo, redis, tenantCache, tenant, inactiveIn, now),
|
||||
deleteSSOSigningSecret: ({ kid }: GQLDeleteSSOSigningSecretInput) =>
|
||||
deleteSSOSigningSecret(mongo, redis, tenantCache, tenant, kid),
|
||||
deactivateSSOSigningSecret: ({ kid }: GQLDeactivateSSOSigningSecretInput) =>
|
||||
deactivateSSOSigningSecret(mongo, redis, tenantCache, tenant, kid, now),
|
||||
enableFeatureFlag: (flag: GQLFEATURE_FLAG) =>
|
||||
enableFeatureFlag(mongo, redis, tenantCache, tenant, flag),
|
||||
disableFeatureFlag: (flag: GQLFEATURE_FLAG) =>
|
||||
disableFeatureFlag(mongo, redis, tenantCache, tenant, flag),
|
||||
createAnnouncement: (input: GQLCreateAnnouncementInput) =>
|
||||
createAnnouncement(mongo, redis, tenantCache, tenant, input, now),
|
||||
createAnnouncement(mongo, redis, tenantCache, tenant, input),
|
||||
deleteAnnouncement: () =>
|
||||
deleteAnnouncement(mongo, redis, tenantCache, tenant),
|
||||
createWebhookEndpoint: (
|
||||
@@ -92,10 +105,59 @@ export const Settings = ({
|
||||
deleteWebhookEndpoint: (
|
||||
input: WithoutMutationID<GQLDeleteWebhookEndpointInput>
|
||||
) => deleteWebhookEndpoint(mongo, redis, tenantCache, tenant, input.id),
|
||||
rotateWebhookEndpointSecret: (
|
||||
input: WithoutMutationID<GQLRotateWebhookEndpointSecretInput>
|
||||
rotateWebhookEndpointSigningSecret: (
|
||||
input: WithoutMutationID<GQLRotateWebhookEndpointSigningSecretInput>
|
||||
) =>
|
||||
rotateWebhookEndpointSecret(
|
||||
rotateWebhookEndpointSigningSecret(
|
||||
mongo,
|
||||
redis,
|
||||
tenantCache,
|
||||
tenant,
|
||||
input.id,
|
||||
input.inactiveIn,
|
||||
now
|
||||
),
|
||||
createExternalModerationPhase: (
|
||||
input: WithoutMutationID<GQLCreateExternalModerationPhaseInput>
|
||||
) =>
|
||||
createExternalModerationPhase(
|
||||
mongo,
|
||||
redis,
|
||||
config,
|
||||
tenantCache,
|
||||
tenant,
|
||||
input,
|
||||
now
|
||||
),
|
||||
updateExternalModerationPhase: ({
|
||||
id,
|
||||
...input
|
||||
}: WithoutMutationID<GQLUpdateExternalModerationPhaseInput>) =>
|
||||
updateExternalModerationPhase(
|
||||
mongo,
|
||||
redis,
|
||||
config,
|
||||
tenantCache,
|
||||
tenant,
|
||||
id,
|
||||
input
|
||||
),
|
||||
enableExternalModerationPhase: (
|
||||
input: WithoutMutationID<GQLEnableExternalModerationPhaseInput>
|
||||
) =>
|
||||
enableExternalModerationPhase(mongo, redis, tenantCache, tenant, input.id),
|
||||
disableExternalModerationPhase: (
|
||||
input: WithoutMutationID<GQLDisableExternalModerationPhaseInput>
|
||||
) =>
|
||||
disableExternalModerationPhase(mongo, redis, tenantCache, tenant, input.id),
|
||||
deleteExternalModerationPhase: (
|
||||
input: WithoutMutationID<GQLDeleteExternalModerationPhaseInput>
|
||||
) =>
|
||||
deleteExternalModerationPhase(mongo, redis, tenantCache, tenant, input.id),
|
||||
rotateExternalModerationPhaseSigningSecret: (
|
||||
input: WithoutMutationID<GQLRotateExternalModerationPhaseSigningSecretInput>
|
||||
) =>
|
||||
rotateExternalModerationPhaseSigningSecret(
|
||||
mongo,
|
||||
redis,
|
||||
tenantCache,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "coral-server/models/action/comment";
|
||||
import * as comment from "coral-server/models/comment";
|
||||
import {
|
||||
getDepth,
|
||||
getLatestRevision,
|
||||
hasAncestors,
|
||||
hasPublishedStatus,
|
||||
@@ -52,9 +53,9 @@ export const Comment: GQLCommentTypeResolver<comment.Comment> = {
|
||||
c.revisions.length > 0
|
||||
? { revision: getLatestRevision(c), comment: c }
|
||||
: null,
|
||||
deleted: ({ deletedAt }) => !!deletedAt,
|
||||
revisionHistory: (c) =>
|
||||
c.revisions.map((revision) => ({ revision, comment: c })),
|
||||
deleted: ({ deletedAt }) => !!deletedAt,
|
||||
editing: ({ revisions, createdAt }, input, ctx) => ({
|
||||
// When there is more than one body history, then the comment has been
|
||||
// edited.
|
||||
@@ -98,8 +99,8 @@ export const Comment: GQLCommentTypeResolver<comment.Comment> = {
|
||||
}),
|
||||
viewerActionPresence: (c, input, ctx) =>
|
||||
ctx.user ? ctx.loaders.Comments.retrieveMyActionPresence.load(c.id) : null,
|
||||
parentCount: (c) => (hasAncestors(c) ? c.ancestorIDs.length : 0),
|
||||
depth: (c) => (hasAncestors(c) ? c.ancestorIDs.length : 0),
|
||||
parentCount: (c) => getDepth(c),
|
||||
depth: (c) => getDepth(c),
|
||||
rootParent: (c, input, ctx, info) =>
|
||||
hasAncestors(c)
|
||||
? maybeLoadOnlyID(ctx, info, c.ancestorIDs[c.ancestorIDs.length - 1])
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as settings from "coral-server/models/settings";
|
||||
|
||||
import { GQLExternalModerationPhaseTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export const ExternalModerationPhase: GQLExternalModerationPhaseTypeResolver<settings.ExternalModerationPhase> = {
|
||||
signingSecret: ({ signingSecrets }) =>
|
||||
signingSecrets[signingSecrets.length - 1],
|
||||
};
|
||||
@@ -75,20 +75,21 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
comment: await ctx.mutators.Comments.unfeature(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
// DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
|
||||
regenerateSSOKey: async (source, { input }, ctx) => ({
|
||||
settings: await ctx.mutators.Settings.regenerateSSOKey(),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
rotateSSOKey: async (source, { input }, ctx) => ({
|
||||
settings: await ctx.mutators.Settings.rotateSSOKey(input),
|
||||
rotateSSOSigningSecret: async (source, { input }, ctx) => ({
|
||||
settings: await ctx.mutators.Settings.rotateSSOSigningSecret(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
deactivateSSOKey: async (source, { input }, ctx) => ({
|
||||
settings: await ctx.mutators.Settings.deactivateSSOKey(input),
|
||||
deactivateSSOSigningSecret: async (source, { input }, ctx) => ({
|
||||
settings: await ctx.mutators.Settings.deactivateSSOSigningSecret(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
deleteSSOKey: async (source, { input }, ctx) => ({
|
||||
settings: await ctx.mutators.Settings.deleteSSOKey(input),
|
||||
deleteSSOSigningSecret: async (source, { input }, ctx) => ({
|
||||
settings: await ctx.mutators.Settings.deleteSSOSigningSecret(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
createStory: async (source, { input }, ctx) => ({
|
||||
@@ -319,12 +320,64 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
endpoint: await ctx.mutators.Settings.deleteWebhookEndpoint(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
rotateWebhookEndpointSecret: async (
|
||||
rotateWebhookEndpointSigningSecret: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
endpoint: await ctx.mutators.Settings.rotateWebhookEndpointSecret(input),
|
||||
endpoint: await ctx.mutators.Settings.rotateWebhookEndpointSigningSecret(
|
||||
input
|
||||
),
|
||||
clientMutationId,
|
||||
}),
|
||||
createExternalModerationPhase: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
...(await ctx.mutators.Settings.createExternalModerationPhase(input)),
|
||||
clientMutationId,
|
||||
}),
|
||||
updateExternalModerationPhase: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
phase: await ctx.mutators.Settings.updateExternalModerationPhase(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
disableExternalModerationPhase: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
phase: await ctx.mutators.Settings.disableExternalModerationPhase(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
enableExternalModerationPhase: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
phase: await ctx.mutators.Settings.enableExternalModerationPhase(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
deleteExternalModerationPhase: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
phase: await ctx.mutators.Settings.deleteExternalModerationPhase(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
rotateExternalModerationPhaseSigningSecret: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
phase: await ctx.mutators.Settings.rotateExternalModerationPhaseSigningSecret(
|
||||
input
|
||||
),
|
||||
clientMutationId,
|
||||
}),
|
||||
testSMTP: async (source, { input: { clientMutationId } }, ctx) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getExternalModerationPhase } from "coral-server/models/settings";
|
||||
import { getWebhookEndpoint } from "coral-server/models/tenant";
|
||||
|
||||
import { GQLQueryTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
@@ -29,4 +30,8 @@ export const Query: Required<GQLQueryTypeResolver<void>> = {
|
||||
site: (source, { id }, ctx) => (id ? ctx.loaders.Sites.site.load(id) : null),
|
||||
webhookEndpoint: (source, { id }, ctx) => getWebhookEndpoint(ctx.tenant, id),
|
||||
queues: () => ({}),
|
||||
externalModerationPhase: (source, { id }, ctx) =>
|
||||
ctx.tenant.integrations.external
|
||||
? getExternalModerationPhase(ctx.tenant.integrations.external, id)
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import * as settings from "coral-server/models/settings";
|
||||
|
||||
import { GQLSSOAuthIntegrationTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
import { filterFreshSigningSecrets } from "coral-server/models/settings";
|
||||
|
||||
function getActiveSSOKey(keys: settings.Secret[]) {
|
||||
// Any key that has been rotated cannot be the active key.
|
||||
return keys.find((key) => !key.rotatedAt);
|
||||
function getActiveSSOSigningSecret(keys: settings.SigningSecret[]) {
|
||||
return keys.find(filterFreshSigningSecrets());
|
||||
}
|
||||
|
||||
export const SSOAuthIntegration: GQLSSOAuthIntegrationTypeResolver<settings.SSOAuthIntegration> = {
|
||||
key: ({ keys }) => {
|
||||
const key = getActiveSSOKey(keys);
|
||||
if (key) {
|
||||
return key.secret;
|
||||
key: ({ signingSecrets }) => {
|
||||
const signingSecret = getActiveSSOSigningSecret(signingSecrets);
|
||||
if (signingSecret) {
|
||||
return signingSecret.secret;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
keyGeneratedAt: ({ keys }) => {
|
||||
const key = getActiveSSOKey(keys);
|
||||
if (key) {
|
||||
return key.createdAt;
|
||||
keyGeneratedAt: ({ signingSecrets }) => {
|
||||
const signingSecret = getActiveSSOSigningSecret(signingSecrets);
|
||||
if (signingSecret) {
|
||||
return signingSecret.createdAt;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import * as settings from "coral-server/models/settings";
|
||||
|
||||
import { GQLSecretTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export const Secret: GQLSecretTypeResolver<settings.Secret> = {
|
||||
lastUsedAt: async ({ kid }, args, ctx) =>
|
||||
ctx.loaders.Auth.retrieveSSOKeyLastUsedAt.load(kid),
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as settings from "coral-server/models/settings";
|
||||
|
||||
import { GQLSigningSecretTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export const SigningSecret: GQLSigningSecretTypeResolver<settings.SigningSecret> = {
|
||||
lastUsedAt: async ({ kid }, args, ctx) =>
|
||||
ctx.loaders.Auth.retrieveSSOSigningSecretLastUsedAt.load(kid),
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import { CommentReplyCreatedPayload } from "./CommentReplyCreatedPayload";
|
||||
import { CommentRevision } from "./CommentRevision";
|
||||
import { CommentStatusUpdatedPayload } from "./CommentStatusUpdatedPayload";
|
||||
import { DisableCommenting } from "./DisableCommenting";
|
||||
import { ExternalModerationPhase } from "./ExternalModerationPhase";
|
||||
import { FacebookAuthIntegration } from "./FacebookAuthIntegration";
|
||||
import { FeatureCommentPayload } from "./FeatureCommentPayload";
|
||||
import { Flag } from "./Flag";
|
||||
@@ -39,8 +40,8 @@ import { Queue } from "./Queue";
|
||||
import { Queues } from "./Queues";
|
||||
import { RecentCommentHistory } from "./RecentCommentHistory";
|
||||
import { RejectCommentPayload } from "./RejectCommentPayload";
|
||||
import { Secret } from "./Secret";
|
||||
import { Settings } from "./Settings";
|
||||
import { SigningSecret } from "./SigningSecret";
|
||||
import { SlackConfiguration } from "./SlackConfiguration";
|
||||
import { SSOAuthIntegration } from "./SSOAuthIntegration";
|
||||
import { Story } from "./Story";
|
||||
@@ -73,6 +74,7 @@ const Resolvers: GQLResolver = {
|
||||
CommentStatusUpdatedPayload,
|
||||
Cursor,
|
||||
DisableCommenting,
|
||||
ExternalModerationPhase,
|
||||
FacebookAuthIntegration,
|
||||
FeatureCommentPayload,
|
||||
Flag,
|
||||
@@ -92,7 +94,7 @@ const Resolvers: GQLResolver = {
|
||||
RecentCommentHistory,
|
||||
RejectCommentPayload,
|
||||
SSOAuthIntegration,
|
||||
Secret,
|
||||
SigningSecret,
|
||||
Story,
|
||||
StorySettings,
|
||||
Subscription,
|
||||
|
||||
@@ -495,7 +495,7 @@ type LocalAuthIntegration {
|
||||
## SSOAuthIntegration
|
||||
##########################
|
||||
|
||||
type Secret {
|
||||
type SigningSecret {
|
||||
"""
|
||||
kid is the identifier for the key used when verifying tokens issued by the
|
||||
provider.
|
||||
@@ -551,23 +551,23 @@ type SSOAuthIntegration {
|
||||
targetFilter: AuthenticationTargetFilter!
|
||||
|
||||
"""
|
||||
keys are the different SSOKey's used by this Tenant.
|
||||
signingSecrets are the different SigningSecret's used by this Tenant.
|
||||
"""
|
||||
keys: [Secret!]! @auth(roles: [ADMIN])
|
||||
signingSecrets: [SigningSecret!]! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
key is the secret that is used to sign tokens.
|
||||
"""
|
||||
key: String
|
||||
@auth(roles: [ADMIN])
|
||||
@deprecated(reason: "field is deprecated in favour of `keys`")
|
||||
@deprecated(reason: "field is deprecated in favour of `signingSecrets`")
|
||||
|
||||
"""
|
||||
keyGeneratedAt is the Time that the key was effective from.
|
||||
"""
|
||||
keyGeneratedAt: Time
|
||||
@auth(roles: [ADMIN])
|
||||
@deprecated(reason: "field is deprecated in favour of `keys`")
|
||||
@deprecated(reason: "field is deprecated in favour of `signingSecrets`")
|
||||
}
|
||||
|
||||
##########################
|
||||
@@ -908,6 +908,73 @@ type PerspectiveExternalIntegration {
|
||||
sendFeedback: Boolean @auth(roles: [ADMIN])
|
||||
}
|
||||
|
||||
"""
|
||||
COMMENT_BODY_FORMAT describes the various formats that a comment body can be
|
||||
provided in.
|
||||
"""
|
||||
enum COMMENT_BODY_FORMAT {
|
||||
"""
|
||||
HTML describes the format of the comment body using HTML.
|
||||
"""
|
||||
HTML
|
||||
|
||||
"""
|
||||
PLAIN_TEXT describes the format of the comment body with the HTML stripped.
|
||||
"""
|
||||
PLAIN_TEXT
|
||||
}
|
||||
|
||||
"""
|
||||
ExternalModerationPhase describes a phase use in the moderation pipeline that
|
||||
calls out to an external resource as defined by the provided URL.
|
||||
"""
|
||||
type ExternalModerationPhase {
|
||||
"""
|
||||
id identifies this particular External Moderation Phase.
|
||||
"""
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
name is the name assigned to this ExternalModerationPhase for identification
|
||||
purposes.
|
||||
"""
|
||||
name: String!
|
||||
|
||||
"""
|
||||
enabled when true, will use this phase in the moderation pipeline.
|
||||
"""
|
||||
enabled: Boolean!
|
||||
|
||||
"""
|
||||
url is the actual URL that should be called.
|
||||
"""
|
||||
url: String!
|
||||
|
||||
"""
|
||||
format is the format of the comment body sent.
|
||||
"""
|
||||
format: COMMENT_BODY_FORMAT!
|
||||
|
||||
"""
|
||||
timeout is the number of milliseconds that this moderation is maximum expected
|
||||
to take before it is skipped.
|
||||
"""
|
||||
timeout: Int!
|
||||
|
||||
"""
|
||||
signingSecret is the secret used to sign outgoing requests to the url during
|
||||
the moderation pipeline.
|
||||
"""
|
||||
signingSecret: SigningSecret!
|
||||
}
|
||||
|
||||
type CustomExternalIntegration {
|
||||
"""
|
||||
phases is all the external moderation phases for this Tenant.
|
||||
"""
|
||||
phases: [ExternalModerationPhase!]!
|
||||
}
|
||||
|
||||
type ExternalIntegrations {
|
||||
"""
|
||||
akismet provides integration with the Akismet Spam detection service.
|
||||
@@ -919,6 +986,12 @@ type ExternalIntegrations {
|
||||
platform.
|
||||
"""
|
||||
perspective: PerspectiveExternalIntegration!
|
||||
|
||||
"""
|
||||
external provides integration details for external moderation phases that can be
|
||||
used in the moderation pipeline.
|
||||
"""
|
||||
external: CustomExternalIntegration
|
||||
}
|
||||
|
||||
################################################################################
|
||||
@@ -1318,7 +1391,7 @@ type WebhookEndpoint {
|
||||
"""
|
||||
signingSecret is the current secret used to sign the events sent out.
|
||||
"""
|
||||
signingSecret: Secret!
|
||||
signingSecret: SigningSecret!
|
||||
|
||||
"""
|
||||
deliveries store the deliveries for each event sent for the last 50 events.
|
||||
@@ -3183,6 +3256,13 @@ type Query {
|
||||
queues returns information on queues used in Coral to manage
|
||||
"""
|
||||
queues: Queues! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
externalModerationPhase will return a specific ExternalModerationPhase if it
|
||||
exists.
|
||||
"""
|
||||
externalModerationPhase(id: ID!): ExternalModerationPhase
|
||||
@auth(roles: [ADMIN])
|
||||
}
|
||||
|
||||
################################################################################
|
||||
@@ -4369,7 +4449,7 @@ input RegenerateSSOKeyInput {
|
||||
|
||||
type RegenerateSSOKeyPayload {
|
||||
"""
|
||||
settings is the Settings that the SSO key was regenerated on.
|
||||
settings is the Settings that the SSO secret was regenerated on.
|
||||
"""
|
||||
settings: Settings
|
||||
|
||||
@@ -5158,10 +5238,10 @@ type UpdateWebhookEndpointPayload {
|
||||
}
|
||||
|
||||
##################
|
||||
# rotateWebhookEndpointSecret
|
||||
# rotateWebhookEndpointSigningSecret
|
||||
##################
|
||||
|
||||
input RotateWebhookEndpointSecretInput {
|
||||
input RotateWebhookEndpointSigningSecretInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
@@ -5179,7 +5259,7 @@ input RotateWebhookEndpointSecretInput {
|
||||
inactiveIn: Int!
|
||||
}
|
||||
|
||||
type RotateWebhookEndpointSecretPayload {
|
||||
type RotateWebhookEndpointSigningSecretPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
@@ -5188,7 +5268,7 @@ type RotateWebhookEndpointSecretPayload {
|
||||
"""
|
||||
endpoint is the endpoint that we just updated.
|
||||
"""
|
||||
endpoint: WebhookEndpoint
|
||||
endpoint: WebhookEndpoint!
|
||||
}
|
||||
|
||||
##################
|
||||
@@ -5216,7 +5296,7 @@ type DisableWebhookEndpointPayload {
|
||||
"""
|
||||
endpoint is the endpoint that we just disabled.
|
||||
"""
|
||||
endpoint: WebhookEndpoint
|
||||
endpoint: WebhookEndpoint!
|
||||
}
|
||||
|
||||
##################
|
||||
@@ -5244,7 +5324,7 @@ type EnableWebhookEndpointPayload {
|
||||
"""
|
||||
endpoint is the endpoint that we just enabled.
|
||||
"""
|
||||
endpoint: WebhookEndpoint
|
||||
endpoint: WebhookEndpoint!
|
||||
}
|
||||
|
||||
##################
|
||||
@@ -5272,7 +5352,225 @@ type DeleteWebhookEndpointPayload {
|
||||
"""
|
||||
endpoint is the endpoint that we just deleted.
|
||||
"""
|
||||
endpoint: WebhookEndpoint
|
||||
endpoint: WebhookEndpoint!
|
||||
}
|
||||
|
||||
#################
|
||||
# createExternalModerationPhase
|
||||
##################
|
||||
|
||||
input CreateExternalModerationPhaseInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
name is the name assigned to this ExternalModerationPhase for identification
|
||||
purposes.
|
||||
"""
|
||||
name: String!
|
||||
|
||||
"""
|
||||
url is the URL that Coral will POST moderation queries to.
|
||||
"""
|
||||
url: String!
|
||||
|
||||
"""
|
||||
format is the format of the comment body sent.
|
||||
"""
|
||||
format: COMMENT_BODY_FORMAT!
|
||||
|
||||
"""
|
||||
timeout is the number of milliseconds that this moderation is maximum expected
|
||||
to take before it is skipped.
|
||||
"""
|
||||
timeout: Int!
|
||||
}
|
||||
|
||||
type CreateExternalModerationPhasePayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
phase is the ExternalModerationPhase that we just created.
|
||||
"""
|
||||
phase: ExternalModerationPhase!
|
||||
|
||||
"""
|
||||
settings is the updated settings also containing the new phase.
|
||||
"""
|
||||
settings: Settings!
|
||||
}
|
||||
|
||||
##################
|
||||
# updateExternalModerationPhase
|
||||
##################
|
||||
|
||||
input UpdateExternalModerationPhaseInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
id is the ID of the ExternalModerationPhase being updated.
|
||||
"""
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
name is the name assigned to this ExternalModerationPhase for identification
|
||||
purposes.
|
||||
"""
|
||||
name: String
|
||||
|
||||
"""
|
||||
url is the URL that Coral will POST moderation queries to.
|
||||
"""
|
||||
url: String
|
||||
|
||||
"""
|
||||
format is the format of the comment body sent.
|
||||
"""
|
||||
format: COMMENT_BODY_FORMAT
|
||||
|
||||
"""
|
||||
timeout is the number of milliseconds that this moderation is maximum expected
|
||||
to take before it is skipped.
|
||||
"""
|
||||
timeout: Int
|
||||
}
|
||||
|
||||
type UpdateExternalModerationPhasePayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
phase is the ExternalModerationPhase that we just updated.
|
||||
"""
|
||||
phase: ExternalModerationPhase!
|
||||
}
|
||||
|
||||
##################
|
||||
# deleteExternalModerationPhase
|
||||
##################
|
||||
|
||||
input DeleteExternalModerationPhaseInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
id is the ID of the ExternalModerationPhase being deleted.
|
||||
"""
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type DeleteExternalModerationPhasePayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
phase is the ExternalModerationPhase that we just deleted.
|
||||
"""
|
||||
phase: ExternalModerationPhase!
|
||||
}
|
||||
|
||||
##################
|
||||
# disableExternalModerationPhase
|
||||
##################
|
||||
|
||||
input DisableExternalModerationPhaseInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
id is the ID of the ExternalModerationPhase being disabled.
|
||||
"""
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type DisableExternalModerationPhasePayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
phase is the ExternalModerationPhase that we just disabled.
|
||||
"""
|
||||
phase: ExternalModerationPhase!
|
||||
}
|
||||
|
||||
##################
|
||||
# enableExternalModerationPhase
|
||||
##################
|
||||
|
||||
input EnableExternalModerationPhaseInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
id is the ID of the ExternalModerationPhase being enabled.
|
||||
"""
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type EnableExternalModerationPhasePayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
phase is the ExternalModerationPhase that we just enabled.
|
||||
"""
|
||||
phase: ExternalModerationPhase!
|
||||
}
|
||||
|
||||
##################
|
||||
# rotateExternalModerationPhaseSigningSecret
|
||||
##################
|
||||
|
||||
input RotateExternalModerationPhaseSigningSecretInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
id is the ID of the ExternalModerationPhase being updated.
|
||||
"""
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
inactiveIn is the number of seconds that the current active Secret should be
|
||||
kept active.
|
||||
"""
|
||||
inactiveIn: Int!
|
||||
}
|
||||
|
||||
type RotateExternalModerationPhaseSigningSecretPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
phase is the ExternalModerationPhase that we just updated.
|
||||
"""
|
||||
phase: ExternalModerationPhase!
|
||||
}
|
||||
|
||||
##################
|
||||
@@ -6001,87 +6299,87 @@ type EnableFeatureFlagPayload {
|
||||
}
|
||||
|
||||
#########################
|
||||
## rotateSSOKey
|
||||
## rotateSSOSigningSecret
|
||||
#########################
|
||||
|
||||
input RotateSSOKeyInput {
|
||||
input RotateSSOSigningSecretInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
inactiveIn is the number of seconds that the current active SSOKey should be
|
||||
kept active (allow signed tokens signed with this secret) before rejecting
|
||||
them.
|
||||
inactiveIn is the number of seconds that the current active SigningSecret
|
||||
should be kept active (allow signed tokens signed with this secret) before
|
||||
rejecting them.
|
||||
"""
|
||||
inactiveIn: Int!
|
||||
}
|
||||
|
||||
type RotateSSOKeyPayload {
|
||||
type RotateSSOSigningSecretPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
settings is the Settings that the SSO key was regenerated on.
|
||||
settings is the Settings that the SSO secret was regenerated on.
|
||||
"""
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
#########################
|
||||
## deactivateSSOKey
|
||||
## deactivateSSOSigningSecret
|
||||
#########################
|
||||
|
||||
input DeactivateSSOKeyInput {
|
||||
input DeactivateSSOSigningSecretInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
kid is the ID of the SSOKey being deactivated.
|
||||
kid is the ID of the SigningSecret being deactivated.
|
||||
"""
|
||||
kid: ID!
|
||||
}
|
||||
|
||||
type DeactivateSSOKeyPayload {
|
||||
type DeactivateSSOSigningSecretPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
settings is the Settings that the SSO key was regenerated on.
|
||||
settings is the Settings that the SSO secret was regenerated on.
|
||||
"""
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
#########################
|
||||
## deleteSSOKey
|
||||
## deleteSSOSigningSecret
|
||||
#########################
|
||||
|
||||
input DeleteSSOKeyInput {
|
||||
input DeleteSSOSigningSecretInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
kid is the ID of the SSOKey being deleted.
|
||||
kid is the ID of the SigningSecret being deleted.
|
||||
"""
|
||||
kid: ID!
|
||||
}
|
||||
|
||||
type DeleteSSOKeyPayload {
|
||||
type DeleteSSOSigningSecretPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
settings is the Settings that the SSO key was regenerated on.
|
||||
settings is the Settings that the SSO secret was regenerated on.
|
||||
"""
|
||||
settings: Settings
|
||||
}
|
||||
@@ -6349,30 +6647,35 @@ type Mutation {
|
||||
@auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
regenerateSSOKey will regenerate the SSO key used to sign secrets. This will
|
||||
regenerateSSOKey will regenerate the SSO secret used to sign secrets. This will
|
||||
invalidate any existing user sessions.
|
||||
|
||||
DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
|
||||
"""
|
||||
regenerateSSOKey(input: RegenerateSSOKeyInput!): RegenerateSSOKeyPayload!
|
||||
@auth(roles: [ADMIN])
|
||||
@deprecated(reason: "deprecated in favour of `rotateSSOKey`")
|
||||
@deprecated(reason: "deprecated in favour of `rotateSSOSigningSecret`")
|
||||
|
||||
"""
|
||||
rotateSSOKey can be used to rotate a given active SSOKey.
|
||||
rotateSSOSigningSecret can be used to rotate a given active SigningSecret.
|
||||
"""
|
||||
rotateSSOKey(input: RotateSSOKeyInput!): RotateSSOKeyPayload!
|
||||
@auth(roles: [ADMIN])
|
||||
rotateSSOSigningSecret(
|
||||
input: RotateSSOSigningSecretInput!
|
||||
): RotateSSOSigningSecretPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
deactivateSSOKey will deactivate a given deactivated SSOKey.
|
||||
deactivateSSOSigningSecret will deactivate a given deactivated SigningSecret.
|
||||
"""
|
||||
deactivateSSOKey(input: DeactivateSSOKeyInput!): DeactivateSSOKeyPayload!
|
||||
@auth(roles: [ADMIN])
|
||||
deactivateSSOSigningSecret(
|
||||
input: DeactivateSSOSigningSecretInput!
|
||||
): DeactivateSSOSigningSecretPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
deleteSSOKey will delete a given inactive SSOKey.
|
||||
deleteSSOSigningSecret will delete a given inactive SigningSecret.
|
||||
"""
|
||||
deleteSSOKey(input: DeleteSSOKeyInput!): DeleteSSOKeyPayload!
|
||||
@auth(roles: [ADMIN])
|
||||
deleteSSOSigningSecret(
|
||||
input: DeleteSSOSigningSecretInput!
|
||||
): DeleteSSOSigningSecretPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
createCommentReaction will create a Reaction authored by the current logged in
|
||||
@@ -6762,11 +7065,56 @@ type Mutation {
|
||||
): DeleteWebhookEndpointPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
rotateWebhookEndpointSecret will roll the current active secret to a new key.
|
||||
rotateWebhookEndpointSigningSecret will roll the current active secret to a new key.
|
||||
"""
|
||||
rotateWebhookEndpointSecret(
|
||||
input: RotateWebhookEndpointSecretInput!
|
||||
): RotateWebhookEndpointSecretPayload! @auth(roles: [ADMIN])
|
||||
rotateWebhookEndpointSigningSecret(
|
||||
input: RotateWebhookEndpointSigningSecretInput!
|
||||
): RotateWebhookEndpointSigningSecretPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
createExternalModerationPhase will create a new ExternalModerationPhase.
|
||||
"""
|
||||
createExternalModerationPhase(
|
||||
input: CreateExternalModerationPhaseInput!
|
||||
): CreateExternalModerationPhasePayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
updateExternalModerationPhase will update a ExternalModerationPhase.
|
||||
"""
|
||||
updateExternalModerationPhase(
|
||||
input: UpdateExternalModerationPhaseInput!
|
||||
): UpdateExternalModerationPhasePayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
enableExternalModerationPhase will enable a ExternalModerationPhase to recieve
|
||||
new comments.
|
||||
"""
|
||||
enableExternalModerationPhase(
|
||||
input: EnableExternalModerationPhaseInput!
|
||||
): EnableExternalModerationPhasePayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
disableExternalModerationPhase will disable a ExternalModerationPhase from
|
||||
recieving new comments.
|
||||
"""
|
||||
disableExternalModerationPhase(
|
||||
input: DisableExternalModerationPhaseInput!
|
||||
): DisableExternalModerationPhasePayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
deleteExternalModerationPhase will delete a ExternalModerationPhase.
|
||||
"""
|
||||
deleteExternalModerationPhase(
|
||||
input: DeleteExternalModerationPhaseInput!
|
||||
): DeleteExternalModerationPhasePayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
rotateExternalModerationPhaseSigningSecret will roll the current active secret
|
||||
to a new key.
|
||||
"""
|
||||
rotateExternalModerationPhaseSigningSecret(
|
||||
input: RotateExternalModerationPhaseSigningSecretInput!
|
||||
): RotateExternalModerationPhaseSigningSecretPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
updateStoryMode will set the story mode.
|
||||
|
||||
@@ -24,10 +24,10 @@ import {
|
||||
} from "coral-server/app/middleware/passport/strategies/jwt";
|
||||
import {
|
||||
CoralError,
|
||||
InternalError,
|
||||
LiveUpdatesDisabled,
|
||||
RawQueryNotAuthorized,
|
||||
TenantNotFoundError,
|
||||
WrappedInternalError,
|
||||
} from "coral-server/errors";
|
||||
import { enrichError, logError, logQuery } from "coral-server/graph/extensions";
|
||||
import { getOperationMetadata } from "coral-server/graph/extensions/helpers";
|
||||
@@ -151,7 +151,10 @@ export function onConnect(options: OnConnectOptions): OnConnectFn {
|
||||
|
||||
if (!(err instanceof CoralError)) {
|
||||
// eslint-disable-next-line no-ex-assign
|
||||
err = new InternalError(err, "could not setup websocket connection");
|
||||
err = new WrappedInternalError(
|
||||
err,
|
||||
"could not setup websocket connection"
|
||||
);
|
||||
}
|
||||
const { message } = err.serializeExtensions(
|
||||
options.i18n.getDefaultBundle()
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
createAugmentedRedisClient,
|
||||
createRedisClient,
|
||||
} from "coral-server/services/redis";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
|
||||
import {
|
||||
NotifierCoralEventListener,
|
||||
|
||||
@@ -57,3 +57,14 @@ export function calculateRejectionRate(counts: CommentStatusCounts): number {
|
||||
export function hasTag(comment: Pick<Comment, "tags">, tag: GQLTAG) {
|
||||
return comment.tags.some((v) => v.type === tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* getDepth will return the depth of the comment.
|
||||
*
|
||||
* @param comment the comment to check for depth
|
||||
*/
|
||||
export function getDepth(
|
||||
comment: Pick<Comment, "ancestorIDs" | "parentID">
|
||||
): number {
|
||||
return hasAncestors(comment) ? comment.ancestorIDs.length : 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
ExternalModerationExternalIntegration,
|
||||
ExternalModerationPhase,
|
||||
} from "./settings";
|
||||
|
||||
export function filterActivePhase() {
|
||||
return (phase: Pick<ExternalModerationPhase, "enabled">) => phase.enabled;
|
||||
}
|
||||
|
||||
export function getExternalModerationPhase(
|
||||
integration: ExternalModerationExternalIntegration,
|
||||
phaseID: string
|
||||
) {
|
||||
return integration.phases.find((p) => p.id === phaseID) || null;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./settings";
|
||||
export * from "./secret";
|
||||
export * from "./helpers";
|
||||
export * from "./signingSecret";
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
export interface Secret {
|
||||
/**
|
||||
* kid is the identifier for the key used when verifying tokens issued by the
|
||||
* provider.
|
||||
*/
|
||||
kid: string;
|
||||
|
||||
/**
|
||||
* secret is the actual underlying secret used to verify the tokens with.
|
||||
*/
|
||||
secret: string;
|
||||
|
||||
/**
|
||||
* createdAt is the date that the key was created at.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* rotatedAt is the time that the token was rotated out.
|
||||
*/
|
||||
rotatedAt?: Date;
|
||||
|
||||
/**
|
||||
* inactiveAt is the date that the token can no longer be used to validate
|
||||
* tokens.
|
||||
*/
|
||||
inactiveAt?: Date;
|
||||
}
|
||||
|
||||
export function isSecretExpired({ inactiveAt }: Secret, now = new Date()) {
|
||||
if (inactiveAt && inactiveAt <= now) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterExpiredSecrets(now = new Date()) {
|
||||
return (secret: Secret) => isSecretExpired(secret, now);
|
||||
}
|
||||
|
||||
export function filterActiveSecrets(now = new Date()) {
|
||||
return (secret: Secret) => !isSecretExpired(secret, now);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
GQLAkismetExternalIntegration,
|
||||
GQLAuth,
|
||||
GQLAuthenticationTargetFilter,
|
||||
GQLCOMMENT_BODY_FORMAT,
|
||||
GQLEmailConfiguration,
|
||||
GQLFacebookAuthIntegration,
|
||||
GQLGoogleAuthIntegration,
|
||||
@@ -8,10 +10,11 @@ import {
|
||||
GQLLocalAuthIntegration,
|
||||
GQLMODERATION_MODE,
|
||||
GQLOIDCAuthIntegration,
|
||||
GQLPerspectiveExternalIntegration,
|
||||
GQLSettings,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { Secret } from "./secret";
|
||||
import { SigningSecretResource } from "./signingSecret";
|
||||
|
||||
export type LiveConfiguration = Omit<GQLLiveConfiguration, "configurable">;
|
||||
|
||||
@@ -38,11 +41,10 @@ export type FacebookAuthIntegration = Omit<
|
||||
"callbackURL" | "redirectURL"
|
||||
>;
|
||||
|
||||
export interface SSOAuthIntegration {
|
||||
export interface SSOAuthIntegration extends SigningSecretResource {
|
||||
enabled: boolean;
|
||||
allowRegistration: boolean;
|
||||
targetFilter: GQLAuthenticationTargetFilter;
|
||||
keys: Secret[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +87,71 @@ export type Auth = Omit<GQLAuth, "integrations"> & {
|
||||
integrations: AuthIntegrations;
|
||||
};
|
||||
|
||||
export interface ExternalModerationPhase extends SigningSecretResource {
|
||||
/**
|
||||
* id identifies this particular External Moderation Phase.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* name is the name assigned to this ExternalModerationPhase for
|
||||
* identification purposes.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* enabled when true, will use this phase in the moderation pipeline.
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* url is the actual URL that should be called.
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* format is the format of the comment body sent.
|
||||
*/
|
||||
format: GQLCOMMENT_BODY_FORMAT;
|
||||
|
||||
/**
|
||||
* timeout is the number of milliseconds that this moderation is maximum
|
||||
* expected to take before it is skipped.
|
||||
*/
|
||||
timeout: number;
|
||||
|
||||
/**
|
||||
* createdAt is the date that this External Moderation Phase was created at.
|
||||
*/
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ExternalModerationExternalIntegration {
|
||||
/**
|
||||
* phases is all the external moderation phases for this Tenant.
|
||||
*/
|
||||
phases: ExternalModerationPhase[];
|
||||
}
|
||||
|
||||
export interface ExternalIntegrations {
|
||||
/**
|
||||
* akismet provides integration with the Akismet Spam detection service.
|
||||
*/
|
||||
akismet: GQLAkismetExternalIntegration;
|
||||
|
||||
/**
|
||||
* perspective provides integration with the Perspective API comment analysis
|
||||
* platform.
|
||||
*/
|
||||
perspective: GQLPerspectiveExternalIntegration;
|
||||
|
||||
/**
|
||||
* external provides integration details for external moderation phases that can be
|
||||
* used in the moderation pipeline.
|
||||
*/
|
||||
external?: ExternalModerationExternalIntegration;
|
||||
}
|
||||
|
||||
/**
|
||||
* CloseCommenting contains settings related to the automatic closing of commenting on
|
||||
* Stories.
|
||||
@@ -108,7 +175,6 @@ export type Settings = GlobalModerationSettings &
|
||||
| "email"
|
||||
| "recentCommentHistory"
|
||||
| "wordList"
|
||||
| "integrations"
|
||||
| "reaction"
|
||||
| "staff"
|
||||
| "editCommentWindowLength"
|
||||
@@ -146,6 +212,11 @@ export type Settings = GlobalModerationSettings &
|
||||
*/
|
||||
accountFeatures: AccountFeatures;
|
||||
|
||||
/**
|
||||
* integrations contains all the external integrations that can be enabled.
|
||||
*/
|
||||
integrations: ExternalIntegrations;
|
||||
|
||||
/**
|
||||
* newCommenters is the configuration for how new commenters comments are treated.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { SigningSecret } from "./signingSecret";
|
||||
|
||||
function generateSecureRandomString(size: number, drift = 5) {
|
||||
return crypto
|
||||
.randomBytes(size + Math.floor(Math.random() * drift))
|
||||
.toString("hex");
|
||||
}
|
||||
|
||||
export function generateSigningSecret(
|
||||
prefix: string,
|
||||
createdAt: Date
|
||||
): SigningSecret {
|
||||
// Generate a new key. We generate a key of minimum length 32 up to 37 bytes,
|
||||
// as 16 was the minimum length recommended.
|
||||
//
|
||||
// Reference: https://security.stackexchange.com/a/96176
|
||||
const secret = prefix + "_" + generateSecureRandomString(32, 5);
|
||||
const kid = generateSecureRandomString(8, 3);
|
||||
|
||||
return { kid, secret, createdAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* isSecretExpired is a function that given a secret and the current date will
|
||||
* return whether the secret has expired.
|
||||
*
|
||||
* @param secret the secret to test
|
||||
* @param now the current date
|
||||
*/
|
||||
function isSigningSecretExpired({ inactiveAt }: SigningSecret, now: Date) {
|
||||
if (inactiveAt && inactiveAt <= now) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* filterExpiredSecrets is a filter function that can be used to filter only
|
||||
* secrets that are inactive or expired.
|
||||
*
|
||||
* @param now the current date
|
||||
*/
|
||||
export function filterExpiredSigningSecrets(now: Date) {
|
||||
return (secret: SigningSecret) => isSigningSecretExpired(secret, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* filterFreshSecrets is a filtering function that can be used to filter for any
|
||||
* secret that has not been rotated.
|
||||
*/
|
||||
export function filterFreshSigningSecrets() {
|
||||
return (secret: SigningSecret) => !secret.rotatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* filterActiveSecrets is a filter function that can be used to filter only
|
||||
* secrets that are active.
|
||||
*
|
||||
* @param now the current date
|
||||
*/
|
||||
export function filterActiveSigningSecrets(now: Date) {
|
||||
return (secret: SigningSecret) => !isSigningSecretExpired(secret, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* generateSignature will generate a signature used to assist clients to
|
||||
* validate that the request came from Coral.
|
||||
*
|
||||
* @param key the secret used to sign the body with
|
||||
* @param data the data to sign
|
||||
*/
|
||||
function generateSignature(key: string, data: string) {
|
||||
return crypto.createHmac("sha256", key).update(data).digest().toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* generateSignatures will return a header value that can be used to verify the
|
||||
* integrity and authenticity of a payload sent from Coral.
|
||||
*
|
||||
* @param signingSecrets the secrets that should be used to sign the data with
|
||||
* @param data the data to sign
|
||||
* @param now the current date
|
||||
*/
|
||||
export function generateSignatures(
|
||||
signingSecrets: SigningSecret[],
|
||||
data: string,
|
||||
now: Date
|
||||
) {
|
||||
// For each of the signatures, we only want to sign the body with secrets that
|
||||
// are still active.
|
||||
return signingSecrets
|
||||
.filter(filterActiveSigningSecrets(now))
|
||||
.map(({ secret }) => generateSignature(secret, data))
|
||||
.map((signature) => `sha256=${signature}`)
|
||||
.join(",");
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./helpers";
|
||||
export * from "./signingSecret";
|
||||
@@ -0,0 +1,287 @@
|
||||
import { get } from "lodash";
|
||||
import { Collection, FindOneAndUpdateOption, UpdateQuery } from "mongodb";
|
||||
|
||||
import logger from "coral-server/logger";
|
||||
import { FilterQuery } from "coral-server/models/helpers";
|
||||
|
||||
import { filterFreshSigningSecrets, generateSigningSecret } from "./helpers";
|
||||
|
||||
export interface SigningSecret {
|
||||
/**
|
||||
* kid is the identifier for the key used when verifying tokens issued by the
|
||||
* provider.
|
||||
*/
|
||||
kid: string;
|
||||
|
||||
/**
|
||||
* secret is the actual underlying secret used to verify the tokens with.
|
||||
*/
|
||||
secret: string;
|
||||
|
||||
/**
|
||||
* createdAt is the date that the key was created at.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* rotatedAt is the time that the token was rotated out.
|
||||
*/
|
||||
rotatedAt?: Date;
|
||||
|
||||
/**
|
||||
* inactiveAt is the date that the token can no longer be used to validate
|
||||
* tokens.
|
||||
*/
|
||||
inactiveAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* SigningSecretResource is a resource that contains signing credentials.
|
||||
*/
|
||||
export interface SigningSecretResource {
|
||||
/**
|
||||
* signingSecrets are secrets used for signing and verification.
|
||||
*/
|
||||
signingSecrets: SigningSecret[];
|
||||
}
|
||||
|
||||
interface SigningSecretResourceNode extends SigningSecretResource {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface RotateSigningSecretOptions<T extends {}> {
|
||||
/**
|
||||
* collection is the database collection that the document exists in.
|
||||
*/
|
||||
collection: Collection<Readonly<T>>;
|
||||
|
||||
/**
|
||||
* filter is the database query used to filter out the specific document that
|
||||
* contains a SigningResource.
|
||||
*/
|
||||
filter: FilterQuery<T>;
|
||||
|
||||
/**
|
||||
* path is the dot notation path of the resource that contains the signing
|
||||
* secrets.
|
||||
*/
|
||||
path: string;
|
||||
|
||||
/**
|
||||
* prefix is the secret prefix that will be added to the new secret that is
|
||||
* generated.
|
||||
*/
|
||||
prefix: string;
|
||||
|
||||
/**
|
||||
* id is the identifier used when querying for the resource identified by the
|
||||
* `path` parameter above.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* inactiveAt is the date that the previous keys should be active for.
|
||||
*/
|
||||
inactiveAt: Date;
|
||||
|
||||
/**
|
||||
* now is the current date.
|
||||
*/
|
||||
now: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* getSecretKIDsToDeprecate will return all the keys that should be deprecated
|
||||
* from the first phase of the rolling process.
|
||||
*
|
||||
* @param signingSecrets the keys returned by the query operation
|
||||
*/
|
||||
const getSecretKIDsToDeprecate = (signingSecrets: SigningSecret[]) =>
|
||||
signingSecrets
|
||||
// By excluding the last one (the one we just pushed)...
|
||||
.splice(0, signingSecrets.length - 1)
|
||||
// And only finding keys that have not been rotated yet.
|
||||
.filter(filterFreshSigningSecrets())
|
||||
// And get their kid's.
|
||||
.map((s) => s.kid);
|
||||
|
||||
async function pushNewSigningSecret<T extends {}>({
|
||||
collection,
|
||||
filter,
|
||||
path,
|
||||
prefix,
|
||||
id,
|
||||
now,
|
||||
}: RotateSigningSecretOptions<T>) {
|
||||
// Create the new secret.
|
||||
const secret = generateSigningSecret(prefix, now);
|
||||
|
||||
// Generate the update for the operation.
|
||||
let update: UpdateQuery<T>;
|
||||
if (id) {
|
||||
update = {
|
||||
$push: {
|
||||
[`${path}.$[resource].signingSecrets`]: secret,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
update = {
|
||||
$push: {
|
||||
[`${path}.signingSecrets`]: secret,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Generate the options for the operation.
|
||||
const options: FindOneAndUpdateOption = {
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
};
|
||||
if (id) {
|
||||
options.arrayFilters = [
|
||||
// Select the secret resource under the object.
|
||||
{ "resource.id": id },
|
||||
];
|
||||
}
|
||||
|
||||
// Update the resource with this new secret.
|
||||
const result = await collection.findOneAndUpdate(filter, update, options);
|
||||
if (!result.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function getResourceFromDoc<T extends {}>(
|
||||
{ id, path }: Pick<RotateSigningSecretOptions<T>, "id" | "path">,
|
||||
doc: T
|
||||
) {
|
||||
if (id) {
|
||||
// The ID was provided, so we need to get the referenced resource by it's
|
||||
// ID. This also means that the resource indicated exists in an Array, so
|
||||
// treat it as such.
|
||||
|
||||
// Get the resource referenced by the path (should be a path to an array).
|
||||
const resources: SigningSecretResourceNode[] | undefined = get(doc, path);
|
||||
if (!resources || !Array.isArray(resources)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the specific resource from the array.
|
||||
const resource = resources.find((r) => r.id === id);
|
||||
if (!resource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
// The ID was not provided, which means that the resource is not in an array.
|
||||
const resource: SigningSecretResource | undefined = get(doc, path);
|
||||
if (!resource) {
|
||||
if (Array.isArray(resource)) {
|
||||
throw new Error("we were not passed an ID but got an array anyways");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
async function deprecateOldSigningSecrets<T extends {}>(
|
||||
{
|
||||
collection,
|
||||
path,
|
||||
inactiveAt,
|
||||
filter,
|
||||
id,
|
||||
now,
|
||||
}: Pick<
|
||||
RotateSigningSecretOptions<T>,
|
||||
"collection" | "path" | "inactiveAt" | "filter" | "id" | "now"
|
||||
>,
|
||||
doc: Readonly<T>
|
||||
) {
|
||||
// Get the resource from the value.
|
||||
const resource = getResourceFromDoc({ id, path }, doc);
|
||||
if (!resource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the secrets we need to deactivate...
|
||||
const secretKIDsToDeprecate = getSecretKIDsToDeprecate(
|
||||
resource.signingSecrets
|
||||
);
|
||||
if (secretKIDsToDeprecate.length === 0) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
logger.trace(
|
||||
{ kids: secretKIDsToDeprecate, filter, arrayFilter: { id: id || null } },
|
||||
"deprecating old signingSecrets"
|
||||
);
|
||||
|
||||
// Construct the update operation for rotating the secret.
|
||||
let update: UpdateQuery<T>;
|
||||
if (id) {
|
||||
update = {
|
||||
$set: {
|
||||
[`${path}.$[resource].signingSecrets.$[signingSecret].inactiveAt`]: inactiveAt,
|
||||
[`${path}.$[resource].signingSecrets.$[signingSecret].rotatedAt`]: now,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
update = {
|
||||
$set: {
|
||||
[`${path}.signingSecrets.$[signingSecret].inactiveAt`]: inactiveAt,
|
||||
[`${path}.signingSecrets.$[signingSecret].rotatedAt`]: now,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Construct the options for the operation.
|
||||
const options: FindOneAndUpdateOption = {
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
arrayFilters: [
|
||||
// Select any signing secrets with the given ids.
|
||||
{ "signingSecret.kid": { $in: secretKIDsToDeprecate } },
|
||||
],
|
||||
};
|
||||
if (id) {
|
||||
options.arrayFilters!.push(
|
||||
// Select the secret resource under the object.
|
||||
{ "resource.id": id }
|
||||
);
|
||||
}
|
||||
|
||||
// Deactivate the old keys.
|
||||
const result = await collection.findOneAndUpdate(filter, update, options);
|
||||
if (!result.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function rotateSigningSecret<T extends {}>(
|
||||
options: RotateSigningSecretOptions<T>
|
||||
) {
|
||||
// Push the new secret into the resource and return it.
|
||||
let doc = await pushNewSigningSecret(options);
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Deprecate any old secrets on the document.
|
||||
doc = await deprecateOldSigningSecrets(options, doc);
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { isEmpty } from "lodash";
|
||||
import { Db } from "mongodb";
|
||||
import uuid from "uuid/v4";
|
||||
|
||||
import { dotize } from "coral-common/utils/dotize";
|
||||
import { tenants as collection } from "coral-server/services/mongodb/collections";
|
||||
|
||||
import { GQLCOMMENT_BODY_FORMAT } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import {
|
||||
ExternalModerationPhase,
|
||||
generateSigningSecret,
|
||||
getExternalModerationPhase,
|
||||
rotateSigningSecret,
|
||||
} from "../settings";
|
||||
import { retrieveTenant } from "./tenant";
|
||||
|
||||
export async function rotateTenantExternalModerationPhaseSigningSecret(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
phaseID: string,
|
||||
inactiveAt: Date,
|
||||
now: Date
|
||||
) {
|
||||
return rotateSigningSecret({
|
||||
collection: collection(mongo),
|
||||
filter: { id },
|
||||
path: "integrations.external.phases",
|
||||
id: phaseID,
|
||||
prefix: "empsec",
|
||||
inactiveAt,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
export interface CreateTenantExternalModerationPhaseInput {
|
||||
name: string;
|
||||
url: string;
|
||||
format: GQLCOMMENT_BODY_FORMAT;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
export async function createTenantExternalModerationPhase(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
input: CreateTenantExternalModerationPhaseInput,
|
||||
now: Date
|
||||
) {
|
||||
// Create the new phase.
|
||||
const phase: ExternalModerationPhase = {
|
||||
...input,
|
||||
id: uuid(),
|
||||
enabled: true,
|
||||
signingSecrets: [generateSigningSecret("empsec", now)],
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Update the Tenant with this new phase.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{ $push: { "integrations.external.phases": phase } },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return {
|
||||
phase: null,
|
||||
tenant: null,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return {
|
||||
phase,
|
||||
tenant: result.value,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateTenantExternalModerationPhaseInput {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
url?: string;
|
||||
format?: GQLCOMMENT_BODY_FORMAT;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export async function updateTenantExternalModerationPhase(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
phaseID: string,
|
||||
update: UpdateTenantExternalModerationPhaseInput
|
||||
) {
|
||||
const $set = dotize(
|
||||
{ "integrations.external.phases.$[phase]": update },
|
||||
{ embedArrays: true }
|
||||
);
|
||||
|
||||
// Check to see if there is any updates that will be made.
|
||||
if (isEmpty($set)) {
|
||||
// No updates need to be made, abort here and just return the tenant.
|
||||
return retrieveTenant(mongo, id);
|
||||
}
|
||||
|
||||
// Perform the actual update operation.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{ $set },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
arrayFilters: [{ "phase.id": phaseID }],
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!tenant.integrations.external) {
|
||||
throw new Error(`phase not found with id: ${phaseID} on tenant: ${id}`);
|
||||
}
|
||||
|
||||
const endpoint = getExternalModerationPhase(
|
||||
tenant.integrations.external,
|
||||
phaseID
|
||||
);
|
||||
if (!endpoint) {
|
||||
throw new Error(`phase not found with id: ${phaseID} on tenant: ${id}`);
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function deleteTenantExternalModerationPhase(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
phaseID: string
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$pull: {
|
||||
"integrations.external.phases": { id: phaseID },
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function deleteTenantExternalModerationPhaseSigningSecrets(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
phaseID: string,
|
||||
kids: string[]
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$pull: {
|
||||
"integrations.external.phases.$[phase].signingSecrets": {
|
||||
kid: { $in: kids },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ returnOriginal: false, arrayFilters: [{ "phase.id": phaseID }] }
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!tenant.integrations.external) {
|
||||
throw new Error(`phase not found with id: ${phaseID} on tenant: ${id}`);
|
||||
}
|
||||
|
||||
const endpoint = getExternalModerationPhase(
|
||||
tenant.integrations.external,
|
||||
phaseID
|
||||
);
|
||||
if (!endpoint) {
|
||||
throw new Error(`phase not found with id: ${phaseID} on tenant: ${id}`);
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FluentBundle } from "@fluent/bundle/compat";
|
||||
import crypto from "crypto";
|
||||
|
||||
import { translate } from "coral-server/services/i18n";
|
||||
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
GQLStaffConfiguration,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { Secret } from "../settings";
|
||||
import { Tenant } from "./tenant";
|
||||
|
||||
export const getDefaultReactionConfiguration = (
|
||||
@@ -34,23 +32,6 @@ export const getDefaultStaffConfiguration = (
|
||||
label: translate(bundle, "Staff", "staff-label"),
|
||||
});
|
||||
|
||||
export function generateRandomString(size: number, drift = 5) {
|
||||
return crypto
|
||||
.randomBytes(size + Math.floor(Math.random() * drift))
|
||||
.toString("hex");
|
||||
}
|
||||
|
||||
export function generateSecret(prefix: string, createdAt: Date): Secret {
|
||||
// Generate a new key. We generate a key of minimum length 32 up to 37 bytes,
|
||||
// as 16 was the minimum length recommended.
|
||||
//
|
||||
// Reference: https://security.stackexchange.com/a/96176
|
||||
const secret = prefix + "_" + generateRandomString(32, 5);
|
||||
const kid = generateRandomString(8, 3);
|
||||
|
||||
return { kid, secret, createdAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* hasFeatureFlag will check to see if the Tenant has a particular feature flag
|
||||
* enabled.
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from "./tenant";
|
||||
export * from "./helpers";
|
||||
export * from "./externalModerationPhase";
|
||||
export * from "./webhookEndpoint";
|
||||
export * from "./sso";
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { rotateSigningSecret } from "coral-server/models/settings";
|
||||
import { tenants as collection } from "coral-server/services/mongodb/collections";
|
||||
|
||||
export async function rotateTenantSSOSigningSecret(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
inactiveAt: Date,
|
||||
now: Date
|
||||
) {
|
||||
return rotateSigningSecret({
|
||||
collection: collection(mongo),
|
||||
filter: { id },
|
||||
path: "auth.integrations.sso",
|
||||
prefix: "ssosec",
|
||||
inactiveAt,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deactivateTenantSSOSigningSecret(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
kid: string,
|
||||
inactiveAt: Date,
|
||||
now: Date
|
||||
) {
|
||||
// Update the tenant.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$set: {
|
||||
"auth.integrations.sso.keys.$[keys].inactiveAt": inactiveAt,
|
||||
"auth.integrations.sso.keys.$[keys].rotatedAt": now,
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
// Add an ArrayFilter to only update one of the keys.
|
||||
arrayFilters: [{ "keys.kid": kid }],
|
||||
}
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function deleteTenantSSOSigningSecret(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
kid: string
|
||||
) {
|
||||
// Update the tenant.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$pull: {
|
||||
"auth.integrations.sso.keys": { kid },
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Redis } from "ioredis";
|
||||
import { isEmpty } from "lodash";
|
||||
import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
@@ -10,7 +9,11 @@ import TIME from "coral-common/time";
|
||||
import { DeepPartial, Sub } from "coral-common/types";
|
||||
import { isBeforeDate } from "coral-common/utils";
|
||||
import { dotize } from "coral-common/utils/dotize";
|
||||
import logger from "coral-server/logger";
|
||||
import {
|
||||
generateSigningSecret,
|
||||
Settings,
|
||||
SigningSecretResource,
|
||||
} from "coral-server/models/settings";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { tenants as collection } from "coral-server/services/mongodb/collections";
|
||||
|
||||
@@ -22,12 +25,9 @@ import {
|
||||
GQLWEBHOOK_EVENT_NAME,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { Secret, Settings } from "../settings";
|
||||
import {
|
||||
generateSecret,
|
||||
getDefaultReactionConfiguration,
|
||||
getDefaultStaffConfiguration,
|
||||
getWebhookEndpoint,
|
||||
} from "./helpers";
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,7 @@ export interface TenantResource {
|
||||
readonly tenantID: string;
|
||||
}
|
||||
|
||||
export interface Endpoint {
|
||||
export interface Endpoint extends SigningSecretResource {
|
||||
/**
|
||||
* id is the unique identifier for this specific endpoint.
|
||||
*/
|
||||
@@ -58,11 +58,6 @@ export interface Endpoint {
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* signingSecret is the secret used to sign the events sent out.
|
||||
*/
|
||||
signingSecrets: Secret[];
|
||||
|
||||
/**
|
||||
* all when true indicates that all events should trigger.
|
||||
*/
|
||||
@@ -198,7 +193,7 @@ export async function createTenant(
|
||||
stream: true,
|
||||
},
|
||||
// TODO: [CORL-754] (wyattjoh) remove this in favor of generating this when needed
|
||||
keys: [generateSecret("ssosec", now)],
|
||||
signingSecrets: [generateSigningSecret("ssosec", now)],
|
||||
},
|
||||
oidc: {
|
||||
enabled: false,
|
||||
@@ -360,59 +355,6 @@ export async function updateTenant(
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* regenerateTenantSSOKey will regenerate the SSO key used for Single Sing-On
|
||||
* for the specified Tenant. All existing user sessions signed with the old
|
||||
* secret will be invalidated.
|
||||
*/
|
||||
export async function createTenantSSOKey(mongo: Db, id: string, now: Date) {
|
||||
// Construct the new key.
|
||||
const key = generateSecret("ssosec", now);
|
||||
|
||||
// Update the Tenant with this new key.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$push: {
|
||||
"auth.integrations.sso.keys": key,
|
||||
},
|
||||
},
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function deactivateTenantSSOKey(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
kid: string,
|
||||
inactiveAt: Date,
|
||||
now: Date
|
||||
) {
|
||||
// Update the tenant.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$set: {
|
||||
"auth.integrations.sso.keys.$[keys].inactiveAt": inactiveAt,
|
||||
"auth.integrations.sso.keys.$[keys].rotatedAt": now,
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
// Add an ArrayFilter to only update one of the keys.
|
||||
arrayFilters: [{ "keys.kid": kid }],
|
||||
}
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function enableTenantFeatureFlag(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
@@ -460,24 +402,6 @@ export async function disableTenantFeatureFlag(
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
export async function deleteTenantSSOKey(mongo: Db, id: string, kid: string) {
|
||||
// Update the tenant.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$pull: {
|
||||
"auth.integrations.sso.keys": { kid },
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export interface CreateAnnouncementInput {
|
||||
content: string;
|
||||
@@ -542,306 +466,3 @@ export function retrieveAnnouncementIfEnabled(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function rollTenantWebhookEndpointSecret(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string,
|
||||
inactiveAt: Date,
|
||||
now: Date
|
||||
) {
|
||||
// Create the new secret.
|
||||
const secret = generateSecret("whsec", now);
|
||||
|
||||
// Update the Tenant with this new secret.
|
||||
let result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$push: { "webhooks.endpoints.$[endpoint].signingSecrets": secret },
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
arrayFilters: [
|
||||
// Select the endpoint we're updating.
|
||||
{ "endpoint.id": endpointID },
|
||||
],
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Grab the endpoint we just modified.
|
||||
const endpoint = getWebhookEndpoint(result.value, endpointID);
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the secrets we need to deactivate...
|
||||
const secretKIDsToDeprecate = endpoint.signingSecrets
|
||||
// By excluding the last one (the one we just pushed)...
|
||||
.splice(0, endpoint.signingSecrets.length - 1)
|
||||
// And only finding keys that have not been rotated yet.
|
||||
.filter((s) => !s.rotatedAt)
|
||||
// And get their kid's.
|
||||
.map((s) => s.kid);
|
||||
if (secretKIDsToDeprecate.length > 0) {
|
||||
logger.trace(
|
||||
{ kids: secretKIDsToDeprecate },
|
||||
"deprecating old signingSecrets"
|
||||
);
|
||||
|
||||
// Deactivate the old keys.
|
||||
result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$set: {
|
||||
"webhooks.endpoints.$[endpoint].signingSecrets.$[signingSecret].inactiveAt": inactiveAt,
|
||||
"webhooks.endpoints.$[endpoint].signingSecrets.$[signingSecret].rotatedAt": now,
|
||||
},
|
||||
},
|
||||
{
|
||||
arrayFilters: [
|
||||
// Select the endpoint we're updating.
|
||||
{ "endpoint.id": endpointID },
|
||||
// Select any signing secrets with the given ids.
|
||||
{ "signingSecret.kid": { $in: secretKIDsToDeprecate } },
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export interface CreateTenantWebhookEndpointInput {
|
||||
url: string;
|
||||
all: boolean;
|
||||
events: GQLWEBHOOK_EVENT_NAME[];
|
||||
}
|
||||
|
||||
export async function createTenantWebhookEndpoint(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
input: CreateTenantWebhookEndpointInput,
|
||||
now: Date
|
||||
) {
|
||||
// Create the new endpoint.
|
||||
const endpoint: Endpoint = {
|
||||
...input,
|
||||
id: uuid(),
|
||||
enabled: true,
|
||||
signingSecrets: [generateSecret("whsec", now)],
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Update the Tenant with this new endpoint.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{ $push: { "webhooks.endpoints": endpoint } },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return {
|
||||
endpoint: null,
|
||||
tenant: null,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
tenant: result.value,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateTenantWebhookEndpointInput {
|
||||
enabled?: boolean;
|
||||
url?: string;
|
||||
all?: boolean;
|
||||
events?: GQLWEBHOOK_EVENT_NAME[];
|
||||
}
|
||||
|
||||
export async function updateTenantWebhookEndpoint(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string,
|
||||
update: UpdateTenantWebhookEndpointInput
|
||||
) {
|
||||
const $set = dotize(
|
||||
{ "webhooks.endpoints.$[endpoint]": update },
|
||||
{ embedArrays: true }
|
||||
);
|
||||
|
||||
// Check to see if there is any updates that will be made.
|
||||
if (isEmpty($set)) {
|
||||
// No updates need to be made, abort here and just return the tenant.
|
||||
return retrieveTenant(mongo, id);
|
||||
}
|
||||
|
||||
// Perform the actual update operation.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{ $set },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
arrayFilters: [{ "endpoint.id": endpointID }],
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error(
|
||||
`endpoint not found with id: ${endpointID} on tenant: ${id}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function deleteEndpointSecrets(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string,
|
||||
kids: string[]
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$pull: {
|
||||
"webhooks.endpoints.$[endpoint].signingSecrets": { kid: { $in: kids } },
|
||||
},
|
||||
},
|
||||
{ returnOriginal: false, arrayFilters: [{ "endpoint.id": endpointID }] }
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error(
|
||||
`endpoint not found with id: ${endpointID} on tenant: ${id}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function deleteTenantWebhookEndpoint(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$pull: {
|
||||
"webhooks.endpoints": { id: endpointID },
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function lastUsedAtTenantSSOKey(id: string): string {
|
||||
return `${id}:lastUsedSSOKey`;
|
||||
}
|
||||
|
||||
/**
|
||||
* updateLastUsedAtTenantSSOKey will update the time stamp that the SSO key was
|
||||
* last used at.
|
||||
*
|
||||
* @param redis the Redis connection to use to update the timestamp on
|
||||
* @param id the ID of the Tenant
|
||||
* @param kid the kid of the token that was used
|
||||
* @param when the date that the token was last used at
|
||||
*/
|
||||
export async function updateLastUsedAtTenantSSOKey(
|
||||
redis: Redis,
|
||||
id: string,
|
||||
kid: string,
|
||||
when: Date
|
||||
) {
|
||||
await redis.hset(lastUsedAtTenantSSOKey(id), kid, when.toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param redis the Redis connection to use to remove the last used on.
|
||||
* @param id the ID of the Tenant
|
||||
* @param kid the kid of the token that is being deleted
|
||||
*/
|
||||
export async function deleteLastUsedAtTenantSSOKey(
|
||||
redis: Redis,
|
||||
id: string,
|
||||
kid: string
|
||||
) {
|
||||
await redis.hdel(lastUsedAtTenantSSOKey(id), kid);
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieveLastUsedAtTenantSSOKeys will get the dates that the requested sso
|
||||
* keys were last used on.
|
||||
*
|
||||
* @param redis the Redis connection to use to update the timestamp on
|
||||
* @param id the ID of the Tenant
|
||||
* @param kids the kids of the tokens that we want to know when they were last used
|
||||
*/
|
||||
export async function retrieveLastUsedAtTenantSSOKeys(
|
||||
redis: Redis,
|
||||
id: string,
|
||||
kids: string[]
|
||||
) {
|
||||
const results: Array<string | null> = await redis.hmget(
|
||||
lastUsedAtTenantSSOKey(id),
|
||||
...kids
|
||||
);
|
||||
|
||||
return results.map((lastUsedAt) => {
|
||||
if (!lastUsedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(lastUsedAt);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { Redis } from "ioredis";
|
||||
import { isEmpty } from "lodash";
|
||||
import { Db } from "mongodb";
|
||||
import uuid from "uuid/v4";
|
||||
|
||||
import { dotize } from "coral-common/utils/dotize";
|
||||
import { tenants as collection } from "coral-server/services/mongodb/collections";
|
||||
|
||||
import { GQLWEBHOOK_EVENT_NAME } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { generateSigningSecret, rotateSigningSecret } from "../settings";
|
||||
import { getWebhookEndpoint } from "./helpers";
|
||||
import { Endpoint, retrieveTenant } from "./tenant";
|
||||
|
||||
export async function rotateTenantWebhookEndpointSigningSecret(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string,
|
||||
inactiveAt: Date,
|
||||
now: Date
|
||||
) {
|
||||
return rotateSigningSecret({
|
||||
collection: collection(mongo),
|
||||
filter: { id },
|
||||
path: "webhooks.endpoints",
|
||||
prefix: "whsec",
|
||||
id: endpointID,
|
||||
inactiveAt,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
export interface CreateTenantWebhookEndpointInput {
|
||||
url: string;
|
||||
all: boolean;
|
||||
events: GQLWEBHOOK_EVENT_NAME[];
|
||||
}
|
||||
|
||||
export async function createTenantWebhookEndpoint(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
input: CreateTenantWebhookEndpointInput,
|
||||
now: Date
|
||||
) {
|
||||
// Create the new endpoint.
|
||||
const endpoint: Endpoint = {
|
||||
...input,
|
||||
id: uuid(),
|
||||
enabled: true,
|
||||
signingSecrets: [generateSigningSecret("whsec", now)],
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Update the Tenant with this new endpoint.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{ $push: { "webhooks.endpoints": endpoint } },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return {
|
||||
endpoint: null,
|
||||
tenant: null,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
tenant: result.value,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateTenantWebhookEndpointInput {
|
||||
enabled?: boolean;
|
||||
url?: string;
|
||||
all?: boolean;
|
||||
events?: GQLWEBHOOK_EVENT_NAME[];
|
||||
}
|
||||
|
||||
export async function updateTenantWebhookEndpoint(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string,
|
||||
update: UpdateTenantWebhookEndpointInput
|
||||
) {
|
||||
const $set = dotize(
|
||||
{ "webhooks.endpoints.$[endpoint]": update },
|
||||
{ embedArrays: true }
|
||||
);
|
||||
|
||||
// Check to see if there is any updates that will be made.
|
||||
if (isEmpty($set)) {
|
||||
// No updates need to be made, abort here and just return the tenant.
|
||||
return retrieveTenant(mongo, id);
|
||||
}
|
||||
|
||||
// Perform the actual update operation.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{ $set },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
arrayFilters: [{ "endpoint.id": endpointID }],
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error(
|
||||
`endpoint not found with id: ${endpointID} on tenant: ${id}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function deleteTenantWebhookEndpointSigningSecrets(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string,
|
||||
kids: string[]
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$pull: {
|
||||
"webhooks.endpoints.$[endpoint].signingSecrets": { kid: { $in: kids } },
|
||||
},
|
||||
},
|
||||
{ returnOriginal: false, arrayFilters: [{ "endpoint.id": endpointID }] }
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error(
|
||||
`endpoint not found with id: ${endpointID} on tenant: ${id}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function deleteTenantWebhookEndpoint(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$pull: {
|
||||
"webhooks.endpoints": { id: endpointID },
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function lastUsedAtTenantSSOSigningSecret(id: string): string {
|
||||
return `${id}:lastUsedSSOSigningSecret`;
|
||||
}
|
||||
|
||||
/**
|
||||
* updateLastUsedAtTenantSSOSigningSecret will update the time stamp that the
|
||||
* SSO key was last used at.
|
||||
*
|
||||
* @param redis the Redis connection to use to update the timestamp on
|
||||
* @param id the ID of the Tenant
|
||||
* @param kid the kid of the token that was used
|
||||
* @param when the date that the token was last used at
|
||||
*/
|
||||
export async function updateLastUsedAtTenantSSOSigningSecret(
|
||||
redis: Redis,
|
||||
id: string,
|
||||
kid: string,
|
||||
when: Date
|
||||
) {
|
||||
await redis.hset(
|
||||
lastUsedAtTenantSSOSigningSecret(id),
|
||||
kid,
|
||||
when.toISOString()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param redis the Redis connection to use to remove the last used on.
|
||||
* @param id the ID of the Tenant
|
||||
* @param kid the kid of the token that is being deleted
|
||||
*/
|
||||
export async function deleteLastUsedAtTenantSSOSigningSecret(
|
||||
redis: Redis,
|
||||
id: string,
|
||||
kid: string
|
||||
) {
|
||||
await redis.hdel(lastUsedAtTenantSSOSigningSecret(id), kid);
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieveLastUsedAtTenantSSOSigningSecrets will get the dates that the
|
||||
* requested sso keys were last used on.
|
||||
*
|
||||
* @param redis the Redis connection to use to update the timestamp on
|
||||
* @param id the ID of the Tenant
|
||||
* @param kids the kids of the tokens that we want to know when they were last used
|
||||
*/
|
||||
export async function retrieveLastUsedAtTenantSSOSigningSecrets(
|
||||
redis: Redis,
|
||||
id: string,
|
||||
kids: string[]
|
||||
) {
|
||||
const results: Array<string | null> = await redis.hmget(
|
||||
lastUsedAtTenantSSOSigningSecret(id),
|
||||
...kids
|
||||
);
|
||||
|
||||
return results.map((lastUsedAt) => {
|
||||
if (!lastUsedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(lastUsedAt);
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Config } from "coral-server/config";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { JWTSigningConfig } from "coral-server/services/jwt";
|
||||
import { AugmentedRedis, createRedisClient } from "coral-server/services/redis";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
|
||||
import { createMailerTask, MailerQueue } from "./tasks/mailer";
|
||||
import { createNotifierTask, NotifierQueue } from "./tasks/notifier";
|
||||
|
||||
@@ -3,8 +3,10 @@ import nunjucks, { Environment, ILoader } from "nunjucks";
|
||||
import path from "path";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter";
|
||||
import {
|
||||
TenantCache,
|
||||
TenantCacheAdapter,
|
||||
} from "coral-server/services/tenant/cache";
|
||||
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { EmailTemplate } from "./templates";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createTimer } from "coral-server/helpers";
|
||||
import logger from "coral-server/logger";
|
||||
import Task from "coral-server/queue/Task";
|
||||
import MailerContent from "coral-server/queue/tasks/mailer/content";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
|
||||
import {
|
||||
createJobProcessor,
|
||||
|
||||
@@ -12,15 +12,17 @@ import { Db } from "mongodb";
|
||||
import { createTransport } from "nodemailer";
|
||||
import { Options } from "nodemailer/lib/smtp-connection";
|
||||
|
||||
import { LanguageCode } from "coral-common/helpers/i18n/locales";
|
||||
import { LanguageCode } from "coral-common/helpers";
|
||||
import { Config } from "coral-server/config";
|
||||
import { InternalError } from "coral-server/errors";
|
||||
import { WrappedInternalError } from "coral-server/errors";
|
||||
import { createTimer } from "coral-server/helpers";
|
||||
import logger from "coral-server/logger";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { I18n, translate } from "coral-server/services/i18n";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter";
|
||||
import {
|
||||
TenantCache,
|
||||
TenantCacheAdapter,
|
||||
} from "coral-server/services/tenant/cache";
|
||||
|
||||
export const JOB_NAME = "mailer";
|
||||
|
||||
@@ -254,7 +256,7 @@ export const createJobProcessor = (options: MailProcessorOptions) => {
|
||||
data
|
||||
);
|
||||
} catch (e) {
|
||||
throw new InternalError(e, "could not translate the message");
|
||||
throw new WrappedInternalError(e, "could not translate the message");
|
||||
}
|
||||
|
||||
log.trace(
|
||||
@@ -284,7 +286,7 @@ export const createJobProcessor = (options: MailProcessorOptions) => {
|
||||
// Create the transport based on the smtp uri.
|
||||
transport = createTransport(opts);
|
||||
} catch (e) {
|
||||
throw new InternalError(e, "could not create email transport");
|
||||
throw new WrappedInternalError(e, "could not create email transport");
|
||||
}
|
||||
|
||||
// Set the transport back into the cache.
|
||||
@@ -303,7 +305,7 @@ export const createJobProcessor = (options: MailProcessorOptions) => {
|
||||
// Send the mail message.
|
||||
await transport.sendMail(message);
|
||||
} catch (e) {
|
||||
throw new InternalError(e, "could not send email");
|
||||
throw new WrappedInternalError(e, "could not send email");
|
||||
}
|
||||
|
||||
log.debug({ responseTime: messageSendTimer() }, "sent the email");
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
categories,
|
||||
NotificationCategory,
|
||||
} from "coral-server/services/notifications/categories";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
|
||||
import { createJobProcessor, JOB_NAME, NotifierData } from "./processor";
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { JWTSigningConfig } from "coral-server/services/jwt";
|
||||
import { NotificationCategory } from "coral-server/services/notifications/categories";
|
||||
import NotificationContext from "coral-server/services/notifications/context";
|
||||
import { Notification } from "coral-server/services/notifications/notification";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
|
||||
import {
|
||||
filterSuperseded,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { Connection } from "coral-server/models/helpers";
|
||||
import Task from "coral-server/queue/Task";
|
||||
import { AugmentedRedis } from "coral-server/services/redis";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
import { rejectComment } from "coral-server/stacks";
|
||||
|
||||
import { GQLCOMMENT_SORT } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import crypto from "crypto";
|
||||
import { Redis } from "ioredis";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
@@ -6,19 +5,15 @@ import { Config } from "coral-server/config";
|
||||
import { CoralEventPayload } from "coral-server/events/event";
|
||||
import { createTimer } from "coral-server/helpers";
|
||||
import logger from "coral-server/logger";
|
||||
import { filterExpiredSigningSecrets } from "coral-server/models/settings";
|
||||
import {
|
||||
filterActiveSecrets,
|
||||
filterExpiredSecrets,
|
||||
} from "coral-server/models/settings";
|
||||
import {
|
||||
deleteEndpointSecrets,
|
||||
Endpoint,
|
||||
deleteTenantWebhookEndpointSigningSecrets,
|
||||
getWebhookEndpoint,
|
||||
} from "coral-server/models/tenant";
|
||||
import { JobProcessor } from "coral-server/queue/Task";
|
||||
import { createFetch, FetchOptions } from "coral-server/services/fetch";
|
||||
import { createFetch, generateFetchOptions } from "coral-server/services/fetch";
|
||||
import { disableWebhookEndpoint } from "coral-server/services/tenant";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
|
||||
export const JOB_NAME = "webhook";
|
||||
|
||||
@@ -53,36 +48,7 @@ export interface WebhookDelivery {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* generateSignature will generate a signature used to assist clients to
|
||||
* validate that the request came from Coral.
|
||||
*
|
||||
* @param secret the secret used to sign the body with
|
||||
* @param body the body to use when signing
|
||||
*/
|
||||
export function generateSignature(secret: string, body: string) {
|
||||
return crypto
|
||||
.createHmac("sha256", secret)
|
||||
.update(body)
|
||||
.digest()
|
||||
.toString("hex");
|
||||
}
|
||||
|
||||
export function generateSignatures(
|
||||
endpoint: Pick<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(",");
|
||||
}
|
||||
|
||||
type CoralWebhookEventPayload = CoralEventPayload & {
|
||||
interface CoralWebhookEventPayload extends CoralEventPayload {
|
||||
/**
|
||||
* tenantID is the ID of the Tenant that this event originated at.
|
||||
*/
|
||||
@@ -92,28 +58,6 @@ type CoralWebhookEventPayload = CoralEventPayload & {
|
||||
* tenantDomain is the domain that is associated with this Tenant that this event originated at.
|
||||
*/
|
||||
readonly tenantDomain: string;
|
||||
};
|
||||
|
||||
export function generateFetchOptions(
|
||||
endpoint: Pick<Endpoint, "signingSecrets">,
|
||||
data: CoralWebhookEventPayload,
|
||||
now: Date
|
||||
): FetchOptions {
|
||||
// Serialize the body and signature to include in the request.
|
||||
const body = JSON.stringify(data, null, 2);
|
||||
const signature = generateSignatures(endpoint, body, now);
|
||||
|
||||
const headers: Record<string, any> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Coral-Event": data.type,
|
||||
"X-Coral-Signature": signature,
|
||||
};
|
||||
|
||||
return {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
export function createJobProcessor({
|
||||
@@ -162,12 +106,21 @@ export function createJobProcessor({
|
||||
// Get the current date.
|
||||
const now = new Date();
|
||||
|
||||
// Generate the payload.
|
||||
const payload: CoralWebhookEventPayload = {
|
||||
...event,
|
||||
tenantID,
|
||||
tenantDomain: tenant.domain,
|
||||
};
|
||||
|
||||
// Get the fetch options.
|
||||
const options = generateFetchOptions(
|
||||
endpoint,
|
||||
{ ...event, tenantID, tenantDomain: tenant.domain },
|
||||
now
|
||||
);
|
||||
const options = generateFetchOptions(endpoint.signingSecrets, payload, now);
|
||||
|
||||
// Add the X-Coral-Event header.
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
"X-Coral-Event": event.type,
|
||||
};
|
||||
|
||||
// Send the request.
|
||||
const timer = createTimer();
|
||||
@@ -240,35 +193,34 @@ export function createJobProcessor({
|
||||
tenant,
|
||||
endpointID
|
||||
);
|
||||
} else {
|
||||
// TODO: (wyattjoh) maybe schedule a retry?
|
||||
}
|
||||
// TODO: (wyattjoh) maybe schedule a retry?
|
||||
}
|
||||
|
||||
// Remove the expired secrets in the next tick so that it does not affect
|
||||
// the sending performance of this job, and errors do not impact the
|
||||
// sending.
|
||||
const expiredSigningSecrets = endpoint.signingSecrets.filter(
|
||||
filterExpiredSecrets(now)
|
||||
);
|
||||
if (expiredSigningSecrets.length > 0) {
|
||||
const expiredSigningSecretKIDs = endpoint.signingSecrets
|
||||
.filter(filterExpiredSigningSecrets(now))
|
||||
.map((s) => s.kid);
|
||||
if (expiredSigningSecretKIDs.length > 0) {
|
||||
process.nextTick(() => {
|
||||
deleteEndpointSecrets(
|
||||
deleteTenantWebhookEndpointSigningSecrets(
|
||||
mongo,
|
||||
tenantID,
|
||||
endpoint.id,
|
||||
expiredSigningSecrets.map((s) => s.kid)
|
||||
expiredSigningSecretKIDs
|
||||
)
|
||||
.then(() => {
|
||||
log.info(
|
||||
{ secrets: expiredSigningSecrets.length },
|
||||
{ endpointID: endpoint.id, kids: expiredSigningSecretKIDs },
|
||||
"removed expired secrets from endpoint"
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error(
|
||||
{ err },
|
||||
"an error occurred when trying to remove expired secrets"
|
||||
"an error occurred when trying to remove expired endpoint secrets"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { PhaseResult } from "./pipeline";
|
||||
|
||||
export function mergePhaseResult(
|
||||
result: Partial<PhaseResult>,
|
||||
final: Partial<PhaseResult>
|
||||
) {
|
||||
const { actions = [], tags = [], metadata = {} } = final;
|
||||
|
||||
// If this result contained actions, then we should push it into the
|
||||
// other actions.
|
||||
if (result.actions) {
|
||||
final.actions = [...actions, ...result.actions];
|
||||
}
|
||||
|
||||
// If this result contained metadata, then we should merge it into the
|
||||
// other metadata.
|
||||
if (result.metadata) {
|
||||
final.metadata = { ...metadata, ...result.metadata };
|
||||
}
|
||||
|
||||
// If the result modified the comment body, we should replace it.
|
||||
if (result.body) {
|
||||
final.body = result.body;
|
||||
}
|
||||
|
||||
// If the result added any tags, we should push it into the existing tags.
|
||||
if (result.tags && result.tags.length > 0) {
|
||||
final.tags = [
|
||||
...tags,
|
||||
// Only push in tags that we haven't already added.
|
||||
...result.tags.filter((tag) => !tags.includes(tag)),
|
||||
];
|
||||
}
|
||||
|
||||
// If this result contained a status, then we've finished resolving
|
||||
// phases!
|
||||
if (result.status) {
|
||||
final.status = result.status;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -20,9 +20,7 @@ export const approve: IntermediateModerationPhase = ({
|
||||
// automatically approved. We will only see EXPERT
|
||||
// tags assigned when we are in Q&A mode, so we can
|
||||
// trust this simple tag type check.
|
||||
if (
|
||||
tags.some((tag) => tag.type === GQLTAG.STAFF || tag.type === GQLTAG.EXPERT)
|
||||
) {
|
||||
if (tags.includes(GQLTAG.STAFF) || tags.includes(GQLTAG.EXPERT)) {
|
||||
return {
|
||||
status: GQLCOMMENT_STATUS.APPROVED,
|
||||
};
|
||||
|
||||
@@ -32,7 +32,6 @@ export const detectLinks: IntermediateModerationPhase = ({
|
||||
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
|
||||
actions: [
|
||||
{
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { validateResponse } from "./external";
|
||||
|
||||
describe("validateResponse", () => {
|
||||
it("allows an empty response", () => {
|
||||
expect(validateResponse({})).toEqual({});
|
||||
});
|
||||
it("allows a valid status response", () => {
|
||||
expect(validateResponse({ status: "NONE" })).toEqual({ status: "NONE" });
|
||||
expect(validateResponse({ status: "APPROVED" })).toEqual({
|
||||
status: "APPROVED",
|
||||
});
|
||||
expect(validateResponse({ status: "REJECTED" })).toEqual({
|
||||
status: "REJECTED",
|
||||
});
|
||||
expect(validateResponse({ status: "SYSTEM_WITHHELD" })).toEqual({
|
||||
status: "SYSTEM_WITHHELD",
|
||||
});
|
||||
});
|
||||
it("allows a valid tag response", () => {
|
||||
expect(validateResponse({ tags: ["FEATURED"] })).toEqual({
|
||||
tags: ["FEATURED"],
|
||||
});
|
||||
expect(validateResponse({ tags: ["STAFF"] })).toEqual({
|
||||
tags: ["STAFF"],
|
||||
});
|
||||
});
|
||||
it("allows and strips unknown fields from the response", () => {
|
||||
expect(validateResponse({ willFail: false })).toEqual({});
|
||||
});
|
||||
it("allows a valid action response", () => {
|
||||
expect(
|
||||
validateResponse({
|
||||
actions: [{ actionType: "FLAG", reason: "COMMENT_DETECTED_SPAM" }],
|
||||
})
|
||||
).toEqual({
|
||||
actions: [{ actionType: "FLAG", reason: "COMMENT_DETECTED_SPAM" }],
|
||||
});
|
||||
});
|
||||
it("allows a valid action response and filters undefined fields", () => {
|
||||
expect(
|
||||
validateResponse({
|
||||
actions: [
|
||||
{
|
||||
actionType: "FLAG",
|
||||
reason: "COMMENT_DETECTED_TOXIC",
|
||||
additionalDetails: "This is additional details",
|
||||
metadata: { this: { is: { a: { deep: "object" } } } },
|
||||
},
|
||||
],
|
||||
})
|
||||
).toEqual({
|
||||
actions: [
|
||||
{
|
||||
actionType: "FLAG",
|
||||
reason: "COMMENT_DETECTED_TOXIC",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
it("disallows incorrect reasons in the actions from the response", () => {
|
||||
expect(() =>
|
||||
validateResponse({
|
||||
actions: [{ actionType: "FLAG", reason: "COMMENT_DETECTED_NOT_REAL" }],
|
||||
})
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
validateResponse({
|
||||
actions: [
|
||||
{ actionType: "ALSO_NOT_REAL", reason: "COMMENT_DETECTED_NOT_REAL" },
|
||||
],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
it("disallows incorrect tag types in the tags from the response", () => {
|
||||
expect(() => validateResponse({ tags: ["NOT_REAL"] })).toThrow();
|
||||
expect(() => validateResponse({ tags: [""] })).toThrow();
|
||||
});
|
||||
it("disallows incorrect values in the status response", () => {
|
||||
expect(() => validateResponse({ status: "fail" })).toThrow();
|
||||
expect(() => validateResponse({ status: "approved" })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import Joi from "@hapi/joi";
|
||||
|
||||
import { ACTION_TYPE } from "coral-server/models/action/comment";
|
||||
import {
|
||||
ExternalModerationPhase,
|
||||
filterActivePhase,
|
||||
filterExpiredSigningSecrets,
|
||||
} from "coral-server/models/settings";
|
||||
import { deleteTenantExternalModerationPhaseSigningSecrets } from "coral-server/models/tenant";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
PhaseResult,
|
||||
} from "coral-server/services/comments/pipeline";
|
||||
import { createFetch, generateFetchOptions } from "coral-server/services/fetch";
|
||||
|
||||
import {
|
||||
GQLCOMMENT_BODY_FORMAT,
|
||||
GQLCOMMENT_FLAG_DETECTED_REASON,
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLTAG,
|
||||
GQLUSER_ROLE,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { mergePhaseResult } from "../helpers";
|
||||
import { IntermediateModerationPhaseContext } from "../pipeline";
|
||||
|
||||
export interface ExternalModerationRequest {
|
||||
/**
|
||||
* action refers to the specific operation being performed. If `NEW`, this
|
||||
* is referring to a new comment being created. If `EDIT`, then this refers to
|
||||
* an operation involving an edit operation on an existing Comment.
|
||||
*/
|
||||
action: "NEW" | "EDIT";
|
||||
|
||||
/**
|
||||
* comment refers to the actual Comment data for the Comment being
|
||||
* created/edited.
|
||||
*/
|
||||
comment: {
|
||||
/**
|
||||
* body refers to the actual body text of the Comment being created/edited.
|
||||
*/
|
||||
body: string;
|
||||
|
||||
/**
|
||||
* parentID is the identifier for the parent comment (if this Comment is a
|
||||
* reply, null otherwise).
|
||||
*/
|
||||
parentID: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* author refers to the User that is creating/editing the Comment.
|
||||
*/
|
||||
author: {
|
||||
/**
|
||||
* id is the identifier for this User.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* role refers to the role of this User.
|
||||
*/
|
||||
role: GQLUSER_ROLE;
|
||||
};
|
||||
|
||||
/**
|
||||
* story refers to the Story being commented on.
|
||||
*/
|
||||
story: {
|
||||
/**
|
||||
* id is the identifier for this Story.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* url is the URL for this Story.
|
||||
*/
|
||||
url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* site refers to the Site that the story being commented on belongs to.
|
||||
*/
|
||||
site: {
|
||||
/**
|
||||
* id is the identifier for this Site.
|
||||
*/
|
||||
id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* tenantID is the identifer of the Tenant that this Comment is being
|
||||
* created/edited on.
|
||||
*/
|
||||
tenantID: string;
|
||||
|
||||
/**
|
||||
* tenantDomain is the domain that is associated with this Tenant that this
|
||||
* Comment is being created/edited on.
|
||||
*/
|
||||
tenantDomain: string;
|
||||
}
|
||||
|
||||
export type ExternalModerationResponse = Partial<
|
||||
Pick<PhaseResult, "actions" | "status" | "tags">
|
||||
>;
|
||||
|
||||
const ExternalModerationResponseSchema = Joi.object().keys({
|
||||
actions: Joi.array().items(
|
||||
Joi.object().keys({
|
||||
actionType: Joi.string().only().allow(ACTION_TYPE.FLAG).required(),
|
||||
reason: Joi.string()
|
||||
.only()
|
||||
.allow(
|
||||
GQLCOMMENT_FLAG_DETECTED_REASON.COMMENT_DETECTED_TOXIC,
|
||||
GQLCOMMENT_FLAG_DETECTED_REASON.COMMENT_DETECTED_SPAM
|
||||
)
|
||||
.required(),
|
||||
})
|
||||
),
|
||||
status: Joi.string()
|
||||
.only()
|
||||
.allow(...Object.keys(GQLCOMMENT_STATUS)),
|
||||
tags: Joi.array().items(
|
||||
Joi.string().only().allow(GQLTAG.FEATURED, GQLTAG.STAFF).required()
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* validate will validate the `ExternalModerationResponse`.
|
||||
*
|
||||
* @param body the input body that is being coerced into an `ExternalModerationResponse`.
|
||||
*/
|
||||
export function validateResponse(body: object): ExternalModerationResponse {
|
||||
const { value, error: err } = ExternalModerationResponseSchema.validate(
|
||||
body,
|
||||
{
|
||||
stripUnknown: true,
|
||||
presence: "optional",
|
||||
abortEarly: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
const fetch = createFetch({ name: "Moderation" });
|
||||
|
||||
/**
|
||||
* processPhase will execute the request for moderation for this particular
|
||||
* phase.
|
||||
*
|
||||
* @param ctx the context for the moderation request.
|
||||
* @param phase the current phase associated with this request.
|
||||
*/
|
||||
async function processPhase(
|
||||
{
|
||||
mongo,
|
||||
action,
|
||||
comment,
|
||||
htmlStripped,
|
||||
author,
|
||||
tenant,
|
||||
story,
|
||||
now,
|
||||
log,
|
||||
}: IntermediateModerationPhaseContext,
|
||||
phase: ExternalModerationPhase
|
||||
) {
|
||||
// Create the crafted input payload to be used.
|
||||
const request: ExternalModerationRequest = {
|
||||
action,
|
||||
comment: {
|
||||
body:
|
||||
// Depending on the selected format, the comment body could be in an
|
||||
// HTML or HTML stripped format.
|
||||
phase.format === GQLCOMMENT_BODY_FORMAT.HTML
|
||||
? comment.body
|
||||
: htmlStripped,
|
||||
// We're casting this to a `string | null` here because it's more
|
||||
// actionable to get a `null` rather than an undefined value in a
|
||||
// request.
|
||||
parentID: comment.parentID || null,
|
||||
},
|
||||
author: {
|
||||
id: author.id,
|
||||
role: author.role,
|
||||
},
|
||||
story: {
|
||||
id: story.id,
|
||||
url: story.url,
|
||||
},
|
||||
site: {
|
||||
id: story.siteID,
|
||||
},
|
||||
tenantID: tenant.id,
|
||||
tenantDomain: tenant.domain,
|
||||
};
|
||||
|
||||
// Craft the request options now to use.
|
||||
const options = generateFetchOptions(phase.signingSecrets, request, now);
|
||||
|
||||
// Send off the request, with the correct timeout.
|
||||
const res = await fetch(phase.url, {
|
||||
...options,
|
||||
timeout: phase.timeout,
|
||||
});
|
||||
if (!res.ok) {
|
||||
// The phase did not respond correctly, continue.
|
||||
log.warn(
|
||||
{ status: res.status, phaseID: phase.id },
|
||||
"failed to get moderation response"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to parse the response.
|
||||
const text = await res.text();
|
||||
|
||||
// If the moderation phase responded 204, or there was no response from
|
||||
// the request, just continue.
|
||||
if (res.status === 204 || text === "" || text === "{}") {
|
||||
log.debug(
|
||||
{ status: res.status, phaseID: phase.id },
|
||||
"empty response received"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to parse the response as JSON.
|
||||
const body = JSON.parse(text);
|
||||
|
||||
// Remove the expired secrets in the next tick so that it does not affect
|
||||
// the sending performance of this job, and errors do not impact the
|
||||
// sending.
|
||||
const expiredSigningSecretKIDs = phase.signingSecrets
|
||||
.filter(filterExpiredSigningSecrets(now))
|
||||
.map((s) => s.kid);
|
||||
if (expiredSigningSecretKIDs.length > 0) {
|
||||
process.nextTick(() => {
|
||||
deleteTenantExternalModerationPhaseSigningSecrets(
|
||||
mongo,
|
||||
tenant.id,
|
||||
phase.id,
|
||||
expiredSigningSecretKIDs
|
||||
)
|
||||
.then(() => {
|
||||
log.info(
|
||||
{ phaseID: phase.id, kids: expiredSigningSecretKIDs },
|
||||
"removed expired secrets from phase"
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error(
|
||||
{ err },
|
||||
"an error occurred when trying to remove expired phase secrets"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Validate will throw an error if the body does not conform to the
|
||||
// specification.
|
||||
return validateResponse(body);
|
||||
}
|
||||
|
||||
export const external: IntermediateModerationPhase = async (ctx) => {
|
||||
// Check to see if any custom moderation phases have been defined, if there is
|
||||
// none, exit now.
|
||||
if (
|
||||
!ctx.tenant.integrations.external ||
|
||||
ctx.tenant.integrations.external.phases.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the enabled phases.
|
||||
const phases = ctx.tenant.integrations.external.phases.filter(
|
||||
filterActivePhase()
|
||||
);
|
||||
if (phases.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect the response we're going to make into this partial object.
|
||||
const result: Partial<PhaseResult> = {};
|
||||
|
||||
// Send the input to each of the phases.
|
||||
for (const phase of phases) {
|
||||
try {
|
||||
// Get the response from the phase.
|
||||
const response = await processPhase(ctx, phase);
|
||||
if (!response) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Merge the results in. If we're finished, return now!
|
||||
const finished = mergePhaseResult(response, result);
|
||||
if (finished) {
|
||||
return result;
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.log.error(
|
||||
{ err, phaseID: phase.id },
|
||||
"failed to process custom moderation phase"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { approve } from "./approve";
|
||||
import { commentingDisabled } from "./commentingDisabled";
|
||||
import { commentLength } from "./commentLength";
|
||||
import { detectLinks } from "./detectLinks";
|
||||
import { external } from "./external";
|
||||
import { linkify } from "./linkify";
|
||||
import { preModerate } from "./preModerate";
|
||||
import { premodUser } from "./preModerateUser";
|
||||
@@ -41,4 +42,5 @@ export const moderationPhases: IntermediateModerationPhase[] = [
|
||||
preModerate,
|
||||
premodUser,
|
||||
premodNewCommenter,
|
||||
external,
|
||||
];
|
||||
|
||||
@@ -30,7 +30,6 @@ export const premodNewCommenter = async ({
|
||||
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
|
||||
actions: [
|
||||
{
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_NEW_COMMENTER,
|
||||
metadata: {
|
||||
|
||||
@@ -48,7 +48,6 @@ export const recentCommentHistory = async ({
|
||||
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
|
||||
actions: [
|
||||
{
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_RECENT_HISTORY,
|
||||
metadata: {
|
||||
|
||||
@@ -63,7 +63,6 @@ export const repeatPost: IntermediateModerationPhase = async ({
|
||||
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
|
||||
actions: [
|
||||
{
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_REPEAT_POST,
|
||||
},
|
||||
|
||||
@@ -105,7 +105,6 @@ export const spam: IntermediateModerationPhase = async ({
|
||||
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
|
||||
actions: [
|
||||
{
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
|
||||
},
|
||||
|
||||
@@ -9,16 +9,10 @@ import { GQLTAG } from "coral-server/graph/schema/__generated__/types";
|
||||
// If a given user is a staff member, always approve their comment.
|
||||
export const staff: IntermediateModerationPhase = ({
|
||||
author,
|
||||
now,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
if (hasStaffRole(author)) {
|
||||
return {
|
||||
tags: [
|
||||
{
|
||||
type: GQLTAG.STAFF,
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
tags: [GQLTAG.STAFF],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CommentTag } from "coral-server/models/comment/tag";
|
||||
import { getDepth } from "coral-server/models/comment";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
|
||||
export const tagExpertAnswers: IntermediateModerationPhase = ({
|
||||
author,
|
||||
now,
|
||||
story,
|
||||
comment,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
@@ -24,20 +23,12 @@ export const tagExpertAnswers: IntermediateModerationPhase = ({
|
||||
story.settings.expertIDs.some((id) => id === author.id)
|
||||
) {
|
||||
// Assign this comment an expert tag!
|
||||
const tags: CommentTag[] = [
|
||||
{
|
||||
type: GQLTAG.EXPERT,
|
||||
createdAt: now,
|
||||
},
|
||||
];
|
||||
const tags: GQLTAG[] = [GQLTAG.EXPERT];
|
||||
|
||||
// If this comment is the first reply in a thread (depth of 1)...
|
||||
if (comment.ancestorIDs.length === 1) {
|
||||
if (getDepth(comment) === 1) {
|
||||
// Add the featured tag!
|
||||
tags.push({
|
||||
type: GQLTAG.FEATURED,
|
||||
createdAt: now,
|
||||
});
|
||||
tags.push(GQLTAG.FEATURED);
|
||||
}
|
||||
|
||||
return { tags };
|
||||
|
||||
@@ -31,12 +31,7 @@ export const tagUnansweredQuestions: IntermediateModerationPhase = ({
|
||||
story.settings.expertIDs.every((id) => id !== comment.authorID)
|
||||
) {
|
||||
return {
|
||||
tags: [
|
||||
{
|
||||
type: GQLTAG.UNANSWERED,
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
tags: [GQLTAG.UNANSWERED],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -131,7 +131,6 @@ export const toxic: IntermediateModerationPhase = async ({
|
||||
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
|
||||
actions: [
|
||||
{
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
|
||||
},
|
||||
|
||||
@@ -35,7 +35,6 @@ export const wordList: IntermediateModerationPhase = ({
|
||||
status: GQLCOMMENT_STATUS.REJECTED,
|
||||
actions: [
|
||||
{
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
|
||||
},
|
||||
@@ -53,7 +52,6 @@ export const wordList: IntermediateModerationPhase = ({
|
||||
return {
|
||||
actions: [
|
||||
{
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD,
|
||||
},
|
||||
|
||||
@@ -9,28 +9,52 @@ import {
|
||||
CreateCommentInput,
|
||||
RevisionMetadata,
|
||||
} from "coral-server/models/comment";
|
||||
import { CommentTag } from "coral-server/models/comment/tag";
|
||||
import { Story } from "coral-server/models/story";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import { AugmentedRedis } from "coral-server/services/redis";
|
||||
import { Request } from "coral-server/types/express";
|
||||
|
||||
import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types";
|
||||
import {
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLTAG,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { mergePhaseResult } from "./helpers";
|
||||
import { moderationPhases } from "./phases";
|
||||
|
||||
export type ModerationAction = Omit<
|
||||
CreateActionInput,
|
||||
"commentID" | "commentRevisionID" | "storyID" | "siteID"
|
||||
"commentID" | "commentRevisionID" | "storyID" | "siteID" | "userID"
|
||||
>;
|
||||
|
||||
export interface PhaseResult {
|
||||
/**
|
||||
* actions are moderation actions that are added to the comment revision.
|
||||
*/
|
||||
actions: ModerationAction[];
|
||||
|
||||
/**
|
||||
* status when provided decides and terminates the moderation process by
|
||||
* setting the status of the comment.
|
||||
*/
|
||||
status: GQLCOMMENT_STATUS;
|
||||
|
||||
/**
|
||||
* metadata should be added to the comment revision when it is created/edited.
|
||||
*/
|
||||
metadata: RevisionMetadata;
|
||||
|
||||
/**
|
||||
* body when returned should replace the comment body as it is currently.
|
||||
*/
|
||||
body: string;
|
||||
tags: CommentTag[];
|
||||
|
||||
/**
|
||||
* tags should be added to the comment when it is created. Tags are not added
|
||||
* when a comment is edited.
|
||||
*/
|
||||
tags: GQLTAG[];
|
||||
}
|
||||
|
||||
export interface ModerationPhaseContextInput {
|
||||
@@ -64,7 +88,7 @@ export type IntermediatePhaseResult = Partial<PhaseResult> | void;
|
||||
export interface IntermediateModerationPhaseContext
|
||||
extends ModerationPhaseContext {
|
||||
metadata: RevisionMetadata;
|
||||
tags: CommentTag[];
|
||||
tags: GQLTAG[];
|
||||
}
|
||||
|
||||
export type IntermediateModerationPhase = (
|
||||
@@ -109,46 +133,10 @@ export const compose = (
|
||||
metadata: final.metadata,
|
||||
});
|
||||
if (result) {
|
||||
// If this result contained actions, then we should push it into the
|
||||
// other actions.
|
||||
const { actions } = result;
|
||||
if (actions) {
|
||||
final.actions.push(...actions);
|
||||
}
|
||||
|
||||
// If this result contained metadata, then we should merge it into the
|
||||
// other metadata.
|
||||
const { metadata } = result;
|
||||
if (metadata) {
|
||||
final.metadata = {
|
||||
...final.metadata,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
// If the result modified the comment body, we should replace it.
|
||||
const { body } = result;
|
||||
if (body) {
|
||||
final.body = body;
|
||||
}
|
||||
|
||||
// If the result added any tags, we should push it into the existing tags.
|
||||
const { tags } = result;
|
||||
if (tags && tags.length > 0) {
|
||||
final.tags.push(
|
||||
// Only push in tags that we haven't already added.
|
||||
...tags.filter(
|
||||
({ type }) => !final.tags.some((tag) => tag.type === type)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// If this result contained a status, then we've finished resolving
|
||||
// phases!
|
||||
const { status } = result;
|
||||
if (status) {
|
||||
final.status = status;
|
||||
break;
|
||||
// Merge the results in. If we're finished, break now!
|
||||
const finished = mergePhaseResult(result, final);
|
||||
if (finished) {
|
||||
return final;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CoralEventPublisherBroker } from "coral-server/events/publisher";
|
||||
import {
|
||||
Comment,
|
||||
CommentModerationQueueCounts,
|
||||
getDepth,
|
||||
hasPublishedStatus,
|
||||
} from "coral-server/models/comment";
|
||||
|
||||
@@ -42,7 +43,7 @@ export async function publishCommentReplyCreated(
|
||||
broker: CoralEventPublisherBroker,
|
||||
comment: Pick<Comment, "id" | "status" | "ancestorIDs">
|
||||
) {
|
||||
if (comment.ancestorIDs.length > 0 && hasPublishedStatus(comment)) {
|
||||
if (getDepth(comment) > 0 && hasPublishedStatus(comment)) {
|
||||
await CommentReplyCreatedCoralEvent.publish(broker, {
|
||||
ancestorIDs: comment.ancestorIDs,
|
||||
commentID: comment.id,
|
||||
|
||||
@@ -5,20 +5,15 @@ import fetch, { RequestInit, Response } from "node-fetch";
|
||||
import { URL } from "url";
|
||||
|
||||
import { version } from "coral-common/version";
|
||||
import {
|
||||
generateSignatures,
|
||||
SigningSecret,
|
||||
} from "coral-server/models/settings";
|
||||
|
||||
import abortAfter from "./abortAfter";
|
||||
|
||||
export type Fetch = (url: string, options?: FetchOptions) => Promise<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
|
||||
@@ -27,7 +22,45 @@ export type FetchOptions = RequestInit & {
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const createFetch = ({ name }: CreateFetchOptions): Fetch => {
|
||||
export interface CreateFetchOptions {
|
||||
/**
|
||||
* name is the string that is attached to the `User-Agent` header as:
|
||||
*
|
||||
* `Coral ${name}/${version}`
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* options to provide defaults for requests made using this fetcher.
|
||||
*/
|
||||
options?: Omit<FetchOptions, "agent" | "body" | "signal">;
|
||||
}
|
||||
|
||||
export function generateFetchOptions(
|
||||
signingSecrets: SigningSecret[],
|
||||
data: object,
|
||||
now: Date
|
||||
): FetchOptions {
|
||||
// Serialize the body and signature to include in the request.
|
||||
const body = JSON.stringify(data, null, 2);
|
||||
const signature = generateSignatures(signingSecrets, body, now);
|
||||
|
||||
const headers: Record<string, any> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Coral-Signature": signature,
|
||||
};
|
||||
|
||||
return {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
export const createFetch = ({
|
||||
name,
|
||||
options: { headers: defaultBaseHeaders = {}, ...defaultOptions } = {},
|
||||
}: CreateFetchOptions): Fetch => {
|
||||
// Create HTTP agents to improve connection performance.
|
||||
const agents = {
|
||||
https: new https.Agent({
|
||||
@@ -46,6 +79,7 @@ export const createFetch = ({ name }: CreateFetchOptions): Fetch => {
|
||||
// overridden).
|
||||
const defaultHeaders = {
|
||||
"User-Agent": `Coral ${capitalize(name)}/${version}`,
|
||||
...defaultBaseHeaders,
|
||||
};
|
||||
|
||||
// Return the actual fetcher that just uses fetch under the hood.
|
||||
@@ -69,10 +103,17 @@ export const createFetch = ({ name }: CreateFetchOptions): Fetch => {
|
||||
...defaultHeaders,
|
||||
...headers,
|
||||
},
|
||||
// Limit response sizes to 2MB of response data. 1e6B is 1MB.
|
||||
size: 2e6,
|
||||
// Do not follow redirects automatically, and do not error if we
|
||||
// encounter one. We'll treat the response from the request as the
|
||||
// endpoints final response.
|
||||
redirect: "manual",
|
||||
// Attach the controller signal to abort the request after the timeout
|
||||
// is reached.
|
||||
signal: abort.controller.signal,
|
||||
// Merge in the passed options.
|
||||
...defaultOptions,
|
||||
...options,
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
startMigration,
|
||||
} from "coral-server/models/migration";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
|
||||
import {
|
||||
FailedMigrationDetectedError,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import Migration from "coral-server/services/migrate/migration";
|
||||
import collections from "coral-server/services/mongodb/collections";
|
||||
|
||||
export default class extends Migration {
|
||||
public async up(mongo: Db, tenantID: string) {
|
||||
await collections.tenants(mongo).updateOne(
|
||||
{ id: tenantID },
|
||||
{
|
||||
$rename: {
|
||||
"auth.integrations.sso.keys": "auth.integrations.sso.signingSecrets",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Db, MongoClient } from "mongodb";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import { InternalError } from "coral-server/errors";
|
||||
import { WrappedInternalError } from "coral-server/errors";
|
||||
|
||||
export async function createMongoClient(config: Config): Promise<MongoClient> {
|
||||
try {
|
||||
@@ -10,7 +10,7 @@ export async function createMongoClient(config: Config): Promise<MongoClient> {
|
||||
ignoreUndefined: true,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new InternalError(err, "could not connect to mongodb");
|
||||
throw new WrappedInternalError(err, "could not connect to mongodb");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import RedisClient, { Pipeline, Redis } from "ioredis";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import { InternalError } from "coral-server/errors";
|
||||
import { WrappedInternalError } from "coral-server/errors";
|
||||
import logger from "coral-server/logger";
|
||||
|
||||
export interface AugmentedRedisCommands {
|
||||
@@ -52,7 +52,7 @@ export function createRedisClient(config: Config, lazyConnect = false): Redis {
|
||||
|
||||
return redis;
|
||||
} catch (err) {
|
||||
throw new InternalError(err, "could not connect to redis");
|
||||
throw new WrappedInternalError(err, "could not connect to redis");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,6 @@ export async function createAugmentedRedisClient(
|
||||
|
||||
return redis;
|
||||
} catch (err) {
|
||||
throw new InternalError(err, "could not connect to redis");
|
||||
throw new WrappedInternalError(err, "could not connect to redis");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Logger from "bunyan";
|
||||
import cheerio from "cheerio";
|
||||
import authorScraper from "metascraper-author";
|
||||
import descriptionScraper from "metascraper-description";
|
||||
@@ -12,7 +11,7 @@ import { ScrapeFailed } from "coral-server/errors";
|
||||
import logger from "coral-server/logger";
|
||||
import { retrieveStory, updateStory } from "coral-server/models/story";
|
||||
import { retrieveTenant } from "coral-server/models/tenant";
|
||||
import { createFetch, Fetch, FetchOptions } from "coral-server/services/fetch";
|
||||
import { createFetch, FetchOptions } from "coral-server/services/fetch";
|
||||
|
||||
import { GQLStoryMetadata } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
@@ -27,15 +26,21 @@ export type Rule = Record<
|
||||
>
|
||||
>;
|
||||
|
||||
interface ScrapeOptions {
|
||||
url: string;
|
||||
timeout: number;
|
||||
size: number;
|
||||
customUserAgent?: string;
|
||||
proxyURL?: string;
|
||||
}
|
||||
|
||||
class Scraper {
|
||||
private readonly rules: Rule[];
|
||||
private readonly log: Logger;
|
||||
private readonly fetch: Fetch;
|
||||
private readonly log = logger.child({ taskName: "scraper" }, true);
|
||||
private readonly fetch = createFetch({ name: "Scraper" });
|
||||
|
||||
constructor(rules: Rule[]) {
|
||||
this.fetch = createFetch({ name: "Scraper" });
|
||||
this.rules = rules;
|
||||
this.log = logger.child({ taskName: "scraper" }, true);
|
||||
}
|
||||
|
||||
public parse(url: string, html: string): GQLStoryMetadata {
|
||||
@@ -83,15 +88,15 @@ class Scraper {
|
||||
};
|
||||
}
|
||||
|
||||
public async download(
|
||||
url: string,
|
||||
timeout: number,
|
||||
customUserAgent?: string,
|
||||
proxyURL?: string
|
||||
) {
|
||||
public async download({
|
||||
url,
|
||||
timeout,
|
||||
customUserAgent,
|
||||
proxyURL,
|
||||
}: ScrapeOptions) {
|
||||
const log = this.log.child({ storyURL: url }, true);
|
||||
|
||||
const options: FetchOptions = { timeout };
|
||||
const options: FetchOptions = { method: "GET", timeout };
|
||||
if (customUserAgent) {
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
@@ -131,22 +136,14 @@ class Scraper {
|
||||
}
|
||||
|
||||
public async scrape(
|
||||
url: string,
|
||||
abortAfterMilliseconds: number,
|
||||
customUserAgent?: string,
|
||||
proxyURL?: string
|
||||
options: ScrapeOptions
|
||||
): Promise<GQLStoryMetadata | null> {
|
||||
const html = await this.download(
|
||||
url,
|
||||
abortAfterMilliseconds,
|
||||
customUserAgent,
|
||||
proxyURL
|
||||
);
|
||||
const html = await this.download(options);
|
||||
if (!html) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.parse(url, html);
|
||||
return this.parse(options.url, html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,14 +192,16 @@ export async function scrape(
|
||||
// This typecast is needed because the custom `ms` format does not return the
|
||||
// desired `number` type even though that's the only type it can output.
|
||||
const timeout = (config.get("scrape_timeout") as unknown) as number;
|
||||
const size = config.get("scrape_max_response_size");
|
||||
|
||||
// Get the metadata from the scraped html.
|
||||
const metadata = await scraper.scrape(
|
||||
storyURL,
|
||||
const metadata = await scraper.scrape({
|
||||
url: storyURL,
|
||||
timeout,
|
||||
tenant.stories.scraping.customUserAgent,
|
||||
tenant.stories.scraping.proxyURL
|
||||
);
|
||||
size,
|
||||
customUserAgent: tenant.stories.scraping.customUserAgent,
|
||||
proxyURL: tenant.stories.scraping.proxyURL,
|
||||
});
|
||||
if (!metadata) {
|
||||
throw new Error("story at specified url not found");
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import TenantCache from "./cache";
|
||||
|
||||
export type DeconstructionFn<T> = (tenantID: string, value: T) => Promise<void>;
|
||||
|
||||
@@ -8,7 +8,7 @@ export type DeconstructionFn<T> = (tenantID: string, value: T) => Promise<void>;
|
||||
* tenants are enabled, this acts as a map to store entries, and will
|
||||
* automatically invalidate tenants that have been updated.
|
||||
*/
|
||||
export class TenantCacheAdapter<T> {
|
||||
export default class TenantCacheAdapter<T> {
|
||||
private readonly cache = new Map<string, T>();
|
||||
private readonly tenantCache: TenantCache;
|
||||
|
||||
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
import DataLoader from "dataloader";
|
||||
import { EventEmitter } from "events";
|
||||
import { Redis } from "ioredis";
|
||||
import { Db } from "mongodb";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import logger from "coral-server/logger";
|
||||
import {
|
||||
countTenants,
|
||||
retrieveAllTenants,
|
||||
retrieveManyTenants,
|
||||
retrieveManyTenantsByDomain,
|
||||
Tenant,
|
||||
} from "coral-server/models/tenant";
|
||||
|
||||
const TENANT_CACHE_CHANNEL = "TENANT_CACHE_CHANNEL";
|
||||
|
||||
enum EVENTS {
|
||||
UPDATE = "UPDATE",
|
||||
DELETE = "DELETE",
|
||||
}
|
||||
|
||||
type UpdateSubscribeCallback = (tenant: Tenant) => void;
|
||||
type DeleteSubscribeCallback = (tenantID: string, tenantDomain: string) => void;
|
||||
|
||||
type Message = UpdateMessage | DeleteMessage;
|
||||
|
||||
interface DeleteMessage {
|
||||
event: EVENTS.DELETE;
|
||||
tenantID: string;
|
||||
tenantDomain: string;
|
||||
clientApplicationID: string;
|
||||
}
|
||||
|
||||
interface UpdateMessage {
|
||||
event: EVENTS.UPDATE;
|
||||
tenant: Tenant;
|
||||
clientApplicationID: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageData is a type that is used to select only the data parts of the
|
||||
* message.
|
||||
*/
|
||||
type MessageData<T extends Message> = Omit<T, "clientApplicationID" | "event">;
|
||||
|
||||
// TenantCache provides an interface for retrieving tenant stored in local
|
||||
// memory rather than grabbing it from the database every single call.
|
||||
export default class TenantCache {
|
||||
/**
|
||||
* tenantsByID reference the tenants that have been cached/retrieved by ID.
|
||||
*/
|
||||
private readonly tenantsByID: DataLoader<string, Readonly<Tenant> | null>;
|
||||
|
||||
/**
|
||||
* tenantsByDomain reference the tenants that have been cached/retrieved by
|
||||
* Domain.
|
||||
*/
|
||||
private readonly tenantsByDomain: DataLoader<string, Readonly<Tenant> | null>;
|
||||
|
||||
/**
|
||||
* tenantCountCache stores all the id's of all the Tenant's that have crossed
|
||||
* it.
|
||||
*/
|
||||
private readonly tenantCountCache = new Set<string>();
|
||||
|
||||
/**
|
||||
* primed is true when the cache has already been fully primed.
|
||||
*/
|
||||
private primed = false;
|
||||
|
||||
/**
|
||||
* Create a new client application ID. This prevents duplicated messages
|
||||
* generated by this application from being handled as external messages
|
||||
* as we should have already processed it.
|
||||
*/
|
||||
private readonly clientApplicationID = uuid();
|
||||
|
||||
private readonly mongo: Db;
|
||||
private readonly emitter = new EventEmitter();
|
||||
|
||||
/**
|
||||
* cachingEnabled is true when tenant caching has been enabled.
|
||||
*/
|
||||
public readonly cachingEnabled: boolean;
|
||||
|
||||
constructor(mongo: Db, subscriber: Redis, config: Config) {
|
||||
this.cachingEnabled = !config.get("disable_tenant_caching");
|
||||
if (!this.cachingEnabled) {
|
||||
logger.warn("tenant caching is disabled");
|
||||
} else {
|
||||
logger.debug("tenant caching is enabled");
|
||||
}
|
||||
|
||||
// Save the Db reference.
|
||||
this.mongo = mongo;
|
||||
|
||||
// Configure the data loaders.
|
||||
this.tenantsByID = new DataLoader(
|
||||
async (ids) => {
|
||||
logger.debug({ ids: ids.length }, "now loading tenants");
|
||||
const tenants = await retrieveManyTenants(this.mongo, ids);
|
||||
logger.debug(
|
||||
{ tenants: tenants.filter((t) => t !== null).length },
|
||||
"loaded tenants"
|
||||
);
|
||||
|
||||
tenants
|
||||
.filter((t) => t !== null)
|
||||
.forEach((t: Readonly<Tenant>) => this.tenantCountCache.add(t.id));
|
||||
|
||||
return tenants;
|
||||
},
|
||||
{
|
||||
cache: this.cachingEnabled,
|
||||
}
|
||||
);
|
||||
|
||||
this.tenantsByDomain = new DataLoader(
|
||||
async (domains) => {
|
||||
logger.debug({ domains: domains.length }, "now loading tenants");
|
||||
const tenants = await retrieveManyTenantsByDomain(this.mongo, domains);
|
||||
logger.debug(
|
||||
{ tenants: tenants.filter((t) => t !== null).length },
|
||||
"loaded tenants"
|
||||
);
|
||||
|
||||
tenants
|
||||
.filter((t) => t !== null)
|
||||
.forEach((t: Readonly<Tenant>) => this.tenantCountCache.add(t.id));
|
||||
|
||||
return tenants;
|
||||
},
|
||||
{
|
||||
cache: this.cachingEnabled,
|
||||
}
|
||||
);
|
||||
|
||||
// We don't need updates if we aren't synced to tenant updates.
|
||||
if (this.cachingEnabled) {
|
||||
// Attach to messages on this connection so we can receive updates when
|
||||
// the tenant are changed.
|
||||
subscriber.on("message", this.onMessage);
|
||||
|
||||
// Subscribe to tenant notifications.
|
||||
subscriber.subscribe(TENANT_CACHE_CHANNEL);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* count will return the number of Tenant's.
|
||||
*/
|
||||
public async count(): Promise<number> {
|
||||
if (!this.cachingEnabled) {
|
||||
return countTenants(this.mongo);
|
||||
}
|
||||
|
||||
if (!this.primed) {
|
||||
await this.primeAll();
|
||||
}
|
||||
|
||||
return this.tenantCountCache.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* primeAll will load all the tenants into the cache on startup.
|
||||
*/
|
||||
public async primeAll() {
|
||||
if (!this.cachingEnabled) {
|
||||
logger.debug("tenants not primed, caching disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// Grab all the tenants for this node.
|
||||
const tenants = await retrieveAllTenants(this.mongo);
|
||||
|
||||
// Clear out all the items in the cache.
|
||||
this.tenantsByID.clearAll();
|
||||
this.tenantsByDomain.clearAll();
|
||||
this.tenantCountCache.clear();
|
||||
|
||||
// Prime the cache with each of these tenants.
|
||||
tenants.forEach((tenant) => {
|
||||
this.tenantsByID.prime(tenant.id, tenant);
|
||||
this.tenantsByDomain.prime(tenant.domain, tenant);
|
||||
this.tenantCountCache.add(tenant.id);
|
||||
});
|
||||
|
||||
logger.debug({ tenants: tenants.length }, "primed all tenants");
|
||||
this.primed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symbol.asyncIterator implements the asyncIterator interface for the
|
||||
* TenantCache. This allows you to use the TenantCache as a asyncIterator with
|
||||
* a `for await (const tenant of tenants) {}` pattern to iterate over all the
|
||||
* tenant's on the cache. If the cache is cacheable, and not primed, the cache
|
||||
* will be primed at the first async iteration process. If caching is
|
||||
* disabled, then the tenants will bne loaded on demand and not persisted
|
||||
* after the iteration.
|
||||
*/
|
||||
public async *[Symbol.asyncIterator]() {
|
||||
// If the cache isn't primed, and caching is enabled, then prime the cache
|
||||
// now, as this will increase performance dramatically.
|
||||
if (!this.primed && this.cachingEnabled) {
|
||||
await this.primeAll();
|
||||
}
|
||||
|
||||
// Copy the tenant count cache to prevent race conditions related to
|
||||
// clearing during iteration.
|
||||
const cache = new Set(this.tenantCountCache);
|
||||
|
||||
// If the tenant's are primed in the cache, then just use the count cache as
|
||||
// the iteration source.
|
||||
if (this.primed) {
|
||||
for (const tenantID of cache) {
|
||||
const tenant = await this.tenantsByID.load(tenantID);
|
||||
if (!tenant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield tenant;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Caching must be disabled, so just grab all the tenants for this node and
|
||||
// iterate through each of them as we handle it.
|
||||
const tenants = await retrieveAllTenants(this.mongo);
|
||||
for (const tenant of tenants) {
|
||||
yield tenant;
|
||||
}
|
||||
}
|
||||
|
||||
private onUpdateMessage({ tenant }: MessageData<UpdateMessage>) {
|
||||
// Update the tenant cache.
|
||||
this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant);
|
||||
this.tenantsByDomain.clear(tenant.domain).prime(tenant.domain, tenant);
|
||||
this.tenantCountCache.add(tenant.id);
|
||||
|
||||
// Publish the event for the connected listeners.
|
||||
this.emitter.emit(EVENTS.UPDATE, tenant);
|
||||
}
|
||||
|
||||
private onDeleteMessage({
|
||||
tenantID,
|
||||
tenantDomain,
|
||||
}: MessageData<DeleteMessage>) {
|
||||
// Delete the tenant in the local cache.
|
||||
this.tenantsByID.clear(tenantID);
|
||||
this.tenantsByDomain.clear(tenantDomain);
|
||||
this.tenantCountCache.delete(tenantID);
|
||||
|
||||
// Publish the event for the connected listeners.
|
||||
this.emitter.emit(EVENTS.DELETE, tenantID, tenantDomain);
|
||||
}
|
||||
|
||||
/**
|
||||
* onMessage is fired every time the client gets a subscription event.
|
||||
*/
|
||||
private onMessage = async (channel: string, data: string): Promise<void> => {
|
||||
// Only do things when the message is for tenant.
|
||||
if (channel !== TENANT_CACHE_CHANNEL) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the message (which is JSON).
|
||||
const message: Message = JSON.parse(data);
|
||||
|
||||
// Extract some known parameters.
|
||||
const { clientApplicationID } = message;
|
||||
|
||||
// Check to see if this was the update issued by this instance.
|
||||
if (clientApplicationID === this.clientApplicationID) {
|
||||
// It was, so just return here, we already updated/handled it.
|
||||
return;
|
||||
}
|
||||
|
||||
const log = logger.child({ eventName: message.event }, true);
|
||||
log.debug("received tenant message");
|
||||
|
||||
// Send the message to the correct handler.
|
||||
switch (message.event) {
|
||||
case EVENTS.UPDATE:
|
||||
return this.onUpdateMessage(message);
|
||||
case EVENTS.DELETE:
|
||||
return this.onDeleteMessage(message);
|
||||
default:
|
||||
log.warn("received unknown event");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err },
|
||||
"an error occurred while trying to handle a message"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
public async retrieveByID(id: string): Promise<Readonly<Tenant> | null> {
|
||||
return this.tenantsByID.load(id);
|
||||
}
|
||||
|
||||
public async retrieveByDomain(
|
||||
domain: string
|
||||
): Promise<Readonly<Tenant> | null> {
|
||||
return this.tenantsByDomain.load(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* This allows you to subscribe to new Tenant updates. This will also return
|
||||
* a function that when called, unsubscribes you from updates.
|
||||
*
|
||||
* @param updateCallback the function to be called when there is an updated Tenant.
|
||||
* @param deleteCallback the function to be called when a tenant needs to be purged
|
||||
*/
|
||||
public subscribe(
|
||||
updateCallback: UpdateSubscribeCallback,
|
||||
deleteCallback: DeleteSubscribeCallback
|
||||
) {
|
||||
this.emitter.on(EVENTS.UPDATE, updateCallback);
|
||||
this.emitter.on(EVENTS.DELETE, deleteCallback);
|
||||
|
||||
// Return the unsubscribe function.
|
||||
return () => {
|
||||
this.emitter.removeListener(EVENTS.UPDATE, updateCallback);
|
||||
this.emitter.removeListener(EVENTS.DELETE, deleteCallback);
|
||||
};
|
||||
}
|
||||
|
||||
private async publish(tenantID: string, conn: Redis, message: Message) {
|
||||
const subscribers = await conn.publish(
|
||||
TENANT_CACHE_CHANNEL,
|
||||
JSON.stringify(message)
|
||||
);
|
||||
logger.debug(
|
||||
{ tenantID, subscribers, eventName: message.event },
|
||||
"updated tenant in cache"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* update will update the value for Tenant in the local cache and publish
|
||||
* a change notification that will be used to keep the other nodes in sync.
|
||||
*
|
||||
* @param conn a redis connection used to publish the change notification
|
||||
* @param tenant the updated Tenant object
|
||||
*/
|
||||
public async update(conn: Redis, tenant: Tenant): Promise<void> {
|
||||
// Process the tenant update on this node.
|
||||
this.onUpdateMessage({ tenant });
|
||||
|
||||
// Notify the other nodes about the tenant change.
|
||||
await this.publish(tenant.id, conn, {
|
||||
event: EVENTS.UPDATE,
|
||||
tenant,
|
||||
clientApplicationID: this.clientApplicationID,
|
||||
});
|
||||
}
|
||||
|
||||
public async delete(conn: Redis, tenantID: string, tenantDomain: string) {
|
||||
// Process the tenant update on this node.
|
||||
this.onDeleteMessage({ tenantID, tenantDomain });
|
||||
|
||||
// Notify the other nodes about the tenant change.
|
||||
await this.publish(tenantID, conn, {
|
||||
event: EVENTS.DELETE,
|
||||
tenantID,
|
||||
tenantDomain,
|
||||
clientApplicationID: this.clientApplicationID,
|
||||
});
|
||||
}
|
||||
}
|
||||
+2
-376
@@ -1,376 +1,2 @@
|
||||
import DataLoader from "dataloader";
|
||||
import { EventEmitter } from "events";
|
||||
import { Redis } from "ioredis";
|
||||
import { Db } from "mongodb";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import logger from "coral-server/logger";
|
||||
import {
|
||||
countTenants,
|
||||
retrieveAllTenants,
|
||||
retrieveManyTenants,
|
||||
retrieveManyTenantsByDomain,
|
||||
Tenant,
|
||||
} from "coral-server/models/tenant";
|
||||
|
||||
const TENANT_CACHE_CHANNEL = "TENANT_CACHE_CHANNEL";
|
||||
|
||||
enum EVENTS {
|
||||
UPDATE = "UPDATE",
|
||||
DELETE = "DELETE",
|
||||
}
|
||||
|
||||
type UpdateSubscribeCallback = (tenant: Tenant) => void;
|
||||
type DeleteSubscribeCallback = (tenantID: string, tenantDomain: string) => void;
|
||||
|
||||
type Message = UpdateMessage | DeleteMessage;
|
||||
|
||||
interface DeleteMessage {
|
||||
event: EVENTS.DELETE;
|
||||
tenantID: string;
|
||||
tenantDomain: string;
|
||||
clientApplicationID: string;
|
||||
}
|
||||
|
||||
interface UpdateMessage {
|
||||
event: EVENTS.UPDATE;
|
||||
tenant: Tenant;
|
||||
clientApplicationID: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageData is a type that is used to select only the data parts of the
|
||||
* message.
|
||||
*/
|
||||
type MessageData<T extends Message> = Omit<T, "clientApplicationID" | "event">;
|
||||
|
||||
// TenantCache provides an interface for retrieving tenant stored in local
|
||||
// memory rather than grabbing it from the database every single call.
|
||||
export default class TenantCache {
|
||||
/**
|
||||
* tenantsByID reference the tenants that have been cached/retrieved by ID.
|
||||
*/
|
||||
private readonly tenantsByID: DataLoader<string, Readonly<Tenant> | null>;
|
||||
|
||||
/**
|
||||
* tenantsByDomain reference the tenants that have been cached/retrieved by
|
||||
* Domain.
|
||||
*/
|
||||
private readonly tenantsByDomain: DataLoader<string, Readonly<Tenant> | null>;
|
||||
|
||||
/**
|
||||
* tenantCountCache stores all the id's of all the Tenant's that have crossed
|
||||
* it.
|
||||
*/
|
||||
private readonly tenantCountCache = new Set<string>();
|
||||
|
||||
/**
|
||||
* primed is true when the cache has already been fully primed.
|
||||
*/
|
||||
private primed = false;
|
||||
|
||||
/**
|
||||
* Create a new client application ID. This prevents duplicated messages
|
||||
* generated by this application from being handled as external messages
|
||||
* as we should have already processed it.
|
||||
*/
|
||||
private readonly clientApplicationID = uuid();
|
||||
|
||||
private readonly mongo: Db;
|
||||
private readonly emitter = new EventEmitter();
|
||||
|
||||
/**
|
||||
* cachingEnabled is true when tenant caching has been enabled.
|
||||
*/
|
||||
public readonly cachingEnabled: boolean;
|
||||
|
||||
constructor(mongo: Db, subscriber: Redis, config: Config) {
|
||||
this.cachingEnabled = !config.get("disable_tenant_caching");
|
||||
if (!this.cachingEnabled) {
|
||||
logger.warn("tenant caching is disabled");
|
||||
} else {
|
||||
logger.debug("tenant caching is enabled");
|
||||
}
|
||||
|
||||
// Save the Db reference.
|
||||
this.mongo = mongo;
|
||||
|
||||
// Configure the data loaders.
|
||||
this.tenantsByID = new DataLoader(
|
||||
async (ids) => {
|
||||
logger.debug({ ids: ids.length }, "now loading tenants");
|
||||
const tenants = await retrieveManyTenants(this.mongo, ids);
|
||||
logger.debug(
|
||||
{ tenants: tenants.filter((t) => t !== null).length },
|
||||
"loaded tenants"
|
||||
);
|
||||
|
||||
tenants
|
||||
.filter((t) => t !== null)
|
||||
.forEach((t: Readonly<Tenant>) => this.tenantCountCache.add(t.id));
|
||||
|
||||
return tenants;
|
||||
},
|
||||
{
|
||||
cache: this.cachingEnabled,
|
||||
}
|
||||
);
|
||||
|
||||
this.tenantsByDomain = new DataLoader(
|
||||
async (domains) => {
|
||||
logger.debug({ domains: domains.length }, "now loading tenants");
|
||||
const tenants = await retrieveManyTenantsByDomain(this.mongo, domains);
|
||||
logger.debug(
|
||||
{ tenants: tenants.filter((t) => t !== null).length },
|
||||
"loaded tenants"
|
||||
);
|
||||
|
||||
tenants
|
||||
.filter((t) => t !== null)
|
||||
.forEach((t: Readonly<Tenant>) => this.tenantCountCache.add(t.id));
|
||||
|
||||
return tenants;
|
||||
},
|
||||
{
|
||||
cache: this.cachingEnabled,
|
||||
}
|
||||
);
|
||||
|
||||
// We don't need updates if we aren't synced to tenant updates.
|
||||
if (this.cachingEnabled) {
|
||||
// Attach to messages on this connection so we can receive updates when
|
||||
// the tenant are changed.
|
||||
subscriber.on("message", this.onMessage);
|
||||
|
||||
// Subscribe to tenant notifications.
|
||||
subscriber.subscribe(TENANT_CACHE_CHANNEL);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* count will return the number of Tenant's.
|
||||
*/
|
||||
public async count(): Promise<number> {
|
||||
if (!this.cachingEnabled) {
|
||||
return countTenants(this.mongo);
|
||||
}
|
||||
|
||||
if (!this.primed) {
|
||||
await this.primeAll();
|
||||
}
|
||||
|
||||
return this.tenantCountCache.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* primeAll will load all the tenants into the cache on startup.
|
||||
*/
|
||||
public async primeAll() {
|
||||
if (!this.cachingEnabled) {
|
||||
logger.debug("tenants not primed, caching disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// Grab all the tenants for this node.
|
||||
const tenants = await retrieveAllTenants(this.mongo);
|
||||
|
||||
// Clear out all the items in the cache.
|
||||
this.tenantsByID.clearAll();
|
||||
this.tenantsByDomain.clearAll();
|
||||
this.tenantCountCache.clear();
|
||||
|
||||
// Prime the cache with each of these tenants.
|
||||
tenants.forEach((tenant) => {
|
||||
this.tenantsByID.prime(tenant.id, tenant);
|
||||
this.tenantsByDomain.prime(tenant.domain, tenant);
|
||||
this.tenantCountCache.add(tenant.id);
|
||||
});
|
||||
|
||||
logger.debug({ tenants: tenants.length }, "primed all tenants");
|
||||
this.primed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symbol.asyncIterator implements the asyncIterator interface for the
|
||||
* TenantCache. This allows you to use the TenantCache as a asyncIterator with
|
||||
* a `for await (const tenant of tenants) {}` pattern to iterate over all the
|
||||
* tenant's on the cache. If the cache is cacheable, and not primed, the cache
|
||||
* will be primed at the first async iteration process. If caching is
|
||||
* disabled, then the tenants will bne loaded on demand and not persisted
|
||||
* after the iteration.
|
||||
*/
|
||||
public async *[Symbol.asyncIterator]() {
|
||||
// If the cache isn't primed, and caching is enabled, then prime the cache
|
||||
// now, as this will increase performance dramatically.
|
||||
if (!this.primed && this.cachingEnabled) {
|
||||
await this.primeAll();
|
||||
}
|
||||
|
||||
// Copy the tenant count cache to prevent race conditions related to
|
||||
// clearing during iteration.
|
||||
const cache = new Set(this.tenantCountCache);
|
||||
|
||||
// If the tenant's are primed in the cache, then just use the count cache as
|
||||
// the iteration source.
|
||||
if (this.primed) {
|
||||
for (const tenantID of cache) {
|
||||
const tenant = await this.tenantsByID.load(tenantID);
|
||||
if (!tenant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield tenant;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Caching must be disabled, so just grab all the tenants for this node and
|
||||
// iterate through each of them as we handle it.
|
||||
const tenants = await retrieveAllTenants(this.mongo);
|
||||
for (const tenant of tenants) {
|
||||
yield tenant;
|
||||
}
|
||||
}
|
||||
|
||||
private onUpdateMessage({ tenant }: MessageData<UpdateMessage>) {
|
||||
// Update the tenant cache.
|
||||
this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant);
|
||||
this.tenantsByDomain.clear(tenant.domain).prime(tenant.domain, tenant);
|
||||
this.tenantCountCache.add(tenant.id);
|
||||
|
||||
// Publish the event for the connected listeners.
|
||||
this.emitter.emit(EVENTS.UPDATE, tenant);
|
||||
}
|
||||
|
||||
private onDeleteMessage({
|
||||
tenantID,
|
||||
tenantDomain,
|
||||
}: MessageData<DeleteMessage>) {
|
||||
// Delete the tenant in the local cache.
|
||||
this.tenantsByID.clear(tenantID);
|
||||
this.tenantsByDomain.clear(tenantDomain);
|
||||
this.tenantCountCache.delete(tenantID);
|
||||
|
||||
// Publish the event for the connected listeners.
|
||||
this.emitter.emit(EVENTS.DELETE, tenantID, tenantDomain);
|
||||
}
|
||||
|
||||
/**
|
||||
* onMessage is fired every time the client gets a subscription event.
|
||||
*/
|
||||
private onMessage = async (channel: string, data: string): Promise<void> => {
|
||||
// Only do things when the message is for tenant.
|
||||
if (channel !== TENANT_CACHE_CHANNEL) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the message (which is JSON).
|
||||
const message: Message = JSON.parse(data);
|
||||
|
||||
// Extract some known parameters.
|
||||
const { clientApplicationID } = message;
|
||||
|
||||
// Check to see if this was the update issued by this instance.
|
||||
if (clientApplicationID === this.clientApplicationID) {
|
||||
// It was, so just return here, we already updated/handled it.
|
||||
return;
|
||||
}
|
||||
|
||||
const log = logger.child({ eventName: message.event }, true);
|
||||
log.debug("received tenant message");
|
||||
|
||||
// Send the message to the correct handler.
|
||||
switch (message.event) {
|
||||
case EVENTS.UPDATE:
|
||||
return this.onUpdateMessage(message);
|
||||
case EVENTS.DELETE:
|
||||
return this.onDeleteMessage(message);
|
||||
default:
|
||||
log.warn("received unknown event");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err },
|
||||
"an error occurred while trying to handle a message"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
public async retrieveByID(id: string): Promise<Readonly<Tenant> | null> {
|
||||
return this.tenantsByID.load(id);
|
||||
}
|
||||
|
||||
public async retrieveByDomain(
|
||||
domain: string
|
||||
): Promise<Readonly<Tenant> | null> {
|
||||
return this.tenantsByDomain.load(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* This allows you to subscribe to new Tenant updates. This will also return
|
||||
* a function that when called, unsubscribes you from updates.
|
||||
*
|
||||
* @param updateCallback the function to be called when there is an updated Tenant.
|
||||
* @param deleteCallback the function to be called when a tenant needs to be purged
|
||||
*/
|
||||
public subscribe(
|
||||
updateCallback: UpdateSubscribeCallback,
|
||||
deleteCallback: DeleteSubscribeCallback
|
||||
) {
|
||||
this.emitter.on(EVENTS.UPDATE, updateCallback);
|
||||
this.emitter.on(EVENTS.DELETE, deleteCallback);
|
||||
|
||||
// Return the unsubscribe function.
|
||||
return () => {
|
||||
this.emitter.removeListener(EVENTS.UPDATE, updateCallback);
|
||||
this.emitter.removeListener(EVENTS.DELETE, deleteCallback);
|
||||
};
|
||||
}
|
||||
|
||||
private async publish(tenantID: string, conn: Redis, message: Message) {
|
||||
const subscribers = await conn.publish(
|
||||
TENANT_CACHE_CHANNEL,
|
||||
JSON.stringify(message)
|
||||
);
|
||||
logger.debug(
|
||||
{ tenantID, subscribers, eventName: message.event },
|
||||
"updated tenant in cache"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* update will update the value for Tenant in the local cache and publish
|
||||
* a change notification that will be used to keep the other nodes in sync.
|
||||
*
|
||||
* @param conn a redis connection used to publish the change notification
|
||||
* @param tenant the updated Tenant object
|
||||
*/
|
||||
public async update(conn: Redis, tenant: Tenant): Promise<void> {
|
||||
// Process the tenant update on this node.
|
||||
this.onUpdateMessage({ tenant });
|
||||
|
||||
// Notify the other nodes about the tenant change.
|
||||
await this.publish(tenant.id, conn, {
|
||||
event: EVENTS.UPDATE,
|
||||
tenant,
|
||||
clientApplicationID: this.clientApplicationID,
|
||||
});
|
||||
}
|
||||
|
||||
public async delete(conn: Redis, tenantID: string, tenantDomain: string) {
|
||||
// Process the tenant update on this node.
|
||||
this.onDeleteMessage({ tenantID, tenantDomain });
|
||||
|
||||
// Notify the other nodes about the tenant change.
|
||||
await this.publish(tenantID, conn, {
|
||||
event: EVENTS.DELETE,
|
||||
tenantID,
|
||||
tenantDomain,
|
||||
clientApplicationID: this.clientApplicationID,
|
||||
});
|
||||
}
|
||||
}
|
||||
export { default as TenantCache } from "./cache";
|
||||
export { default as TenantCacheAdapter } from "./adapter";
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import { Redis } from "ioredis";
|
||||
import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import { getExternalModerationPhase } from "coral-server/models/settings";
|
||||
import {
|
||||
createTenantExternalModerationPhase,
|
||||
CreateTenantExternalModerationPhaseInput,
|
||||
deleteTenantExternalModerationPhase,
|
||||
rotateTenantExternalModerationPhaseSigningSecret,
|
||||
Tenant,
|
||||
updateTenantExternalModerationPhase,
|
||||
UpdateTenantExternalModerationPhaseInput,
|
||||
} from "coral-server/models/tenant";
|
||||
|
||||
import { TenantCache } from "./cache";
|
||||
|
||||
interface ExternalModerationPhaseInput {
|
||||
url: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
export function validateExternalModerationPhaseInput(
|
||||
config: Config,
|
||||
input: ExternalModerationPhaseInput
|
||||
) {
|
||||
// Check to see that this URL is valid and has a https:// scheme if in
|
||||
// production mode.
|
||||
const url = new URL(input.url);
|
||||
if (config.get("env") === "production" && url.protocol !== "https:") {
|
||||
throw new Error(`invalid scheme provided in production: ${url.protocol}`);
|
||||
}
|
||||
|
||||
// Check to see if the timeout value is within range.
|
||||
if (input.timeout < 100) {
|
||||
throw new Error("timeout value too low");
|
||||
} else if (input.timeout > 10000) {
|
||||
throw new Error("timeout value too high");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createExternalModerationPhase(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
config: Config,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
input: CreateTenantExternalModerationPhaseInput,
|
||||
now: Date
|
||||
) {
|
||||
// Validate the input.
|
||||
validateExternalModerationPhaseInput(config, input);
|
||||
|
||||
// Looks good in create this, send it off to be created.
|
||||
const result = await createTenantExternalModerationPhase(
|
||||
mongo,
|
||||
tenant.id,
|
||||
input,
|
||||
now
|
||||
);
|
||||
if (!result.tenant) {
|
||||
throw new Error("could not create the tenant phase, tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, result.tenant);
|
||||
|
||||
return {
|
||||
phase: result.phase,
|
||||
settings: result.tenant,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateExternalModerationPhase(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
config: Config,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
phaseID: string,
|
||||
input: UpdateTenantExternalModerationPhaseInput
|
||||
) {
|
||||
// Find the phase.
|
||||
if (!tenant.integrations.external) {
|
||||
throw new Error(
|
||||
"referenced phase was not found on tenant, none configured"
|
||||
);
|
||||
}
|
||||
|
||||
let phase = getExternalModerationPhase(tenant.integrations.external, phaseID);
|
||||
if (!phase) {
|
||||
throw new Error("referenced phase was not found on tenant");
|
||||
}
|
||||
|
||||
// Extract the input.
|
||||
const { url = phase.url, timeout = phase.timeout } = input;
|
||||
|
||||
// Validate the input.
|
||||
validateExternalModerationPhaseInput(config, {
|
||||
url,
|
||||
timeout,
|
||||
});
|
||||
|
||||
const updatedTenant = await updateTenantExternalModerationPhase(
|
||||
mongo,
|
||||
tenant.id,
|
||||
phaseID,
|
||||
input
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated phase.
|
||||
phase = getExternalModerationPhase(
|
||||
// We know that `external` is provided because we already verified it earlier.
|
||||
updatedTenant.integrations.external!,
|
||||
phaseID
|
||||
);
|
||||
if (!phase) {
|
||||
throw new Error("referenced phase was not found on tenant");
|
||||
}
|
||||
|
||||
return phase;
|
||||
}
|
||||
|
||||
export async function deleteExternalModerationPhase(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
phaseID: string
|
||||
) {
|
||||
// Find the phase.
|
||||
if (!tenant.integrations.external) {
|
||||
throw new Error(
|
||||
"referenced phase was not found on tenant, none configured"
|
||||
);
|
||||
}
|
||||
const phase = getExternalModerationPhase(
|
||||
tenant.integrations.external,
|
||||
phaseID
|
||||
);
|
||||
if (!phase) {
|
||||
throw new Error("referenced phase was not found on tenant");
|
||||
}
|
||||
|
||||
const updatedTenant = await deleteTenantExternalModerationPhase(
|
||||
mongo,
|
||||
tenant.id,
|
||||
phaseID
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
return phase;
|
||||
}
|
||||
|
||||
export async function enableExternalModerationPhase(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
phaseID: string
|
||||
) {
|
||||
// Find the phase.
|
||||
if (!tenant.integrations.external) {
|
||||
throw new Error(
|
||||
"referenced phase was not found on tenant, none configured"
|
||||
);
|
||||
}
|
||||
|
||||
let phase = getExternalModerationPhase(tenant.integrations.external, phaseID);
|
||||
if (!phase) {
|
||||
throw new Error("referenced phase was not found on tenant");
|
||||
}
|
||||
|
||||
// Phase is already enabled.
|
||||
if (phase.enabled === true) {
|
||||
return phase;
|
||||
}
|
||||
|
||||
const updatedTenant = await updateTenantExternalModerationPhase(
|
||||
mongo,
|
||||
tenant.id,
|
||||
phaseID,
|
||||
{ enabled: true }
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated phase.
|
||||
phase = getExternalModerationPhase(
|
||||
// We know that `external` is provided because we already verified it earlier.
|
||||
updatedTenant.integrations.external!,
|
||||
phaseID
|
||||
);
|
||||
if (!phase) {
|
||||
throw new Error("referenced phase was not found on tenant");
|
||||
}
|
||||
|
||||
return phase;
|
||||
}
|
||||
|
||||
export async function disableExternalModerationPhase(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
phaseID: string
|
||||
) {
|
||||
// Find the phase.
|
||||
if (!tenant.integrations.external) {
|
||||
throw new Error(
|
||||
"referenced phase was not found on tenant, none configured"
|
||||
);
|
||||
}
|
||||
let phase = getExternalModerationPhase(tenant.integrations.external, phaseID);
|
||||
if (!phase) {
|
||||
throw new Error("referenced phase was not found on tenant");
|
||||
}
|
||||
|
||||
// Phase is already disabled.
|
||||
if (phase.enabled === false) {
|
||||
return phase;
|
||||
}
|
||||
|
||||
const updatedTenant = await updateTenantExternalModerationPhase(
|
||||
mongo,
|
||||
tenant.id,
|
||||
phaseID,
|
||||
{ enabled: false }
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated phase.
|
||||
phase = getExternalModerationPhase(
|
||||
// We know that `external` is provided because we already verified it earlier.
|
||||
updatedTenant.integrations.external!,
|
||||
phaseID
|
||||
);
|
||||
if (!phase) {
|
||||
throw new Error("referenced phase was not found on tenant");
|
||||
}
|
||||
|
||||
return phase;
|
||||
}
|
||||
|
||||
export async function rotateExternalModerationPhaseSigningSecret(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
phaseID: string,
|
||||
inactiveIn: number,
|
||||
now: Date
|
||||
) {
|
||||
// Find the phase.
|
||||
if (!tenant.integrations.external) {
|
||||
throw new Error(
|
||||
"referenced phase was not found on tenant, none configured"
|
||||
);
|
||||
}
|
||||
let phase = getExternalModerationPhase(tenant.integrations.external, phaseID);
|
||||
if (!phase) {
|
||||
throw new Error("referenced phase was not found on tenant");
|
||||
}
|
||||
|
||||
if (inactiveIn < 0 || inactiveIn > 86400) {
|
||||
throw new Error(`invalid inactiveIn passed: ${inactiveIn}`);
|
||||
}
|
||||
|
||||
// Compute the inactiveAt dates for the current active secrets.
|
||||
const inactiveAt =
|
||||
inactiveIn === 0
|
||||
? now
|
||||
: DateTime.fromJSDate(now).plus({ seconds: inactiveIn }).toJSDate();
|
||||
|
||||
// Rotate the secrets.
|
||||
const updatedTenant = await rotateTenantExternalModerationPhaseSigningSecret(
|
||||
mongo,
|
||||
tenant.id,
|
||||
phaseID,
|
||||
inactiveAt,
|
||||
now
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated endpoint.
|
||||
phase = getExternalModerationPhase(
|
||||
// We know that `external` is provided because we already verified it earlier.
|
||||
updatedTenant.integrations.external!,
|
||||
phaseID
|
||||
);
|
||||
if (!phase) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
return phase;
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from "./tenant";
|
||||
export * from "./sso";
|
||||
export * from "./externalModerationPhases";
|
||||
export * from "./webhookEndpoints";
|
||||
|
||||
@@ -3,18 +3,20 @@ import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import {
|
||||
createTenantSSOKey,
|
||||
deactivateTenantSSOKey,
|
||||
deleteLastUsedAtTenantSSOKey,
|
||||
deleteTenantSSOKey,
|
||||
deactivateTenantSSOSigningSecret,
|
||||
deleteLastUsedAtTenantSSOSigningSecret,
|
||||
deleteTenantSSOSigningSecret,
|
||||
rotateTenantSSOSigningSecret,
|
||||
Tenant,
|
||||
} from "coral-server/models/tenant";
|
||||
|
||||
import TenantCache from "./cache";
|
||||
import { TenantCache } from "./cache";
|
||||
|
||||
/**
|
||||
* regenerateSSOKey will regenerate the Single Sign-On key for the specified
|
||||
* Tenant and notify all other Tenant's connected that the Tenant was updated.
|
||||
*
|
||||
* DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
|
||||
*/
|
||||
export async function regenerateSSOKey(
|
||||
mongo: Db,
|
||||
@@ -24,10 +26,17 @@ export async function regenerateSSOKey(
|
||||
now: Date
|
||||
) {
|
||||
// Regeneration is the same as rotating but with a specific 30 day window.
|
||||
return rotateSSOKey(mongo, redis, cache, tenant, 30 * 24 * 60 * 60, now);
|
||||
return rotateSSOSigningSecret(
|
||||
mongo,
|
||||
redis,
|
||||
cache,
|
||||
tenant,
|
||||
30 * 24 * 60 * 60,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
export async function rotateSSOKey(
|
||||
export async function rotateSSOSigningSecret(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
@@ -35,35 +44,16 @@ export async function rotateSSOKey(
|
||||
inactiveIn: number,
|
||||
now: Date
|
||||
) {
|
||||
// Deprecate the old Tenant SSO key if it exists.
|
||||
if (tenant.auth.integrations.sso.keys.length > 0) {
|
||||
// Get the old keys that are not deprecated.
|
||||
const keysToDeprecate = tenant.auth.integrations.sso.keys.filter((key) => {
|
||||
return !key.rotatedAt;
|
||||
});
|
||||
const inactiveAt = DateTime.fromJSDate(now)
|
||||
.plus({ seconds: inactiveIn })
|
||||
.toJSDate();
|
||||
|
||||
// Check to see if there are keys to deprecate.
|
||||
if (keysToDeprecate.length > 0) {
|
||||
const deprecateAt = DateTime.fromJSDate(now)
|
||||
.plus({ seconds: inactiveIn })
|
||||
.toJSDate();
|
||||
|
||||
// Deprecate all the keys that are associated on the tenant that haven't
|
||||
// been done.
|
||||
for (const key of keysToDeprecate) {
|
||||
await deactivateTenantSSOKey(
|
||||
mongo,
|
||||
tenant.id,
|
||||
key.kid,
|
||||
deprecateAt,
|
||||
now
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the new SSOKey.
|
||||
const updatedTenant = await createTenantSSOKey(mongo, tenant.id, now);
|
||||
const updatedTenant = await rotateTenantSSOSigningSecret(
|
||||
mongo,
|
||||
tenant.id,
|
||||
inactiveAt,
|
||||
now
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
return null;
|
||||
}
|
||||
@@ -74,7 +64,7 @@ export async function rotateSSOKey(
|
||||
return updatedTenant;
|
||||
}
|
||||
|
||||
export async function deactivateSSOKey(
|
||||
export async function deactivateSSOSigningSecret(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
@@ -82,13 +72,15 @@ export async function deactivateSSOKey(
|
||||
kid: string,
|
||||
now: Date
|
||||
) {
|
||||
const key = tenant.auth.integrations.sso.keys.find((k) => k.kid === kid);
|
||||
const key = tenant.auth.integrations.sso.signingSecrets.find(
|
||||
(k) => k.kid === kid
|
||||
);
|
||||
if (!key) {
|
||||
throw new Error("specified kid not found on tenant");
|
||||
}
|
||||
|
||||
// Deactivate the sso key now.
|
||||
const updatedTenant = await deactivateTenantSSOKey(
|
||||
const updatedTenant = await deactivateTenantSSOSigningSecret(
|
||||
mongo,
|
||||
tenant.id,
|
||||
kid,
|
||||
@@ -105,26 +97,32 @@ export async function deactivateSSOKey(
|
||||
return updatedTenant;
|
||||
}
|
||||
|
||||
export async function deleteSSOKey(
|
||||
export async function deleteSSOSigningSecret(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
kid: string
|
||||
) {
|
||||
const key = tenant.auth.integrations.sso.keys.find((k) => k.kid === kid);
|
||||
const key = tenant.auth.integrations.sso.signingSecrets.find(
|
||||
(k) => k.kid === kid
|
||||
);
|
||||
if (!key) {
|
||||
throw new Error("specified kid not found on tenant");
|
||||
}
|
||||
|
||||
// Deactivate the sso key now.
|
||||
const updatedTenant = await deleteTenantSSOKey(mongo, tenant.id, kid);
|
||||
const updatedTenant = await deleteTenantSSOSigningSecret(
|
||||
mongo,
|
||||
tenant.id,
|
||||
kid
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove the last used date entry from the Redis hash.
|
||||
await deleteLastUsedAtTenantSSOKey(redis, tenant.id, kid);
|
||||
await deleteLastUsedAtTenantSSOSigningSecret(redis, tenant.id, kid);
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Redis } from "ioredis";
|
||||
import { isUndefined, lowerCase, uniqBy } from "lodash";
|
||||
import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
import { URL } from "url";
|
||||
|
||||
@@ -13,18 +12,11 @@ import {
|
||||
createTenant,
|
||||
createTenantAnnouncement,
|
||||
CreateTenantInput,
|
||||
createTenantWebhookEndpoint,
|
||||
CreateTenantWebhookEndpointInput,
|
||||
deleteTenantAnnouncement,
|
||||
deleteTenantWebhookEndpoint,
|
||||
disableTenantFeatureFlag,
|
||||
enableTenantFeatureFlag,
|
||||
getWebhookEndpoint,
|
||||
rollTenantWebhookEndpointSecret,
|
||||
Tenant,
|
||||
updateTenant,
|
||||
updateTenantWebhookEndpoint,
|
||||
UpdateTenantWebhookEndpointInput,
|
||||
} from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
@@ -34,10 +26,9 @@ import {
|
||||
GQLFEATURE_FLAG,
|
||||
GQLSettingsInput,
|
||||
GQLSettingsWordListInput,
|
||||
GQLWEBHOOK_EVENT_NAME,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import TenantCache from "./cache";
|
||||
import TenantCache from "./cache/cache";
|
||||
|
||||
export type UpdateTenant = GQLSettingsInput;
|
||||
|
||||
@@ -165,258 +156,6 @@ export async function discoverOIDCConfiguration(issuerString: string) {
|
||||
return discover(issuer);
|
||||
}
|
||||
|
||||
interface WebhookEndpointInput {
|
||||
url: string;
|
||||
all: boolean;
|
||||
events: GQLWEBHOOK_EVENT_NAME[];
|
||||
}
|
||||
|
||||
export function validateWebhookEndpointInput(
|
||||
config: Config,
|
||||
input: WebhookEndpointInput
|
||||
) {
|
||||
// Check to see that this URL is valid and has a https:// scheme if in
|
||||
// production mode.
|
||||
const url = new URL(input.url);
|
||||
if (config.get("env") === "production" && url.protocol !== "https:") {
|
||||
throw new Error(`invalid scheme provided in production: ${url.protocol}`);
|
||||
}
|
||||
|
||||
// Ensure that either the "all" or "events" is provided but not both.
|
||||
if (input.all && input.events.length > 0) {
|
||||
throw new Error("both all events and specific events were requested");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWebhookEndpoint(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
config: Config,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
input: CreateTenantWebhookEndpointInput,
|
||||
now: Date
|
||||
) {
|
||||
// Validate the input.
|
||||
validateWebhookEndpointInput(config, input);
|
||||
|
||||
// Looks good in create this, send it off to be created.
|
||||
const result = await createTenantWebhookEndpoint(
|
||||
mongo,
|
||||
tenant.id,
|
||||
input,
|
||||
now
|
||||
);
|
||||
if (!result.tenant) {
|
||||
throw new Error("could not create the tenant endpoint, tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, result.tenant);
|
||||
|
||||
return {
|
||||
endpoint: result.endpoint,
|
||||
settings: result.tenant,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateWebhookEndpoint(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
config: Config,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
endpointID: string,
|
||||
input: UpdateTenantWebhookEndpointInput
|
||||
) {
|
||||
// Find the endpoint.
|
||||
let endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
// Extract the input.
|
||||
const {
|
||||
url = endpoint.url,
|
||||
all = endpoint.all,
|
||||
events = endpoint.events,
|
||||
} = input;
|
||||
|
||||
// Validate the input.
|
||||
validateWebhookEndpointInput(config, {
|
||||
url,
|
||||
all,
|
||||
events,
|
||||
});
|
||||
|
||||
const updatedTenant = await updateTenantWebhookEndpoint(
|
||||
mongo,
|
||||
tenant.id,
|
||||
endpointID,
|
||||
input
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated endpoint.
|
||||
endpoint = getWebhookEndpoint(updatedTenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export async function enableWebhookEndpoint(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
endpointID: string
|
||||
) {
|
||||
// Find the endpoint.
|
||||
let endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
// Endpoint is already enabled.
|
||||
if (endpoint.enabled === true) {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
const updatedTenant = await updateTenantWebhookEndpoint(
|
||||
mongo,
|
||||
tenant.id,
|
||||
endpointID,
|
||||
{ enabled: true }
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated endpoint.
|
||||
endpoint = getWebhookEndpoint(updatedTenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export async function disableWebhookEndpoint(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
endpointID: string
|
||||
) {
|
||||
// Find the endpoint.
|
||||
let endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
// Endpoint is already disabled.
|
||||
if (endpoint.enabled === false) {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
const updatedTenant = await updateTenantWebhookEndpoint(
|
||||
mongo,
|
||||
tenant.id,
|
||||
endpointID,
|
||||
{ enabled: false }
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated endpoint.
|
||||
endpoint = getWebhookEndpoint(updatedTenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export async function deleteWebhookEndpoint(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
endpointID: string
|
||||
) {
|
||||
// Find the endpoint.
|
||||
const endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
const updatedTenant = await deleteTenantWebhookEndpoint(
|
||||
mongo,
|
||||
tenant.id,
|
||||
endpointID
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export async function rotateWebhookEndpointSecret(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
endpointID: string,
|
||||
inactiveIn: number,
|
||||
now: Date
|
||||
) {
|
||||
// Compute the inactiveAt dates for the current active secrets.
|
||||
const inactiveAt = DateTime.fromJSDate(now)
|
||||
.plus({ seconds: inactiveIn })
|
||||
.toJSDate();
|
||||
|
||||
// Rotate the secrets.
|
||||
const updatedTenant = await rollTenantWebhookEndpointSecret(
|
||||
mongo,
|
||||
tenant.id,
|
||||
endpointID,
|
||||
inactiveAt,
|
||||
now
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated endpoint.
|
||||
const endpoint = getWebhookEndpoint(updatedTenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export async function enableFeatureFlag(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
@@ -484,6 +223,7 @@ export async function createAnnouncement(
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
await cache.update(redis, updated);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { Redis } from "ioredis";
|
||||
import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import {
|
||||
createTenantWebhookEndpoint,
|
||||
CreateTenantWebhookEndpointInput,
|
||||
deleteTenantWebhookEndpoint,
|
||||
getWebhookEndpoint,
|
||||
rotateTenantWebhookEndpointSigningSecret,
|
||||
Tenant,
|
||||
updateTenantWebhookEndpoint,
|
||||
UpdateTenantWebhookEndpointInput,
|
||||
} from "coral-server/models/tenant";
|
||||
|
||||
import { GQLWEBHOOK_EVENT_NAME } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { TenantCache } from "./cache";
|
||||
|
||||
interface WebhookEndpointInput {
|
||||
url: string;
|
||||
all: boolean;
|
||||
events: GQLWEBHOOK_EVENT_NAME[];
|
||||
}
|
||||
|
||||
export function validateWebhookEndpointInput(
|
||||
config: Config,
|
||||
input: WebhookEndpointInput
|
||||
) {
|
||||
// Check to see that this URL is valid and has a https:// scheme if in
|
||||
// production mode.
|
||||
const url = new URL(input.url);
|
||||
if (config.get("env") === "production" && url.protocol !== "https:") {
|
||||
throw new Error(`invalid scheme provided in production: ${url.protocol}`);
|
||||
}
|
||||
|
||||
// Ensure that either the "all" or "events" is provided but not both.
|
||||
if (input.all && input.events.length > 0) {
|
||||
throw new Error("both all events and specific events were requested");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWebhookEndpoint(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
config: Config,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
input: CreateTenantWebhookEndpointInput,
|
||||
now: Date
|
||||
) {
|
||||
// Validate the input.
|
||||
validateWebhookEndpointInput(config, input);
|
||||
|
||||
// Looks good in create this, send it off to be created.
|
||||
const result = await createTenantWebhookEndpoint(
|
||||
mongo,
|
||||
tenant.id,
|
||||
input,
|
||||
now
|
||||
);
|
||||
if (!result.tenant) {
|
||||
throw new Error("could not create the tenant endpoint, tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, result.tenant);
|
||||
|
||||
return {
|
||||
endpoint: result.endpoint,
|
||||
settings: result.tenant,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateWebhookEndpoint(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
config: Config,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
endpointID: string,
|
||||
input: UpdateTenantWebhookEndpointInput
|
||||
) {
|
||||
// Find the endpoint.
|
||||
let endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
// Extract the input.
|
||||
const {
|
||||
url = endpoint.url,
|
||||
all = endpoint.all,
|
||||
events = endpoint.events,
|
||||
} = input;
|
||||
|
||||
// Validate the input.
|
||||
validateWebhookEndpointInput(config, {
|
||||
url,
|
||||
all,
|
||||
events,
|
||||
});
|
||||
|
||||
const updatedTenant = await updateTenantWebhookEndpoint(
|
||||
mongo,
|
||||
tenant.id,
|
||||
endpointID,
|
||||
input
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated endpoint.
|
||||
endpoint = getWebhookEndpoint(updatedTenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export async function enableWebhookEndpoint(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
endpointID: string
|
||||
) {
|
||||
// Find the endpoint.
|
||||
let endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
// Endpoint is already enabled.
|
||||
if (endpoint.enabled === true) {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
const updatedTenant = await updateTenantWebhookEndpoint(
|
||||
mongo,
|
||||
tenant.id,
|
||||
endpointID,
|
||||
{ enabled: true }
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated endpoint.
|
||||
endpoint = getWebhookEndpoint(updatedTenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export async function disableWebhookEndpoint(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
endpointID: string
|
||||
) {
|
||||
// Find the endpoint.
|
||||
let endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
// Endpoint is already disabled.
|
||||
if (endpoint.enabled === false) {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
const updatedTenant = await updateTenantWebhookEndpoint(
|
||||
mongo,
|
||||
tenant.id,
|
||||
endpointID,
|
||||
{ enabled: false }
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated endpoint.
|
||||
endpoint = getWebhookEndpoint(updatedTenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export async function deleteWebhookEndpoint(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
endpointID: string
|
||||
) {
|
||||
// Find the endpoint.
|
||||
const endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
const updatedTenant = await deleteTenantWebhookEndpoint(
|
||||
mongo,
|
||||
tenant.id,
|
||||
endpointID
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export async function rotateWebhookEndpointSigningSecret(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
endpointID: string,
|
||||
inactiveIn: number,
|
||||
now: Date
|
||||
) {
|
||||
// Find the endpoint.
|
||||
let endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
if (inactiveIn < 0 || inactiveIn > 86400) {
|
||||
throw new Error(`invalid inactiveIn passed: ${inactiveIn}`);
|
||||
}
|
||||
|
||||
// Compute the inactiveAt dates for the current active secrets.
|
||||
const inactiveAt =
|
||||
inactiveIn === 0
|
||||
? now
|
||||
: DateTime.fromJSDate(now).plus({ seconds: inactiveIn }).toJSDate();
|
||||
|
||||
// Rotate the secrets.
|
||||
const updatedTenant = await rotateTenantWebhookEndpointSigningSecret(
|
||||
mongo,
|
||||
tenant.id,
|
||||
endpointID,
|
||||
inactiveAt,
|
||||
now
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated endpoint.
|
||||
endpoint = getWebhookEndpoint(updatedTenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
retrieveComment,
|
||||
} from "coral-server/models/comment";
|
||||
import {
|
||||
getDepth,
|
||||
hasAncestors,
|
||||
hasPublishedStatus,
|
||||
} from "coral-server/models/comment/helpers";
|
||||
@@ -91,7 +92,7 @@ const markCommentAsAnswered = async (
|
||||
// If we are the export on this story...
|
||||
story.settings.expertIDs.some((id) => id === author.id) &&
|
||||
// And this is the first reply (depth of 1)...
|
||||
comment.ancestorIDs.length === 1
|
||||
getDepth(comment) === 1
|
||||
) {
|
||||
// We need to mark the parent question as answered.
|
||||
// - Remove the unanswered tag.
|
||||
@@ -170,14 +171,14 @@ export default async function create(
|
||||
try {
|
||||
// Run the comment through the moderation phases.
|
||||
result = await processForModeration({
|
||||
action: "NEW",
|
||||
log,
|
||||
mongo,
|
||||
redis,
|
||||
config,
|
||||
nudge,
|
||||
story,
|
||||
action: "NEW",
|
||||
tenant,
|
||||
story,
|
||||
nudge,
|
||||
comment: { ...input, ancestorIDs },
|
||||
author,
|
||||
req,
|
||||
@@ -221,7 +222,8 @@ export default async function create(
|
||||
{
|
||||
...input,
|
||||
siteID: story.siteID,
|
||||
tags,
|
||||
// Remap the tags to include the createdAt.
|
||||
tags: tags.map((tag) => ({ type: tag, createdAt: now })),
|
||||
body,
|
||||
status,
|
||||
ancestorIDs,
|
||||
@@ -277,10 +279,11 @@ export default async function create(
|
||||
...action,
|
||||
commentID: comment.id,
|
||||
commentRevisionID: revision.id,
|
||||
|
||||
// Store the Story ID on the action.
|
||||
storyID: story.id,
|
||||
siteID: story.siteID,
|
||||
|
||||
// All these actions are created by the system.
|
||||
userID: null,
|
||||
})
|
||||
),
|
||||
now
|
||||
|
||||
@@ -103,13 +103,13 @@ export default async function edit(
|
||||
|
||||
// Run the comment through the moderation phases.
|
||||
const { body, status, metadata, actions } = await processForModeration({
|
||||
action: "EDIT",
|
||||
log,
|
||||
mongo,
|
||||
redis,
|
||||
config,
|
||||
story,
|
||||
action: "EDIT",
|
||||
tenant,
|
||||
story,
|
||||
comment: {
|
||||
...originalStaleComment,
|
||||
...input,
|
||||
@@ -161,6 +161,9 @@ export default async function edit(
|
||||
commentRevisionID: result.revision.id,
|
||||
storyID: story.id,
|
||||
siteID: story.siteID,
|
||||
|
||||
// All these actions are created by the system.
|
||||
userID: null,
|
||||
})
|
||||
),
|
||||
now
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Logger } from "coral-server/logger";
|
||||
import { PersistedQuery } from "coral-server/models/queries";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
|
||||
export interface CoralRequest {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user