From 6fe46467559a40735ddc32b12824c4757e181de2 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 3 Oct 2019 19:33:52 +0000 Subject: [PATCH] [CORL-668] User Comment Rate Limiting (#2616) * feat: added user comment rate limiting * fix: change 30 second to 3 second * fix: moved file * fix: services cleanup * fix: changes based on review --- src/core/common/constants.ts | 6 + src/core/server/errors/index.ts | 2 +- .../server/graph/tenant/mutators/Comments.ts | 2 + src/core/server/services/comments/comments.ts | 537 +++++++ src/core/server/services/comments/index.ts | 529 +------ .../services/comments/moderation/index.ts | 136 +- .../{index.spec.ts => moderation.spec.ts} | 0 .../comments/moderation/moderation.ts | 134 ++ .../services/comments/pipeline/index.ts | 138 +- .../comments/pipeline/phases/index.ts | 2 + .../comments/pipeline/phases/userRateLimit.ts | 59 + .../{index.spec.ts => pipeline.spec.ts} | 6 +- .../services/comments/pipeline/pipeline.ts | 140 ++ src/core/server/services/users/index.ts | 1169 +--------------- src/core/server/services/users/users.ts | 1234 +++++++++++++++++ 15 files changed, 2127 insertions(+), 1967 deletions(-) create mode 100644 src/core/server/services/comments/comments.ts rename src/core/server/services/comments/moderation/{index.spec.ts => moderation.spec.ts} (100%) create mode 100644 src/core/server/services/comments/moderation/moderation.ts create mode 100644 src/core/server/services/comments/pipeline/phases/userRateLimit.ts rename src/core/server/services/comments/pipeline/{index.spec.ts => pipeline.spec.ts} (96%) create mode 100644 src/core/server/services/comments/pipeline/pipeline.ts create mode 100644 src/core/server/services/users/users.ts diff --git a/src/core/common/constants.ts b/src/core/common/constants.ts index 802ebda24..f0e1a209d 100644 --- a/src/core/common/constants.ts +++ b/src/core/common/constants.ts @@ -53,6 +53,12 @@ export const ALLOWED_USERNAME_CHANGE_FREQUENCY = 14 * 86400; */ export const SCHEDULED_DELETION_TIMESPAN_DAYS = 14; +/** + * COMMENT_LIMIT_WINDOW_SECONDS is the number of seconds that a user has to + * wait in-between writing comments. + */ +export const COMMENT_LIMIT_WINDOW_SECONDS = 3; + /** * DEFAULT_SESSION_LENTTH is the length of time in seconds a session is valid for unless configured in tenant. */ diff --git a/src/core/server/errors/index.ts b/src/core/server/errors/index.ts index b60eddf52..54866ee59 100644 --- a/src/core/server/errors/index.ts +++ b/src/core/server/errors/index.ts @@ -675,7 +675,7 @@ export class InviteTokenExpired extends CoralError { } export class RateLimitExceeded extends CoralError { - constructor(resource: string, max: number, tries: number) { + constructor(resource: string, max: number, tries?: number) { super({ code: ERROR_CODES.RATE_LIMIT_EXCEEDED, status: 429, diff --git a/src/core/server/graph/tenant/mutators/Comments.ts b/src/core/server/graph/tenant/mutators/Comments.ts index a38acbf90..3922d4f26 100644 --- a/src/core/server/graph/tenant/mutators/Comments.ts +++ b/src/core/server/graph/tenant/mutators/Comments.ts @@ -44,6 +44,7 @@ export const Comments = (ctx: TenantContext) => ({ create( ctx.mongo, ctx.redis, + ctx.config, ctx.publisher, ctx.tenant, ctx.user!, @@ -66,6 +67,7 @@ export const Comments = (ctx: TenantContext) => ({ edit( ctx.mongo, ctx.redis, + ctx.config, ctx.publisher, ctx.tenant, ctx.user!, diff --git a/src/core/server/services/comments/comments.ts b/src/core/server/services/comments/comments.ts new file mode 100644 index 000000000..e1b212105 --- /dev/null +++ b/src/core/server/services/comments/comments.ts @@ -0,0 +1,537 @@ +import { DateTime } from "luxon"; +import { Db } from "mongodb"; + +import { ERROR_TYPES } from "coral-common/errors"; +import { Omit } from "coral-common/types"; +import { Config } from "coral-server/config"; +import { + CommentNotFoundError, + CoralError, + StoryNotFoundError, +} from "coral-server/errors"; +import { GQLTAG } from "coral-server/graph/tenant/schema/__generated__/types"; +import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; +import logger from "coral-server/logger"; +import { + encodeActionCounts, + filterDuplicateActions, +} from "coral-server/models/action/comment"; +import { createCommentModerationAction } from "coral-server/models/action/moderation/comment"; +import { + addCommentTag, + createComment, + CreateCommentInput, + editComment, + EditCommentInput, + pushChildCommentIDOntoParent, + removeCommentTag, + retrieveComment, + validateEditable, +} from "coral-server/models/comment"; +import { + getLatestRevision, + hasAncestors, + hasPublishedStatus, +} from "coral-server/models/comment/helpers"; +import { + retrieveStory, + StoryCounts, + updateStoryCounts, +} from "coral-server/models/story"; +import { Tenant } from "coral-server/models/tenant"; +import { User } from "coral-server/models/user"; +import { + publishCommentCreated, + publishCommentReplyCreated, + publishCommentStatusChanges, + publishModerationQueueChanges, +} from "coral-server/services/events"; +import { AugmentedRedis } from "coral-server/services/redis"; +import { Request } from "coral-server/types/express"; + +import { updateUserLastWroteCommentTimestamp } from "../users"; +import { addCommentActions, CreateAction } from "./actions"; +import { calculateCounts, calculateCountsDiff } from "./moderation/counts"; +import { PhaseResult, processForModeration } from "./pipeline"; + +export type CreateComment = Omit< + CreateCommentInput, + "status" | "metadata" | "ancestorIDs" | "actionCounts" | "tags" +>; + +export async function create( + mongo: Db, + redis: AugmentedRedis, + config: Config, + publish: Publisher, + tenant: Tenant, + author: User, + input: CreateComment, + nudge: boolean, + now = new Date(), + req?: Request +) { + let log = logger.child( + { + authorID: author.id, + tenantID: tenant.id, + storyID: input.storyID, + parentID: input.parentID, + nudge, + }, + true + ); + + log.trace("creating comment on story"); + + // Grab the story that we'll use to check moderation pieces with. + const story = await retrieveStory(mongo, tenant.id, input.storyID); + if (!story) { + throw new StoryNotFoundError(input.storyID); + } + + const ancestorIDs: string[] = []; + if (input.parentID) { + // Check to see that the reference parent ID exists. + const parent = await retrieveComment(mongo, tenant.id, input.parentID); + if (!parent) { + throw new CommentNotFoundError(input.parentID); + } + + // Check that the parent comment was visible. + if (!hasPublishedStatus(parent)) { + throw new CommentNotFoundError(parent.id); + } + + ancestorIDs.push(input.parentID); + if (hasAncestors(parent)) { + // Push the parent's ancestors id's into the comment's ancestor id's. + ancestorIDs.push(...parent.ancestorIDs); + } + + log.trace( + { ancestorIDs: ancestorIDs.length }, + "pushed parent ancestorIDs into comment" + ); + } + + let result: PhaseResult; + + try { + // Run the comment through the moderation phases. + result = await processForModeration({ + action: "NEW", + mongo, + redis, + config, + nudge, + story, + tenant, + comment: input, + author, + req, + now, + }); + } catch (err) { + if ( + err instanceof CoralError && + err.type === ERROR_TYPES.MODERATION_NUDGE_ERROR + ) { + log.info({ err }, "detected pipeline nudge"); + } + + throw err; + } + + const { actions, body, status, metadata, tags } = result; + + // This is the first time this comment is being published.. So we need to + // ensure we don't run into any race conditions when we create the comment. + // One of the situations where we could encounter a race is when the comment + // is created, and does not have it's flag data associated with it. This would + // cause the comment to not be added to the flagged queue. If a flag is + // pending, and a user flags this comment before the next step can proceed, + // then we would end up double adding the comment to the flagged queue. + // Instead, we need to add the action metadata to the comment before we add it + // for the first time to ensure that the data is there for when the next flag + // is added, that it can already know that the comment is already in the + // queue. + let actionCounts = {}; + if (actions.length > 0) { + // Determine the unique actions, we will use this to compute the comment + // action counts. This should match what is added below. + const deDuplicatedActions = filterDuplicateActions(actions); + + // Encode the action counts. + actionCounts = encodeActionCounts(...deDuplicatedActions); + } + + // Create the comment action in our rate limiter. This will throw an error if + // there is a rate limit error. + await updateUserLastWroteCommentTimestamp(redis, tenant, author, now); + + // Create the comment! + const comment = await createComment( + mongo, + tenant.id, + { + ...input, + tags, + body, + status, + ancestorIDs, + metadata, + actionCounts, + }, + now + ); + + // Pull the revision out. + const revision = getLatestRevision(comment); + + log = log.child( + { commentID: comment.id, status, revisionID: revision.id }, + true + ); + + log.trace("comment created"); + + if (input.parentID) { + // Push the child's ID onto the parent. + await pushChildCommentIDOntoParent( + mongo, + tenant.id, + input.parentID, + comment.id + ); + + log.trace("pushed child comment id onto parent"); + } + + if (actions.length > 0) { + // Actually add the actions to the database. This will not interact with the + // counts at all. + const upsertedActions = await addCommentActions( + mongo, + tenant, + actions.map( + (action): CreateAction => ({ + ...action, + commentID: comment.id, + commentRevisionID: revision.id, + + // Store the Story ID on the action. + storyID: story.id, + }) + ), + now + ); + + log.trace({ actions: upsertedActions.length }, "added actions to comment"); + } + const moderationQueue = calculateCounts(comment); + + // Publish changes. + publishModerationQueueChanges(publish, moderationQueue, comment); + + // If this is a reply, publish it. + if (input.parentID) { + publishCommentReplyCreated(publish, comment); + } + + // If this comment is visible (and not a reply), publish it. + if (!input.parentID && hasPublishedStatus(comment)) { + publishCommentCreated(publish, comment); + } + + // Compile the changes we want to apply to the story counts. + const storyCounts: Required> = { + // This is a new comment, so we need to increment for this status. + status: { [status]: 1 }, + // This comment is being created, so we can compute it raw from the comment + // that we created. + moderationQueue, + }; + + log.trace({ storyCounts }, "updating story status counts"); + + // Increment the status count for the particular status on the Story. + await updateStoryCounts(mongo, redis, tenant.id, story.id, storyCounts); + + return comment; +} + +export type EditComment = Omit< + EditCommentInput, + "status" | "authorID" | "lastEditableCommentCreatedAt" | "metadata" +>; + +export async function edit( + mongo: Db, + redis: AugmentedRedis, + config: Config, + publish: Publisher, + tenant: Tenant, + author: User, + input: EditComment, + now = new Date(), + req?: Request +) { + let log = logger.child({ commentID: input.id, tenantID: tenant.id }, true); + + // Get the comment that we're editing. This comment is considered stale, + // because it wasn't involved in the atomic transaction. + const originalStaleComment = await retrieveComment( + mongo, + tenant.id, + input.id + ); + if (!originalStaleComment) { + throw new CommentNotFoundError(input.id); + } + + // The editable time is based on the current time, and the edit window + // length. By subtracting the current date from the edit window length, we + // get the maximum value for the `createdAt` time that would be permitted + // for the comment edit to succeed. + const lastEditableCommentCreatedAt = getLastCommentEditableUntilDate( + tenant, + now + ); + + // Validate and potentially return with a more useful error. + validateEditable(originalStaleComment, { + authorID: author.id, + lastEditableCommentCreatedAt, + }); + + // Grab the story that we'll use to check moderation pieces with. + const story = await retrieveStory( + mongo, + tenant.id, + originalStaleComment.storyID + ); + if (!story) { + throw new StoryNotFoundError(originalStaleComment.storyID); + } + + // Run the comment through the moderation phases. + const { body, status, metadata, actions } = await processForModeration({ + action: "EDIT", + mongo, + redis, + config, + story, + tenant, + comment: input, + author, + req, + now, + }); + + let actionCounts = {}; + if (actions.length > 0) { + // Encode the new action counts that are going to be added to the new + // revision. + actionCounts = encodeActionCounts(...filterDuplicateActions(actions)); + } + + log.trace( + { predictedActionCounts: actionCounts }, + "associating action counts with comment" + ); + + // Perform the edit. + const result = await editComment( + mongo, + tenant.id, + { + id: input.id, + authorID: author.id, + body, + status, + metadata, + actionCounts, + lastEditableCommentCreatedAt, + }, + now + ); + if (!result) { + throw new CommentNotFoundError(input.id); + } + + // Pull the old/edited comments out of the edit result. + const { oldComment, editedComment, newRevision } = result; + + log = log.child({ revisionID: newRevision.id }, true); + + if (actions.length > 0) { + // Insert and handle creating the actions. + const upsertedActions = await addCommentActions( + mongo, + tenant, + actions.map( + (action): CreateAction => ({ + ...action, + commentID: editedComment.id, + commentRevisionID: newRevision.id, + storyID: story.id, + }) + ), + now + ); + + log.trace( + { + actualActionCounts: encodeActionCounts(...upsertedActions), + actions: upsertedActions.length, + }, + "added actions to comment" + ); + } + + // Compute the changes in queue counts. This looks at the action counts that + // are encoded, as well as the comment status's. We however may have had the + // comment status when we grabbed the updated comment after changing the + // action counts, so we extract the action counts out of the edited comment + // and use the status from the moderation decision. + const moderationQueue = calculateCountsDiff(oldComment, { + status, + actionCounts: editedComment.actionCounts, + }); + + // Compile the changes we want to apply to the story counts. + const storyCounts: Required> = { + // Status is updated below if it has been changed. + status: {}, + moderationQueue, + }; + + if (oldComment.status !== editedComment.status) { + // Increment the status count for the particular status on the Story, and + // decrement the status on the comment's previous status. The old comment + // status was only there before the atomic mutation. The new status is based + // on the moderation pipeline. + storyCounts.status[oldComment.status] = -1; + storyCounts.status[status] = 1; + + // The comment status changed as a result of a pipeline operation, create a + // moderation action as a result. + await createCommentModerationAction(mongo, tenant.id, { + commentID: editedComment.id, + commentRevisionID: newRevision.id, + status: editedComment.status, + moderatorID: null, + }); + } + + log.trace({ storyCounts }, "updating story status counts"); + + // Update the story counts as a result. + await updateStoryCounts(mongo, redis, tenant.id, story.id, storyCounts); + + // Publish changes. + publishModerationQueueChanges(publish, moderationQueue, editedComment); + publishCommentStatusChanges( + publish, + oldComment.status, + editedComment.status, + editedComment.id, + // This is a comment that was edited, so it should not present a moderator. + null + ); + + return editedComment; +} + +/** + * getLastCommentEditableUntilDate will return the `createdAt` date that will + * represent the _oldest_ date that a comment could have been created on in + * order to still be editable. + * + * @param tenant the tenant that contains settings related editing + * @param now the date that is the base, defaulting to the current time + */ +export function getLastCommentEditableUntilDate( + tenant: Pick, + now = new Date() +): Date { + return ( + DateTime.fromJSDate(now) + // editCommentWindowLength is in seconds, so multiply by 1000 to get + // milliseconds. + .minus(tenant.editCommentWindowLength * 1000) + .toJSDate() + ); +} + +/** + * getCommentEditableUntilDate will return the date that the given comment is + * still editable until. + * + * @param tenant the tenant that contains settings related editing + * @param createdAt the date that is the base, defaulting to the current time + */ +export function getCommentEditableUntilDate( + tenant: Pick, + createdAt: Date +): Date { + return ( + DateTime.fromJSDate(createdAt) + // editCommentWindowLength is in seconds, so multiply by 1000 to get + // milliseconds. + .plus(tenant.editCommentWindowLength * 1000) + .toJSDate() + ); +} + +export async function addTag( + mongo: Db, + tenant: Tenant, + commentID: string, + commentRevisionID: string, + user: User, + tagType: GQLTAG, + now = new Date() +) { + const comment = await retrieveComment(mongo, tenant.id, commentID); + if (!comment) { + throw new CommentNotFoundError(commentID); + } + + // Check to see if the selected comment revision is the latest one. + const revision = getLatestRevision(comment); + if (revision.id !== commentRevisionID) { + throw new Error("revision id does not match latest revision"); + } + + // Check to see if this tag is already on this comment. + if (comment.tags.some(({ type }) => type === tagType)) { + return comment; + } + + return addCommentTag(mongo, tenant.id, commentID, { + type: tagType, + createdBy: user.id, + createdAt: now, + }); +} + +export async function removeTag( + mongo: Db, + tenant: Tenant, + commentID: string, + tagType: GQLTAG +) { + const comment = await retrieveComment(mongo, tenant.id, commentID); + if (!comment) { + throw new CommentNotFoundError(commentID); + } + + // Check to see if this tag is even on this comment. + if (comment.tags.every(({ type }) => type !== tagType)) { + return comment; + } + + return removeCommentTag(mongo, tenant.id, commentID, tagType); +} diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 5387e26ce..1fa64c643 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -1,525 +1,4 @@ -import { DateTime } from "luxon"; -import { Db } from "mongodb"; - -import { ERROR_TYPES } from "coral-common/errors"; -import { Omit } from "coral-common/types"; -import { - CommentNotFoundError, - CoralError, - StoryNotFoundError, -} from "coral-server/errors"; -import { GQLTAG } from "coral-server/graph/tenant/schema/__generated__/types"; -import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; -import logger from "coral-server/logger"; -import { - encodeActionCounts, - filterDuplicateActions, -} from "coral-server/models/action/comment"; -import { createCommentModerationAction } from "coral-server/models/action/moderation/comment"; -import { - addCommentTag, - createComment, - CreateCommentInput, - editComment, - EditCommentInput, - pushChildCommentIDOntoParent, - removeCommentTag, - retrieveComment, - validateEditable, -} from "coral-server/models/comment"; -import { - getLatestRevision, - hasAncestors, - hasPublishedStatus, -} from "coral-server/models/comment/helpers"; -import { - retrieveStory, - StoryCounts, - updateStoryCounts, -} from "coral-server/models/story"; -import { Tenant } from "coral-server/models/tenant"; -import { User } from "coral-server/models/user"; -import { - publishCommentCreated, - publishCommentReplyCreated, - publishCommentStatusChanges, - publishModerationQueueChanges, -} from "coral-server/services/events"; -import { AugmentedRedis } from "coral-server/services/redis"; -import { Request } from "coral-server/types/express"; - -import { addCommentActions, CreateAction } from "./actions"; -import { calculateCounts, calculateCountsDiff } from "./moderation/counts"; -import { PhaseResult, processForModeration } from "./pipeline"; - -export type CreateComment = Omit< - CreateCommentInput, - "status" | "metadata" | "ancestorIDs" | "actionCounts" | "tags" ->; - -export async function create( - mongo: Db, - redis: AugmentedRedis, - publish: Publisher, - tenant: Tenant, - author: User, - input: CreateComment, - nudge: boolean, - now = new Date(), - req?: Request -) { - let log = logger.child( - { - authorID: author.id, - tenantID: tenant.id, - storyID: input.storyID, - parentID: input.parentID, - nudge, - }, - true - ); - - // TODO: (wyattjoh) perform rate limiting based on the user? - - log.trace("creating comment on story"); - - // Grab the story that we'll use to check moderation pieces with. - const story = await retrieveStory(mongo, tenant.id, input.storyID); - if (!story) { - throw new StoryNotFoundError(input.storyID); - } - - const ancestorIDs: string[] = []; - if (input.parentID) { - // Check to see that the reference parent ID exists. - const parent = await retrieveComment(mongo, tenant.id, input.parentID); - if (!parent) { - throw new CommentNotFoundError(input.parentID); - } - - // Check that the parent comment was visible. - if (!hasPublishedStatus(parent)) { - throw new CommentNotFoundError(parent.id); - } - - ancestorIDs.push(input.parentID); - if (hasAncestors(parent)) { - // Push the parent's ancestors id's into the comment's ancestor id's. - ancestorIDs.push(...parent.ancestorIDs); - } - - log.trace( - { ancestorIDs: ancestorIDs.length }, - "pushed parent ancestorIDs into comment" - ); - } - - let result: PhaseResult; - - try { - // Run the comment through the moderation phases. - result = await processForModeration({ - mongo, - nudge, - story, - tenant, - comment: input, - author, - req, - now, - }); - } catch (err) { - if ( - err instanceof CoralError && - err.type === ERROR_TYPES.MODERATION_NUDGE_ERROR - ) { - log.info({ err }, "detected pipeline nudge"); - } - - throw err; - } - - const { actions, body, status, metadata, tags } = result; - - // This is the first time this comment is being published.. So we need to - // ensure we don't run into any race conditions when we create the comment. - // One of the situations where we could encounter a race is when the comment - // is created, and does not have it's flag data associated with it. This would - // cause the comment to not be added to the flagged queue. If a flag is - // pending, and a user flags this comment before the next step can proceed, - // then we would end up double adding the comment to the flagged queue. - // Instead, we need to add the action metadata to the comment before we add it - // for the first time to ensure that the data is there for when the next flag - // is added, that it can already know that the comment is already in the - // queue. - let actionCounts = {}; - if (actions.length > 0) { - // Determine the unique actions, we will use this to compute the comment - // action counts. This should match what is added below. - const deDuplicatedActions = filterDuplicateActions(actions); - - // Encode the action counts. - actionCounts = encodeActionCounts(...deDuplicatedActions); - } - - // Create the comment! - const comment = await createComment( - mongo, - tenant.id, - { - ...input, - tags, - body, - status, - ancestorIDs, - metadata, - actionCounts, - }, - now - ); - - // Pull the revision out. - const revision = getLatestRevision(comment); - - log = log.child( - { commentID: comment.id, status, revisionID: revision.id }, - true - ); - - log.trace("comment created"); - - if (input.parentID) { - // Push the child's ID onto the parent. - await pushChildCommentIDOntoParent( - mongo, - tenant.id, - input.parentID, - comment.id - ); - - log.trace("pushed child comment id onto parent"); - } - - if (actions.length > 0) { - // Actually add the actions to the database. This will not interact with the - // counts at all. - const upsertedActions = await addCommentActions( - mongo, - tenant, - actions.map( - (action): CreateAction => ({ - ...action, - commentID: comment.id, - commentRevisionID: revision.id, - - // Store the Story ID on the action. - storyID: story.id, - }) - ), - now - ); - - log.trace({ actions: upsertedActions.length }, "added actions to comment"); - } - const moderationQueue = calculateCounts(comment); - - // Publish changes. - publishModerationQueueChanges(publish, moderationQueue, comment); - - // If this is a reply, publish it. - if (input.parentID) { - publishCommentReplyCreated(publish, comment); - } - - // If this comment is visible (and not a reply), publish it. - if (!input.parentID && hasPublishedStatus(comment)) { - publishCommentCreated(publish, comment); - } - - // Compile the changes we want to apply to the story counts. - const storyCounts: Required> = { - // This is a new comment, so we need to increment for this status. - status: { [status]: 1 }, - // This comment is being created, so we can compute it raw from the comment - // that we created. - moderationQueue, - }; - - log.trace({ storyCounts }, "updating story status counts"); - - // Increment the status count for the particular status on the Story. - await updateStoryCounts(mongo, redis, tenant.id, story.id, storyCounts); - - return comment; -} - -export type EditComment = Omit< - EditCommentInput, - "status" | "authorID" | "lastEditableCommentCreatedAt" | "metadata" ->; - -export async function edit( - mongo: Db, - redis: AugmentedRedis, - publish: Publisher, - tenant: Tenant, - author: User, - input: EditComment, - now = new Date(), - req?: Request -) { - let log = logger.child({ commentID: input.id, tenantID: tenant.id }, true); - - // Get the comment that we're editing. This comment is considered stale, - // because it wasn't involved in the atomic transaction. - const originalStaleComment = await retrieveComment( - mongo, - tenant.id, - input.id - ); - if (!originalStaleComment) { - throw new CommentNotFoundError(input.id); - } - - // The editable time is based on the current time, and the edit window - // length. By subtracting the current date from the edit window length, we - // get the maximum value for the `createdAt` time that would be permitted - // for the comment edit to succeed. - const lastEditableCommentCreatedAt = getLastCommentEditableUntilDate( - tenant, - now - ); - - // Validate and potentially return with a more useful error. - validateEditable(originalStaleComment, { - authorID: author.id, - lastEditableCommentCreatedAt, - }); - - // Grab the story that we'll use to check moderation pieces with. - const story = await retrieveStory( - mongo, - tenant.id, - originalStaleComment.storyID - ); - if (!story) { - throw new StoryNotFoundError(originalStaleComment.storyID); - } - - // Run the comment through the moderation phases. - const { body, status, metadata, actions } = await processForModeration({ - mongo, - story, - tenant, - comment: input, - author, - req, - now, - }); - - let actionCounts = {}; - if (actions.length > 0) { - // Encode the new action counts that are going to be added to the new - // revision. - actionCounts = encodeActionCounts(...filterDuplicateActions(actions)); - } - - log.trace( - { predictedActionCounts: actionCounts }, - "associating action counts with comment" - ); - - // Perform the edit. - const result = await editComment( - mongo, - tenant.id, - { - id: input.id, - authorID: author.id, - body, - status, - metadata, - actionCounts, - lastEditableCommentCreatedAt, - }, - now - ); - if (!result) { - throw new CommentNotFoundError(input.id); - } - - // Pull the old/edited comments out of the edit result. - const { oldComment, editedComment, newRevision } = result; - - log = log.child({ revisionID: newRevision.id }, true); - - if (actions.length > 0) { - // Insert and handle creating the actions. - const upsertedActions = await addCommentActions( - mongo, - tenant, - actions.map( - (action): CreateAction => ({ - ...action, - commentID: editedComment.id, - commentRevisionID: newRevision.id, - storyID: story.id, - }) - ), - now - ); - - log.trace( - { - actualActionCounts: encodeActionCounts(...upsertedActions), - actions: upsertedActions.length, - }, - "added actions to comment" - ); - } - - // Compute the changes in queue counts. This looks at the action counts that - // are encoded, as well as the comment status's. We however may have had the - // comment status when we grabbed the updated comment after changing the - // action counts, so we extract the action counts out of the edited comment - // and use the status from the moderation decision. - const moderationQueue = calculateCountsDiff(oldComment, { - status, - actionCounts: editedComment.actionCounts, - }); - - // Compile the changes we want to apply to the story counts. - const storyCounts: Required> = { - // Status is updated below if it has been changed. - status: {}, - moderationQueue, - }; - - if (oldComment.status !== editedComment.status) { - // Increment the status count for the particular status on the Story, and - // decrement the status on the comment's previous status. The old comment - // status was only there before the atomic mutation. The new status is based - // on the moderation pipeline. - storyCounts.status[oldComment.status] = -1; - storyCounts.status[status] = 1; - - // The comment status changed as a result of a pipeline operation, create a - // moderation action as a result. - await createCommentModerationAction(mongo, tenant.id, { - commentID: editedComment.id, - commentRevisionID: newRevision.id, - status: editedComment.status, - moderatorID: null, - }); - } - - log.trace({ storyCounts }, "updating story status counts"); - - // Update the story counts as a result. - await updateStoryCounts(mongo, redis, tenant.id, story.id, storyCounts); - - // Publish changes. - publishModerationQueueChanges(publish, moderationQueue, editedComment); - publishCommentStatusChanges( - publish, - oldComment.status, - editedComment.status, - editedComment.id, - // This is a comment that was edited, so it should not present a moderator. - null - ); - - return editedComment; -} - -/** - * getLastCommentEditableUntilDate will return the `createdAt` date that will - * represent the _oldest_ date that a comment could have been created on in - * order to still be editable. - * - * @param tenant the tenant that contains settings related editing - * @param now the date that is the base, defaulting to the current time - */ -export function getLastCommentEditableUntilDate( - tenant: Pick, - now = new Date() -): Date { - return ( - DateTime.fromJSDate(now) - // editCommentWindowLength is in seconds, so multiply by 1000 to get - // milliseconds. - .minus(tenant.editCommentWindowLength * 1000) - .toJSDate() - ); -} - -/** - * getCommentEditableUntilDate will return the date that the given comment is - * still editable until. - * - * @param tenant the tenant that contains settings related editing - * @param createdAt the date that is the base, defaulting to the current time - */ -export function getCommentEditableUntilDate( - tenant: Pick, - createdAt: Date -): Date { - return ( - DateTime.fromJSDate(createdAt) - // editCommentWindowLength is in seconds, so multiply by 1000 to get - // milliseconds. - .plus(tenant.editCommentWindowLength * 1000) - .toJSDate() - ); -} - -export async function addTag( - mongo: Db, - tenant: Tenant, - commentID: string, - commentRevisionID: string, - user: User, - tagType: GQLTAG, - now = new Date() -) { - const comment = await retrieveComment(mongo, tenant.id, commentID); - if (!comment) { - throw new CommentNotFoundError(commentID); - } - - // Check to see if the selected comment revision is the latest one. - const revision = getLatestRevision(comment); - if (revision.id !== commentRevisionID) { - throw new Error("revision id does not match latest revision"); - } - - // Check to see if this tag is already on this comment. - if (comment.tags.some(({ type }) => type === tagType)) { - return comment; - } - - return addCommentTag(mongo, tenant.id, commentID, { - type: tagType, - createdBy: user.id, - createdAt: now, - }); -} - -export async function removeTag( - mongo: Db, - tenant: Tenant, - commentID: string, - tagType: GQLTAG -) { - const comment = await retrieveComment(mongo, tenant.id, commentID); - if (!comment) { - throw new CommentNotFoundError(commentID); - } - - // Check to see if this tag is even on this comment. - if (comment.tags.every(({ type }) => type !== tagType)) { - return comment; - } - - return removeCommentTag(mongo, tenant.id, commentID, tagType); -} +export * from "./comments"; +export * from "./actions"; +export * from "./pipeline"; +export * from "./moderation"; diff --git a/src/core/server/services/comments/moderation/index.ts b/src/core/server/services/comments/moderation/index.ts index 7922689a0..bd46c87d5 100644 --- a/src/core/server/services/comments/moderation/index.ts +++ b/src/core/server/services/comments/moderation/index.ts @@ -1,134 +1,2 @@ -import { Db } from "mongodb"; - -import { Omit } from "coral-common/types"; -import { CommentNotFoundError, StoryNotFoundError } from "coral-server/errors"; -import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types"; -import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; -import logger from "coral-server/logger"; -import { - createCommentModerationAction, - CreateCommentModerationActionInput, -} from "coral-server/models/action/moderation/comment"; -import { updateCommentStatus } from "coral-server/models/comment"; -import { updateStoryCounts } from "coral-server/models/story"; -import { Tenant } from "coral-server/models/tenant"; -import { - publishCommentReleased, - publishCommentStatusChanges, - publishModerationQueueChanges, -} from "coral-server/services/events"; -import { AugmentedRedis } from "coral-server/services/redis"; - -import { calculateCountsDiff } from "./counts"; - -export type Moderate = Omit; - -const moderate = ( - status: GQLCOMMENT_STATUS.APPROVED | GQLCOMMENT_STATUS.REJECTED -) => async ( - mongo: Db, - redis: AugmentedRedis, - publish: Publisher, - tenant: Tenant, - input: Moderate -) => { - // TODO: wrap these operations in a transaction? - - // Create the logger. - const log = logger.child( - { - ...input, - tenantID: tenant.id, - newStatus: status, - }, - true - ); - - // Update the Comment's status. - const result = await updateCommentStatus( - mongo, - tenant.id, - input.commentID, - input.commentRevisionID, - status - ); - if (!result) { - throw new CommentNotFoundError(input.commentID, input.commentRevisionID); - } - - log.trace("updated comment status"); - - // Create the moderation action in the audit log. - const action = await createCommentModerationAction(mongo, tenant.id, { - ...input, - status, - }); - if (!action) { - // TODO: wrap in better error? - throw new Error("could not create moderation action"); - } - - log.trace( - { commentModerationActionID: action.id }, - "created the moderation action" - ); - - // Compute the queue difference as a result of the old status and the new - // status. - const moderationQueue = calculateCountsDiff( - { - status: result.oldStatus, - actionCounts: result.comment.actionCounts, - }, - { - status, - actionCounts: result.comment.actionCounts, - } - ); - - // Update the story comment counts. - const story = await updateStoryCounts( - mongo, - redis, - tenant.id, - result.comment.storyID, - { - // Update the comment counts. - status: { - [result.oldStatus]: -1, - [status]: 1, - }, - - moderationQueue, - } - ); - if (!story) { - throw new StoryNotFoundError(result.comment.storyID); - } - - // Publish changes. - publishModerationQueueChanges(publish, moderationQueue, result.comment); - publishCommentStatusChanges( - publish, - result.oldStatus, - status, - result.comment.id, - input.moderatorID - ); - if ( - [GQLCOMMENT_STATUS.PREMOD, GQLCOMMENT_STATUS.SYSTEM_WITHHELD].includes( - result.oldStatus - ) && - status === "APPROVED" - ) { - publishCommentReleased(publish, result.comment); - } - - log.trace({ oldStatus: result.oldStatus }, "adjusted story comment counts"); - - return result.comment; -}; - -export const approve = moderate(GQLCOMMENT_STATUS.APPROVED); - -export const reject = moderate(GQLCOMMENT_STATUS.REJECTED); +export * from "./counts"; +export * from "./moderation"; diff --git a/src/core/server/services/comments/moderation/index.spec.ts b/src/core/server/services/comments/moderation/moderation.spec.ts similarity index 100% rename from src/core/server/services/comments/moderation/index.spec.ts rename to src/core/server/services/comments/moderation/moderation.spec.ts diff --git a/src/core/server/services/comments/moderation/moderation.ts b/src/core/server/services/comments/moderation/moderation.ts new file mode 100644 index 000000000..7922689a0 --- /dev/null +++ b/src/core/server/services/comments/moderation/moderation.ts @@ -0,0 +1,134 @@ +import { Db } from "mongodb"; + +import { Omit } from "coral-common/types"; +import { CommentNotFoundError, StoryNotFoundError } from "coral-server/errors"; +import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types"; +import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; +import logger from "coral-server/logger"; +import { + createCommentModerationAction, + CreateCommentModerationActionInput, +} from "coral-server/models/action/moderation/comment"; +import { updateCommentStatus } from "coral-server/models/comment"; +import { updateStoryCounts } from "coral-server/models/story"; +import { Tenant } from "coral-server/models/tenant"; +import { + publishCommentReleased, + publishCommentStatusChanges, + publishModerationQueueChanges, +} from "coral-server/services/events"; +import { AugmentedRedis } from "coral-server/services/redis"; + +import { calculateCountsDiff } from "./counts"; + +export type Moderate = Omit; + +const moderate = ( + status: GQLCOMMENT_STATUS.APPROVED | GQLCOMMENT_STATUS.REJECTED +) => async ( + mongo: Db, + redis: AugmentedRedis, + publish: Publisher, + tenant: Tenant, + input: Moderate +) => { + // TODO: wrap these operations in a transaction? + + // Create the logger. + const log = logger.child( + { + ...input, + tenantID: tenant.id, + newStatus: status, + }, + true + ); + + // Update the Comment's status. + const result = await updateCommentStatus( + mongo, + tenant.id, + input.commentID, + input.commentRevisionID, + status + ); + if (!result) { + throw new CommentNotFoundError(input.commentID, input.commentRevisionID); + } + + log.trace("updated comment status"); + + // Create the moderation action in the audit log. + const action = await createCommentModerationAction(mongo, tenant.id, { + ...input, + status, + }); + if (!action) { + // TODO: wrap in better error? + throw new Error("could not create moderation action"); + } + + log.trace( + { commentModerationActionID: action.id }, + "created the moderation action" + ); + + // Compute the queue difference as a result of the old status and the new + // status. + const moderationQueue = calculateCountsDiff( + { + status: result.oldStatus, + actionCounts: result.comment.actionCounts, + }, + { + status, + actionCounts: result.comment.actionCounts, + } + ); + + // Update the story comment counts. + const story = await updateStoryCounts( + mongo, + redis, + tenant.id, + result.comment.storyID, + { + // Update the comment counts. + status: { + [result.oldStatus]: -1, + [status]: 1, + }, + + moderationQueue, + } + ); + if (!story) { + throw new StoryNotFoundError(result.comment.storyID); + } + + // Publish changes. + publishModerationQueueChanges(publish, moderationQueue, result.comment); + publishCommentStatusChanges( + publish, + result.oldStatus, + status, + result.comment.id, + input.moderatorID + ); + if ( + [GQLCOMMENT_STATUS.PREMOD, GQLCOMMENT_STATUS.SYSTEM_WITHHELD].includes( + result.oldStatus + ) && + status === "APPROVED" + ) { + publishCommentReleased(publish, result.comment); + } + + log.trace({ oldStatus: result.oldStatus }, "adjusted story comment counts"); + + return result.comment; +}; + +export const approve = moderate(GQLCOMMENT_STATUS.APPROVED); + +export const reject = moderate(GQLCOMMENT_STATUS.REJECTED); diff --git a/src/core/server/services/comments/pipeline/index.ts b/src/core/server/services/comments/pipeline/index.ts index af7cce01e..c72658299 100644 --- a/src/core/server/services/comments/pipeline/index.ts +++ b/src/core/server/services/comments/pipeline/index.ts @@ -1,135 +1,3 @@ -import { Db } from "mongodb"; - -import { Omit, Promiseable, RequireProperty } from "coral-common/types"; -import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types"; -import { CreateActionInput } from "coral-server/models/action/comment"; -import { - EditCommentInput, - 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 { Request } from "coral-server/types/express"; - -import { moderationPhases } from "./phases"; - -export type ModerationAction = Omit< - CreateActionInput, - "commentID" | "commentRevisionID" | "storyID" ->; - -export interface PhaseResult { - actions: ModerationAction[]; - status: GQLCOMMENT_STATUS; - metadata: RevisionMetadata; - body: string; - tags: CommentTag[]; -} - -export interface ModerationPhaseContext { - mongo: Db; - story: Story; - tenant: Tenant; - comment: RequireProperty, "body">; - author: User; - now: Date; - nudge?: boolean; - req?: Request; -} - -export type RootModerationPhase = ( - context: ModerationPhaseContext -) => Promiseable; - -export type IntermediatePhaseResult = Partial | void; - -export interface IntermediateModerationPhaseContext - extends ModerationPhaseContext { - metadata: RevisionMetadata; -} - -export type IntermediateModerationPhase = ( - context: IntermediateModerationPhaseContext -) => Promiseable; - -/** - * compose will create a moderation pipeline for which is executable with the - * passed actions. - */ -export const compose = ( - phases: IntermediateModerationPhase[] -): RootModerationPhase => async context => { - const final: PhaseResult = { - status: GQLCOMMENT_STATUS.NONE, - body: context.comment.body, - actions: [], - metadata: context.comment.metadata || {}, - tags: [], - }; - - // Loop over all the moderation phases and see if we've resolved the status. - for (const phase of phases) { - const result = await phase({ - ...context, - comment: { - ...context.comment, - body: final.body, - }, - 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; - } - } - } - - return final; -}; - -/** - * process the comment and return moderation details. - */ -export const processForModeration: RootModerationPhase = compose( - moderationPhases -); +export * from "./pipeline"; +export * from "./wordList"; +export * from "./phases"; diff --git a/src/core/server/services/comments/pipeline/phases/index.ts b/src/core/server/services/comments/pipeline/phases/index.ts index 96e8f958e..9778e8f37 100644 --- a/src/core/server/services/comments/pipeline/phases/index.ts +++ b/src/core/server/services/comments/pipeline/phases/index.ts @@ -12,12 +12,14 @@ import { spam } from "./spam"; import { staff } from "./staff"; import { storyClosed } from "./storyClosed"; import { toxic } from "./toxic"; +import { userRateLimit } from "./userRateLimit"; import { wordList } from "./wordList"; /** * The moderation phases to apply for each comment being processed. */ export const moderationPhases: IntermediateModerationPhase[] = [ + userRateLimit, commentLength, storyClosed, commentingDisabled, diff --git a/src/core/server/services/comments/pipeline/phases/userRateLimit.ts b/src/core/server/services/comments/pipeline/phases/userRateLimit.ts new file mode 100644 index 000000000..366b06983 --- /dev/null +++ b/src/core/server/services/comments/pipeline/phases/userRateLimit.ts @@ -0,0 +1,59 @@ +import { DateTime } from "luxon"; + +import { COMMENT_LIMIT_WINDOW_SECONDS } from "coral-common/constants"; +import { RateLimitExceeded } from "coral-server/errors"; +import { + IntermediateModerationPhase, + IntermediatePhaseResult, + ModerationPhaseContext, +} from "coral-server/services/comments/pipeline"; +import { retrieveUserLastWroteCommentTimestamp } from "coral-server/services/users"; + +export const userRateLimit: IntermediateModerationPhase = async ({ + action, + author, + redis, + config, + tenant, + now, +}: Pick< + ModerationPhaseContext, + "author" | "redis" | "config" | "now" | "tenant" | "action" +>): Promise => { + // If we're in development mode and rate limiters are disabled, then just + // continue anyways now. + if ( + config.get("env") === "development" && + config.get("disable_rate_limiters") + ) { + return; + } + + // If this is an edit, we don't need to process this again here. + if (action === "EDIT") { + return; + } + + // Check when the last comment was written by this user. + const timestamp = await retrieveUserLastWroteCommentTimestamp( + redis, + tenant, + author + ); + if (!timestamp) { + // There is no timestamp written for this user, they are definitely allowed + // to write a comment! + return; + } + + // Check to see if this timestamp is still within the limit window. If it is, + // reject the comment. + const nextEditTime = DateTime.fromJSDate(timestamp) + .plus({ seconds: COMMENT_LIMIT_WINDOW_SECONDS }) + .toJSDate(); + if (nextEditTime > now) { + throw new RateLimitExceeded("createComment", 1); + } + + return; +}; diff --git a/src/core/server/services/comments/pipeline/index.spec.ts b/src/core/server/services/comments/pipeline/pipeline.spec.ts similarity index 96% rename from src/core/server/services/comments/pipeline/index.spec.ts rename to src/core/server/services/comments/pipeline/pipeline.spec.ts index 33b967948..956e68d2a 100644 --- a/src/core/server/services/comments/pipeline/index.spec.ts +++ b/src/core/server/services/comments/pipeline/pipeline.spec.ts @@ -3,10 +3,8 @@ import { GQLCOMMENT_STATUS, } from "coral-server/graph/tenant/schema/__generated__/types"; import { ACTION_TYPE } from "coral-server/models/action/comment"; -import { - compose, - ModerationPhaseContext, -} from "coral-server/services/comments/pipeline"; + +import { compose, ModerationPhaseContext } from "./pipeline"; const context = { comment: { body: "This is a test" }, diff --git a/src/core/server/services/comments/pipeline/pipeline.ts b/src/core/server/services/comments/pipeline/pipeline.ts new file mode 100644 index 000000000..fd1f8e8e8 --- /dev/null +++ b/src/core/server/services/comments/pipeline/pipeline.ts @@ -0,0 +1,140 @@ +import { Db } from "mongodb"; + +import { Omit, Promiseable, RequireProperty } from "coral-common/types"; +import { Config } from "coral-server/config"; +import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types"; +import { CreateActionInput } from "coral-server/models/action/comment"; +import { + EditCommentInput, + 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 { moderationPhases } from "./phases"; + +export type ModerationAction = Omit< + CreateActionInput, + "commentID" | "commentRevisionID" | "storyID" +>; + +export interface PhaseResult { + actions: ModerationAction[]; + status: GQLCOMMENT_STATUS; + metadata: RevisionMetadata; + body: string; + tags: CommentTag[]; +} + +export interface ModerationPhaseContext { + mongo: Db; + redis: AugmentedRedis; + config: Config; + story: Story; + tenant: Tenant; + comment: RequireProperty, "body">; + author: User; + now: Date; + action: "NEW" | "EDIT"; + nudge?: boolean; + req?: Request; +} + +export type RootModerationPhase = ( + context: ModerationPhaseContext +) => Promiseable; + +export type IntermediatePhaseResult = Partial | void; + +export interface IntermediateModerationPhaseContext + extends ModerationPhaseContext { + metadata: RevisionMetadata; +} + +export type IntermediateModerationPhase = ( + context: IntermediateModerationPhaseContext +) => Promiseable; + +/** + * compose will create a moderation pipeline for which is executable with the + * passed actions. + */ +export const compose = ( + phases: IntermediateModerationPhase[] +): RootModerationPhase => async context => { + const final: PhaseResult = { + status: GQLCOMMENT_STATUS.NONE, + body: context.comment.body, + actions: [], + metadata: context.comment.metadata || {}, + tags: [], + }; + + // Loop over all the moderation phases and see if we've resolved the status. + for (const phase of phases) { + const result = await phase({ + ...context, + comment: { + ...context.comment, + body: final.body, + }, + 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; + } + } + } + + return final; +}; + +/** + * process the comment and return moderation details. + */ +export const processForModeration: RootModerationPhase = compose( + moderationPhases +); diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index 93f9eca65..ddf77b462 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -1,1168 +1 @@ -import { DateTime } from "luxon"; -import { Db } from "mongodb"; - -import { - ALLOWED_USERNAME_CHANGE_FREQUENCY, - DOWNLOAD_LIMIT_TIMEFRAME, -} from "coral-common/constants"; -import { SCHEDULED_DELETION_TIMESPAN_DAYS } from "coral-common/constants"; -import { Config } from "coral-server/config"; -import { - DuplicateEmailError, - DuplicateUserError, - EmailAlreadySetError, - EmailNotSetError, - LocalProfileAlreadySetError, - LocalProfileNotSetError, - PasswordIncorrect, - TokenNotFoundError, - UserAlreadyBannedError, - UserAlreadyPremoderated, - UserAlreadySuspendedError, - UserCannotBeIgnoredError, - UsernameAlreadySetError, - UsernameUpdatedWithinWindowError, - UserNotFoundError, -} from "coral-server/errors"; -import { - GQLAuthIntegrations, - GQLUSER_ROLE, -} from "coral-server/graph/tenant/schema/__generated__/types"; -import logger from "coral-server/logger"; -import { Tenant } from "coral-server/models/tenant"; -import { - banUser, - clearDeletionDate, - consolidateUserBanStatus, - consolidateUserPremodStatus, - consolidateUserSuspensionStatus, - createModeratorNote, - createUser, - createUserToken, - deactivateUserToken, - deleteModeratorNote, - findOrCreateUser, - FindOrCreateUserInput, - ignoreUser, - NotificationSettingsInput, - premodUser, - removeActiveUserSuspensions, - removeUserBan, - removeUserIgnore, - removeUserPremod, - retrieveUser, - retrieveUserWithEmail, - scheduleDeletionDate, - setUserEmail, - setUserLastDownloadedAt, - setUserLocalProfile, - setUserUsername, - suspendUser, - updateUserAvatar, - updateUserEmail, - updateUserNotificationSettings, - updateUserPassword, - updateUserRole, - updateUserUsername, - User, - verifyUserPassword, -} from "coral-server/models/user"; -import { - getLocalProfile, - hasLocalProfile, -} from "coral-server/models/user/helpers"; -import { hasStaffRole } from "coral-server/models/user/helpers"; -import { MailerQueue } from "coral-server/queue/tasks/mailer"; -import { sendConfirmationEmail } from "coral-server/services/users/auth"; - -import { JWTSigningConfig, signPATString } from "coral-server/services/jwt"; - -import { - generateAdminDownloadLink, - generateDownloadLink, -} from "./download/token"; -import { validateEmail, validatePassword, validateUsername } from "./helpers"; - -function validateFindOrCreateUserInput(input: FindOrCreateUser) { - if (input.username) { - validateUsername(input.username); - } - - if (input.email) { - validateEmail(input.email); - } - - const localProfile = getLocalProfile({ profiles: [input.profile] }); - if (localProfile) { - validateEmail(localProfile.id); - validatePassword(localProfile.password); - - if (input.email !== localProfile.id) { - throw new Error("email addresses don't match profile"); - } - } -} - -export type FindOrCreateUser = FindOrCreateUserInput; - -export async function findOrCreate( - mongo: Db, - tenant: Tenant, - input: FindOrCreateUser, - now: Date -) { - // Validate the input. - validateFindOrCreateUserInput(input); - - const user = await findOrCreateUser(mongo, tenant.id, input, now); - - // TODO: (wyattjoh) evaluate the tenant to determine if we should send the verification email. - - return user; -} - -export type CreateUser = FindOrCreateUserInput; - -export async function create( - mongo: Db, - tenant: Tenant, - input: CreateUser, - now: Date -) { - // Validate the input. - validateFindOrCreateUserInput(input); - - if (input.id) { - // Try to check to see if there is a user with the same ID before we try to - // create the user again. - const alreadyFoundUser = await retrieveUser(mongo, tenant.id, input.id); - if (alreadyFoundUser) { - throw new DuplicateUserError(); - } - } - - if (input.email) { - // Try to lookup the user to see if this user already has an account if they - // do, we can short circuit the database index hit. - const alreadyFoundUser = await retrieveUserWithEmail( - mongo, - tenant.id, - input.email - ); - if (alreadyFoundUser) { - throw new DuplicateEmailError(input.email); - } - } - - const user = await createUser(mongo, tenant.id, input, now); - - // TODO: (wyattjoh) evaluate the tenant to determine if we should send the verification email. - - return user; -} - -/** - * setUsername will set the username on the User if they don't already have one - * associated with them. - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be interacted with - * @param user User that should get their username changed - * @param username the new username for the User - */ -export async function setUsername( - mongo: Db, - tenant: Tenant, - user: User, - username: string -) { - // We require that the username is not defined in order to use this method. - if (user.username) { - throw new UsernameAlreadySetError(); - } - - validateUsername(username); - - return setUserUsername(mongo, tenant.id, user.id, username); -} - -/** - * setEmail will set the email address on the User if they don't already have - * one associated with them. - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be interacted with - * @param user User that should get their username changed - * @param email the new email for the User - */ -export async function setEmail( - mongo: Db, - mailer: MailerQueue, - tenant: Tenant, - user: User, - email: string -) { - // We requires that the email address is not defined in order to use this - // method. - if (user.email) { - throw new EmailAlreadySetError(); - } - - validateEmail(email); - - const updatedUser = await setUserEmail(mongo, tenant.id, user.id, email); - - // // FIXME: (wyattjoh) evaluate the tenant to determine if we should send the verification email. - // // Send the email confirmation email. - // await sendConfirmationEmail(mailer, tenant, updatedUser, email); - - return updatedUser; -} - -/** - * setPassword will set the password on the User if they don't already have - * one associated with them. This will allow the User to sign in with their - * current email address and new password if email based authentication is - * enabled. If the User does not have a email address associated with their - * account, this will fail. - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be interacted with - * @param user User that should get their password changed - * @param password the new password for the User - */ -export async function setPassword( - mongo: Db, - tenant: Tenant, - user: User, - password: string -) { - // We require that the email address for the user be defined for this method. - if (!user.email) { - throw new EmailNotSetError(); - } - - // We also don't allow this method to be used by users that already have a - // local profile. - if (hasLocalProfile(user)) { - throw new LocalProfileAlreadySetError(); - } - - validatePassword(password); - - return setUserLocalProfile(mongo, tenant.id, user.id, user.email, password); -} - -/** - * updatePassword will update the password associated with the User. If the User - * does not already have a password associated with their account, it will fail. - * If the User does not have an email address associated with the account, this - * will fail. - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be interacted with - * @param user User that should get their password changed - * @param password the new password for the User - */ -export async function updatePassword( - mongo: Db, - mailer: MailerQueue, - tenant: Tenant, - user: User, - oldPassword: string, - newPassword: string -) { - // Validate that the new password is valid. - validatePassword(newPassword); - - // We require that the email address for the user be defined for this method. - if (!user.email) { - throw new EmailNotSetError(); - } - - // We also don't allow this method to be used by users that don't have a local - // profile already. - const profile = getLocalProfile(user, user.email); - if (!profile) { - throw new LocalProfileNotSetError(); - } - - // Verify that the old password is correct. We'll be using the profile's - // passwordID to ensure we prevent a race. - const passwordVerified = await verifyUserPassword( - user, - oldPassword, - user.email - ); - if (!passwordVerified) { - // We throw a PasswordIncorrect error here instead of an - // InvalidCredentialsError because the current user is already signed in. - throw new PasswordIncorrect(); - } - - const updatedUser = await updateUserPassword( - mongo, - tenant.id, - user.id, - newPassword, - profile.passwordID - ); - - // If the user has an email address associated with their account, send them - // a ban notification email. - if (updatedUser.email) { - // Send the ban user email. - await mailer.add({ - tenantID: tenant.id, - message: { - to: updatedUser.email, - }, - template: { - name: "account-notification/password-change", - context: { - // TODO: (wyattjoh) possibly reevaluate the use of a required username. - username: updatedUser.username!, - organizationName: tenant.organization.name, - organizationURL: tenant.organization.url, - organizationContactEmail: tenant.organization.contactEmail, - }, - }, - }); - } - - return user; -} - -export async function requestAccountDeletion( - mongo: Db, - mailer: MailerQueue, - tenant: Tenant, - user: User, - password: string, - now: Date -) { - if (!user.email) { - throw new EmailNotSetError(); - } - - const passwordVerified = await verifyUserPassword(user, password, user.email); - if (!passwordVerified) { - // We throw a PasswordIncorrect error here instead of an - // InvalidCredentialsError because the current user is already signed in. - throw new PasswordIncorrect(); - } - - const deletionDate = DateTime.fromJSDate(now).plus({ - days: SCHEDULED_DELETION_TIMESPAN_DAYS, - }); - - const updatedUser = await scheduleDeletionDate( - mongo, - tenant.id!, - user.id, - deletionDate.toJSDate() - ); - - // TODO: extract out into a common shared formatter - // this is being duplicated everywhere - const formattedDate = Intl.DateTimeFormat(tenant.locale, { - year: "numeric", - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - second: "numeric", - }).format(deletionDate.toJSDate()); - - await mailer.add({ - tenantID: tenant.id, - message: { - to: user.email, - }, - template: { - name: "account-notification/delete-request-confirmation", - context: { - requestDate: formattedDate, - organizationName: tenant.organization.name, - organizationURL: tenant.organization.url, - }, - }, - }); - - return updatedUser; -} - -export async function cancelAccountDeletion( - mongo: Db, - mailer: MailerQueue, - tenant: Tenant, - user: User -) { - if (!user.email) { - throw new EmailNotSetError(); - } - - const updatedUser = await clearDeletionDate(mongo, tenant.id, user.id); - - await mailer.add({ - tenantID: tenant.id, - message: { - to: user.email, - }, - template: { - name: "account-notification/delete-request-cancel", - context: { - organizationName: tenant.organization.name, - organizationURL: tenant.organization.url, - }, - }, - }); - - return updatedUser; -} - -/** - * createToken will create a Token for the User as well as return a signed Token - * that can be used to authenticate. - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be interacted with - * @param config signing configuration to create the signed token - * @param user User that should get updated - * @param name name of the Token - */ -export async function createToken( - mongo: Db, - tenant: Tenant, - config: JWTSigningConfig, - user: User, - name: string, - now = new Date() -) { - // Create the token for the User! - const result = await createUserToken(mongo, tenant.id, user.id, name, now); - - // Sign the token! - const signedToken = await signPATString( - config, - user, - { - // Tokens are issued with the token ID as their JWT ID. - jwtid: result.token.id, - - // Tokens are issued with the tenant ID. - issuer: tenant.id, - - // Tokens are not valid before the creation date. - notBefore: 0, - }, - now - ); - - return { ...result, signedToken }; -} - -/** - * deactivateToken will disable the given Token so that it can not be used to - * authenticate any more. - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be interacted with - * @param config signing configuration to create the signed token - * @param user User that should get updated - * @param id of the Token to be deactivated - */ -export async function deactivateToken( - mongo: Db, - tenant: Tenant, - user: User, - id: string -) { - if (!user.tokens.find(t => t.id === id)) { - throw new TokenNotFoundError(); - } - - return deactivateUserToken(mongo, tenant.id, user.id, id); -} - -/** - * updateUsername will update the current users username. - * - * @param mongo mongo database to interact with - * @param mailer mailer queue instance - * @param tenant Tenant where the User will be interacted with - * @param user the User we are updating - * @param username the username that we are setting on the User - */ -export async function updateUsername( - mongo: Db, - mailer: MailerQueue, - tenant: Tenant, - user: User, - username: string, - now: Date -) { - // Validate the username. - validateUsername(username); - - const canUpdate = canUpdateLocalProfile(tenant, user); - if (!canUpdate) { - throw new Error("Cannot update profile due to tenant settings"); - } - - // Get the earliest date that the username could have been edited before to/ - // allow it now. - const lastUsernameEditAllowed = DateTime.fromJSDate(now) - .plus({ seconds: -ALLOWED_USERNAME_CHANGE_FREQUENCY }) - .toJSDate(); - - const { history } = user.status.username; - if (history.length > 1) { - // If the last update was made at a date sooner than the earliest edited - // date, then we know that the last edit was conducted within the time-frame - // already. - const lastUpdate = history[history.length - 1]; - if (lastUpdate.createdAt > lastUsernameEditAllowed) { - throw new UsernameUpdatedWithinWindowError(lastUpdate.createdAt); - } - } - - const updated = await updateUserUsername( - mongo, - tenant.id, - user.id, - username, - user.id - ); - - if (user.email) { - await mailer.add({ - tenantID: tenant.id, - message: { - to: user.email, - }, - template: { - name: "account-notification/update-username", - context: { - username: user.username!, - organizationName: tenant.organization.name, - organizationURL: tenant.organization.url, - organizationContactEmail: tenant.organization.contactEmail, - }, - }, - }); - } else { - logger.warn( - { id: user.id }, - "Failed to send email: user does not have email address" - ); - } - - return updated; -} - -/** - * updateUsernameByID will update a given User's username. - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be interacted with - * @param userID the User's ID that we are updating - * @param username the username that we are setting on the User - */ -export async function updateUsernameByID( - mongo: Db, - tenant: Tenant, - userID: string, - username: string, - createdBy: User -) { - // Validate the username. - validateUsername(username); - - return updateUserUsername(mongo, tenant.id, userID, username, createdBy.id); -} - -/** - * updateRole will update the given User to the specified role. - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be interacted with - * @param userID the User's ID that we are updating - * @param role the role that we are setting on the User - */ -export async function updateRole( - mongo: Db, - tenant: Tenant, - user: Pick, - userID: string, - role: GQLUSER_ROLE -) { - if (user.id === userID) { - throw new Error("cannot update your own user role"); - } - - return updateUserRole(mongo, tenant.id, userID, role); -} - -/** - * enabledAuthenticationIntegrations returns enabled auth integrations for a tenant - * @param tenant Tenant where the User will be interacted with - * @param target whether to filter by stream or admin enabled. defaults to requiring both. - */ -function enabledAuthenticationIntegrations( - tenant: Tenant, - target?: "stream" | "admin" -): string[] { - return Object.keys(tenant.auth.integrations).filter((key: string) => { - const { enabled, targetFilter } = tenant.auth.integrations[ - key as keyof GQLAuthIntegrations - ]; - if (target) { - return enabled && targetFilter[target]; - } - return enabled && targetFilter.admin && targetFilter.stream; - }); -} - -/** - * canUpdateLocalProfile will determine if a user is permitted to update their email address. - * @param tenant Tenant where the User will be interacted with - * @param user the User that we are updating - */ -function canUpdateLocalProfile(tenant: Tenant, user: User): boolean { - if (!tenant.accountFeatures.changeUsername) { - return false; - } - - if (!hasLocalProfile(user)) { - return false; - } - - const streamAuthTypes = enabledAuthenticationIntegrations(tenant, "stream"); - - // user can update email if local auth is enabled or any integration other than sso is enabled - return ( - streamAuthTypes.includes("local") || - !(streamAuthTypes.length === 1 && streamAuthTypes[0] === "sso") - ); -} - -/** - * updateEmail will update the current User's email address. - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be interacted with - * @param mailer The mailer queue - * @param config Convict config - * @param user the User that we are updating - * @param email the email address that we are setting on the User - * @param password the users password for confirmation - */ -export async function updateEmail( - mongo: Db, - tenant: Tenant, - mailer: MailerQueue, - config: Config, - signingConfig: JWTSigningConfig, - user: User, - emailAddress: string, - password: string, - now = new Date() -) { - const email = emailAddress.toLowerCase(); - validateEmail(email); - - const canUpdate = canUpdateLocalProfile(tenant, user); - if (!canUpdate) { - throw new Error("Cannot update profile due to tenant settings"); - } - - const passwordVerified = await verifyUserPassword(user, password); - if (!passwordVerified) { - // We throw a PasswordIncorrect error here instead of an - // InvalidCredentialsError because the current user is already signed in. - throw new PasswordIncorrect(); - } - - const updated = await updateUserEmail(mongo, tenant.id, user.id, email); - - await sendConfirmationEmail( - mongo, - mailer, - tenant, - config, - signingConfig, - updated as Required, - now - ); - return updated; -} - -/** - * updateUserEmail will update the given User's email address. This should not - * trigger and email notifications as it's designed to be used by administrators - * to update a user's email address. - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be interacted with - * @param userID the User's ID that we are updating - * @param email the email address that we are setting on the User - */ -export async function updateEmailByID( - mongo: Db, - tenant: Tenant, - userID: string, - email: string -) { - // Validate the email address. - validateEmail(email); - - return updateUserEmail(mongo, tenant.id, userID, email, true); -} - -/** - * updateAvatar will update the given User's avatar. - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be interacted with - * @param userID the User's ID that we are updating - * @param avatar the avatar that we are setting on the User - */ -export async function updateAvatar( - mongo: Db, - tenant: Tenant, - userID: string, - avatar?: string -) { - return updateUserAvatar(mongo, tenant.id, userID, avatar); -} - -/** - * addModeratorNote will add a note to the users account. - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be banned on - * @param moderator the Moderator that is creating the note - * @param userID the ID of the User who is the subject of the note - * @param note the contents of the note - * @param now the current time that the note was created - */ -export async function addModeratorNote( - mongo: Db, - tenant: Tenant, - moderator: User, - userID: string, - note: string, - now = new Date() -) { - if (!note || note.length < 1) { - throw new Error("Note cannot be empty"); - } - - return createModeratorNote(mongo, tenant.id, userID, moderator.id, note, now); -} - -/** - * destroyModeratorNote will remove a note from a user - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be banned on - * @param userID id of the user who is the subjet - * @param id id of the note to delete - */ - -export async function destroyModeratorNote( - mongo: Db, - tenant: Tenant, - userID: string, - id: string, - createdBy: User -) { - return deleteModeratorNote(mongo, tenant.id, userID, id, createdBy.id); -} - -/** - * ban will ban a specific user from interacting with Coral. - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be banned on - * @param user the User that is banning the User - * @param userID the ID of the User being banned - * @param message message to banned user - * @param now the current time that the ban took effect - */ -export async function ban( - mongo: Db, - mailer: MailerQueue, - tenant: Tenant, - banner: User, - userID: string, - message: string, - now = new Date() -) { - // Get the user being banned to check to see if the user already has an - // existing ban. - const targetUser = await retrieveUser(mongo, tenant.id, userID); - if (!targetUser) { - throw new UserNotFoundError(userID); - } - - // Check to see if the User is currently banned. - const banStatus = consolidateUserBanStatus(targetUser.status.ban); - if (banStatus.active) { - throw new UserAlreadyBannedError(); - } - - // Ban the user. - const user = await banUser(mongo, tenant.id, userID, banner.id, message, now); - - // If the user has an email address associated with their account, send them - // a ban notification email. - if (user.email) { - // Send the ban user email. - await mailer.add({ - tenantID: tenant.id, - message: { - to: user.email, - }, - template: { - name: "account-notification/ban", - context: { - // TODO: (wyattjoh) possibly reevaluate the use of a required username. - username: user.username!, - organizationName: tenant.organization.name, - organizationURL: tenant.organization.url, - organizationContactEmail: tenant.organization.contactEmail, - customMessage: (message || "").replace(/\n/g, "
"), - }, - }, - }); - } - - return user; -} - -/** - * premod will premod a specific user. - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be banned on - * @param moderator the User that is banning the User - * @param userID the ID of the User being banned - * @param now the current time that the ban took effect - */ -export async function premod( - mongo: Db, - tenant: Tenant, - moderator: User, - userID: string, - now = new Date() -) { - // Get the user being banned to check to see if the user already has an - // existing ban. - const targetUser = await retrieveUser(mongo, tenant.id, userID); - if (!targetUser) { - throw new UserNotFoundError(userID); - } - - // Check to see if the User is currently banned. - const premodStatus = consolidateUserPremodStatus(targetUser.status.premod); - if (premodStatus.active) { - throw new UserAlreadyPremoderated(); - } - - // Ban the user. - return premodUser(mongo, tenant.id, userID, moderator.id, now); -} - -export async function removePremod( - mongo: Db, - tenant: Tenant, - moderator: User, - userID: string, - now = new Date() -) { - // Get the user being suspended to check to see if the user already has an - // existing suspension. - const targetUser = await retrieveUser(mongo, tenant.id, userID); - if (!targetUser) { - throw new UserNotFoundError(userID); - } - - // Check to see if the User is currently suspended. - const premodStatus = consolidateUserPremodStatus(targetUser.status.premod); - if (!premodStatus.active) { - // The user is not premodded currently, just return the user because we - // don't have to do anything. - return targetUser; - } - - // For each of the suspensions, remove it. - return removeUserPremod(mongo, tenant.id, userID, moderator.id, now); -} -/** - * suspend will suspend a give user from interacting with Coral. - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be suspended on - * @param user the User that is suspending the User - * @param userID the ID of the user being suspended - * @param timeout the duration in seconds that the user will suspended for - * @param message message to suspended user - * @param now the current time that the suspension will take effect - */ -export async function suspend( - mongo: Db, - mailer: MailerQueue, - tenant: Tenant, - user: User, - userID: string, - timeout: number, - message: string, - now = new Date() -) { - // Convert the timeout to the until time. - const finishDateTime = DateTime.fromJSDate(now).plus({ seconds: timeout }); - - // Get the user being suspended to check to see if the user already has an - // existing suspension. - const targetUser = await retrieveUser(mongo, tenant.id, userID); - if (!targetUser) { - throw new UserNotFoundError(userID); - } - - // Check to see if the User is currently suspended. - const suspended = consolidateUserSuspensionStatus( - targetUser.status.suspension, - now - ); - if (suspended.active && suspended.until) { - throw new UserAlreadySuspendedError(suspended.until); - } - - const updatedUser = await suspendUser( - mongo, - tenant.id, - userID, - user.id, - finishDateTime.toJSDate(), - message, - now - ); - - // If the user has an email address associated with their account, send them - // a suspend notification email. - if (updatedUser.email) { - // Send the suspend user email. - await mailer.add({ - tenantID: tenant.id, - message: { - to: updatedUser.email, - }, - template: { - name: "account-notification/suspend", - context: { - // TODO: (wyattjoh) possibly reevaluate the use of a required username. - username: updatedUser.username!, - until: finishDateTime.toRFC2822(), - organizationName: tenant.organization.name, - organizationURL: tenant.organization.url, - organizationContactEmail: tenant.organization.contactEmail, - customMessage: (message || "").replace(/\n/g, "
"), - }, - }, - }); - } - - return updatedUser; -} - -export async function removeSuspension( - mongo: Db, - tenant: Tenant, - user: User, - userID: string, - now = new Date() -) { - // Get the user being suspended to check to see if the user already has an - // existing suspension. - const targetUser = await retrieveUser(mongo, tenant.id, userID); - if (!targetUser) { - throw new UserNotFoundError(userID); - } - - // Check to see if the User is currently suspended. - const suspended = consolidateUserSuspensionStatus( - targetUser.status.suspension, - now - ); - if (!suspended.active) { - // The user is not suspended currently, just return the user because we - // don't have to do anything. - return targetUser; - } - - // For each of the suspensions, remove it. - return removeActiveUserSuspensions(mongo, tenant.id, userID, user.id, now); -} - -export async function removeBan( - mongo: Db, - tenant: Tenant, - user: User, - userID: string, - now = new Date() -) { - // Get the user being un-banned to check if they are even banned. - const targetUser = await retrieveUser(mongo, tenant.id, userID); - if (!targetUser) { - throw new UserNotFoundError(userID); - } - - // Check to see if the User is currently banned. - const banStatus = consolidateUserBanStatus(targetUser.status.ban); - if (!banStatus.active) { - // The user is not ban currently, just return the user because we don't - // have to do anything. - return targetUser; - } - - return removeUserBan(mongo, tenant.id, userID, user.id, now); -} - -export async function ignore( - mongo: Db, - tenant: Tenant, - user: User, - userID: string, - now = new Date() -) { - // Get the user being ignored to check if they exist. - const targetUser = await retrieveUser(mongo, tenant.id, userID); - if (!targetUser) { - throw new UserNotFoundError(userID); - } - - const userToBeIgnoredIsStaff = hasStaffRole(targetUser); - if (userToBeIgnoredIsStaff) { - throw new UserCannotBeIgnoredError(userID); - } - - // TODO: extract function - if (user.ignoredUsers && user.ignoredUsers.some(u => u.id === userID)) { - // TODO: improve error - throw new Error("user already ignored"); - } - - await ignoreUser(mongo, tenant.id, user.id, userID, now); - - return targetUser; -} - -export async function removeIgnore( - mongo: Db, - tenant: Tenant, - user: User, - userID: string -) { - // Get the user being un-ignored to check if they exist. - const targetUser = await retrieveUser(mongo, tenant.id, userID); - if (!targetUser) { - throw new UserNotFoundError(userID); - } - - if (user.ignoredUsers && user.ignoredUsers.every(u => u.id !== userID)) { - // TODO: improve error - throw new Error("user already not ignored"); - } - - await removeUserIgnore(mongo, tenant.id, user.id, userID); - - return targetUser; -} - -export async function requestCommentsDownload( - mongo: Db, - mailer: MailerQueue, - tenant: Tenant, - config: Config, - signingConfig: JWTSigningConfig, - user: User, - now: Date -) { - if (!tenant.accountFeatures.downloadComments) { - throw new Error("Downloading comments is not enabled"); - } - // Check to see if the user is allowed to download this now. - if ( - user.lastDownloadedAt && - DateTime.fromJSDate(user.lastDownloadedAt) - .plus({ seconds: DOWNLOAD_LIMIT_TIMEFRAME }) - .toSeconds() >= DateTime.fromJSDate(now).toSeconds() - ) { - throw new Error("requested download too early"); - } - - const downloadUrl = await generateDownloadLink( - user.id, - tenant, - config, - signingConfig, - now - ); - - await setUserLastDownloadedAt(mongo, tenant.id, user.id, now); - - if (user.email) { - await mailer.add({ - tenantID: tenant.id, - message: { - to: user.email, - }, - template: { - name: "account-notification/download-comments", - context: { - username: user.username!, - date: Intl.DateTimeFormat(tenant.locale).format(now), - downloadUrl, - organizationName: tenant.organization.name, - organizationURL: tenant.organization.url, - }, - }, - }); - } else { - logger.error( - { userID: user.id }, - "could not send download email because the user does not have an email address" - ); - } - - return user; -} - -export async function requestUserCommentsDownload( - mongo: Db, - tenant: Tenant, - config: Config, - signingConfig: JWTSigningConfig, - userID: string, - now: Date -) { - const downloadUrl = await generateAdminDownloadLink( - userID, - tenant, - config, - signingConfig, - now - ); - - return downloadUrl; -} - -export async function updateNotificationSettings( - mongo: Db, - tenant: Tenant, - user: User, - settings: NotificationSettingsInput -) { - return updateUserNotificationSettings(mongo, tenant.id, user.id, settings); -} +export * from "./users"; diff --git a/src/core/server/services/users/users.ts b/src/core/server/services/users/users.ts new file mode 100644 index 000000000..556af36c2 --- /dev/null +++ b/src/core/server/services/users/users.ts @@ -0,0 +1,1234 @@ +import { DateTime } from "luxon"; +import { Db } from "mongodb"; + +import { + ALLOWED_USERNAME_CHANGE_FREQUENCY, + COMMENT_LIMIT_WINDOW_SECONDS, + DOWNLOAD_LIMIT_TIMEFRAME, +} from "coral-common/constants"; +import { SCHEDULED_DELETION_TIMESPAN_DAYS } from "coral-common/constants"; +import { Config } from "coral-server/config"; +import { + DuplicateEmailError, + DuplicateUserError, + EmailAlreadySetError, + EmailNotSetError, + LocalProfileAlreadySetError, + LocalProfileNotSetError, + PasswordIncorrect, + RateLimitExceeded, + TokenNotFoundError, + UserAlreadyBannedError, + UserAlreadyPremoderated, + UserAlreadySuspendedError, + UserCannotBeIgnoredError, + UsernameAlreadySetError, + UsernameUpdatedWithinWindowError, + UserNotFoundError, +} from "coral-server/errors"; +import { + GQLAuthIntegrations, + GQLUSER_ROLE, +} from "coral-server/graph/tenant/schema/__generated__/types"; +import logger from "coral-server/logger"; +import { Tenant } from "coral-server/models/tenant"; +import { + banUser, + clearDeletionDate, + consolidateUserBanStatus, + consolidateUserPremodStatus, + consolidateUserSuspensionStatus, + createModeratorNote, + createUser, + createUserToken, + deactivateUserToken, + deleteModeratorNote, + findOrCreateUser, + FindOrCreateUserInput, + ignoreUser, + NotificationSettingsInput, + premodUser, + removeActiveUserSuspensions, + removeUserBan, + removeUserIgnore, + removeUserPremod, + retrieveUser, + retrieveUserWithEmail, + scheduleDeletionDate, + setUserEmail, + setUserLastDownloadedAt, + setUserLocalProfile, + setUserUsername, + suspendUser, + updateUserAvatar, + updateUserEmail, + updateUserNotificationSettings, + updateUserPassword, + updateUserRole, + updateUserUsername, + User, + verifyUserPassword, +} from "coral-server/models/user"; +import { + getLocalProfile, + hasLocalProfile, +} from "coral-server/models/user/helpers"; +import { hasStaffRole } from "coral-server/models/user/helpers"; +import { MailerQueue } from "coral-server/queue/tasks/mailer"; +import { sendConfirmationEmail } from "coral-server/services/users/auth"; + +import { JWTSigningConfig, signPATString } from "coral-server/services/jwt"; + +import { AugmentedRedis } from "../redis"; +import { + generateAdminDownloadLink, + generateDownloadLink, +} from "./download/token"; +import { validateEmail, validatePassword, validateUsername } from "./helpers"; + +function validateFindOrCreateUserInput(input: FindOrCreateUser) { + if (input.username) { + validateUsername(input.username); + } + + if (input.email) { + validateEmail(input.email); + } + + const localProfile = getLocalProfile({ profiles: [input.profile] }); + if (localProfile) { + validateEmail(localProfile.id); + validatePassword(localProfile.password); + + if (input.email !== localProfile.id) { + throw new Error("email addresses don't match profile"); + } + } +} + +export type FindOrCreateUser = FindOrCreateUserInput; + +export async function findOrCreate( + mongo: Db, + tenant: Tenant, + input: FindOrCreateUser, + now: Date +) { + // Validate the input. + validateFindOrCreateUserInput(input); + + const user = await findOrCreateUser(mongo, tenant.id, input, now); + + // TODO: (wyattjoh) evaluate the tenant to determine if we should send the verification email. + + return user; +} + +export type CreateUser = FindOrCreateUserInput; + +export async function create( + mongo: Db, + tenant: Tenant, + input: CreateUser, + now: Date +) { + // Validate the input. + validateFindOrCreateUserInput(input); + + if (input.id) { + // Try to check to see if there is a user with the same ID before we try to + // create the user again. + const alreadyFoundUser = await retrieveUser(mongo, tenant.id, input.id); + if (alreadyFoundUser) { + throw new DuplicateUserError(); + } + } + + if (input.email) { + // Try to lookup the user to see if this user already has an account if they + // do, we can short circuit the database index hit. + const alreadyFoundUser = await retrieveUserWithEmail( + mongo, + tenant.id, + input.email + ); + if (alreadyFoundUser) { + throw new DuplicateEmailError(input.email); + } + } + + const user = await createUser(mongo, tenant.id, input, now); + + // TODO: (wyattjoh) evaluate the tenant to determine if we should send the verification email. + + return user; +} + +/** + * setUsername will set the username on the User if they don't already have one + * associated with them. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param user User that should get their username changed + * @param username the new username for the User + */ +export async function setUsername( + mongo: Db, + tenant: Tenant, + user: User, + username: string +) { + // We require that the username is not defined in order to use this method. + if (user.username) { + throw new UsernameAlreadySetError(); + } + + validateUsername(username); + + return setUserUsername(mongo, tenant.id, user.id, username); +} + +/** + * setEmail will set the email address on the User if they don't already have + * one associated with them. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param user User that should get their username changed + * @param email the new email for the User + */ +export async function setEmail( + mongo: Db, + mailer: MailerQueue, + tenant: Tenant, + user: User, + email: string +) { + // We requires that the email address is not defined in order to use this + // method. + if (user.email) { + throw new EmailAlreadySetError(); + } + + validateEmail(email); + + const updatedUser = await setUserEmail(mongo, tenant.id, user.id, email); + + // // FIXME: (wyattjoh) evaluate the tenant to determine if we should send the verification email. + // // Send the email confirmation email. + // await sendConfirmationEmail(mailer, tenant, updatedUser, email); + + return updatedUser; +} + +/** + * setPassword will set the password on the User if they don't already have + * one associated with them. This will allow the User to sign in with their + * current email address and new password if email based authentication is + * enabled. If the User does not have a email address associated with their + * account, this will fail. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param user User that should get their password changed + * @param password the new password for the User + */ +export async function setPassword( + mongo: Db, + tenant: Tenant, + user: User, + password: string +) { + // We require that the email address for the user be defined for this method. + if (!user.email) { + throw new EmailNotSetError(); + } + + // We also don't allow this method to be used by users that already have a + // local profile. + if (hasLocalProfile(user)) { + throw new LocalProfileAlreadySetError(); + } + + validatePassword(password); + + return setUserLocalProfile(mongo, tenant.id, user.id, user.email, password); +} + +/** + * updatePassword will update the password associated with the User. If the User + * does not already have a password associated with their account, it will fail. + * If the User does not have an email address associated with the account, this + * will fail. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param user User that should get their password changed + * @param password the new password for the User + */ +export async function updatePassword( + mongo: Db, + mailer: MailerQueue, + tenant: Tenant, + user: User, + oldPassword: string, + newPassword: string +) { + // Validate that the new password is valid. + validatePassword(newPassword); + + // We require that the email address for the user be defined for this method. + if (!user.email) { + throw new EmailNotSetError(); + } + + // We also don't allow this method to be used by users that don't have a local + // profile already. + const profile = getLocalProfile(user, user.email); + if (!profile) { + throw new LocalProfileNotSetError(); + } + + // Verify that the old password is correct. We'll be using the profile's + // passwordID to ensure we prevent a race. + const passwordVerified = await verifyUserPassword( + user, + oldPassword, + user.email + ); + if (!passwordVerified) { + // We throw a PasswordIncorrect error here instead of an + // InvalidCredentialsError because the current user is already signed in. + throw new PasswordIncorrect(); + } + + const updatedUser = await updateUserPassword( + mongo, + tenant.id, + user.id, + newPassword, + profile.passwordID + ); + + // If the user has an email address associated with their account, send them + // a ban notification email. + if (updatedUser.email) { + // Send the ban user email. + await mailer.add({ + tenantID: tenant.id, + message: { + to: updatedUser.email, + }, + template: { + name: "account-notification/password-change", + context: { + // TODO: (wyattjoh) possibly reevaluate the use of a required username. + username: updatedUser.username!, + organizationName: tenant.organization.name, + organizationURL: tenant.organization.url, + organizationContactEmail: tenant.organization.contactEmail, + }, + }, + }); + } + + return user; +} + +export async function requestAccountDeletion( + mongo: Db, + mailer: MailerQueue, + tenant: Tenant, + user: User, + password: string, + now: Date +) { + if (!user.email) { + throw new EmailNotSetError(); + } + + const passwordVerified = await verifyUserPassword(user, password, user.email); + if (!passwordVerified) { + // We throw a PasswordIncorrect error here instead of an + // InvalidCredentialsError because the current user is already signed in. + throw new PasswordIncorrect(); + } + + const deletionDate = DateTime.fromJSDate(now).plus({ + days: SCHEDULED_DELETION_TIMESPAN_DAYS, + }); + + const updatedUser = await scheduleDeletionDate( + mongo, + tenant.id!, + user.id, + deletionDate.toJSDate() + ); + + // TODO: extract out into a common shared formatter + // this is being duplicated everywhere + const formattedDate = Intl.DateTimeFormat(tenant.locale, { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + }).format(deletionDate.toJSDate()); + + await mailer.add({ + tenantID: tenant.id, + message: { + to: user.email, + }, + template: { + name: "account-notification/delete-request-confirmation", + context: { + requestDate: formattedDate, + organizationName: tenant.organization.name, + organizationURL: tenant.organization.url, + }, + }, + }); + + return updatedUser; +} + +export async function cancelAccountDeletion( + mongo: Db, + mailer: MailerQueue, + tenant: Tenant, + user: User +) { + if (!user.email) { + throw new EmailNotSetError(); + } + + const updatedUser = await clearDeletionDate(mongo, tenant.id, user.id); + + await mailer.add({ + tenantID: tenant.id, + message: { + to: user.email, + }, + template: { + name: "account-notification/delete-request-cancel", + context: { + organizationName: tenant.organization.name, + organizationURL: tenant.organization.url, + }, + }, + }); + + return updatedUser; +} + +/** + * createToken will create a Token for the User as well as return a signed Token + * that can be used to authenticate. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param config signing configuration to create the signed token + * @param user User that should get updated + * @param name name of the Token + */ +export async function createToken( + mongo: Db, + tenant: Tenant, + config: JWTSigningConfig, + user: User, + name: string, + now = new Date() +) { + // Create the token for the User! + const result = await createUserToken(mongo, tenant.id, user.id, name, now); + + // Sign the token! + const signedToken = await signPATString( + config, + user, + { + // Tokens are issued with the token ID as their JWT ID. + jwtid: result.token.id, + + // Tokens are issued with the tenant ID. + issuer: tenant.id, + + // Tokens are not valid before the creation date. + notBefore: 0, + }, + now + ); + + return { ...result, signedToken }; +} + +/** + * deactivateToken will disable the given Token so that it can not be used to + * authenticate any more. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param config signing configuration to create the signed token + * @param user User that should get updated + * @param id of the Token to be deactivated + */ +export async function deactivateToken( + mongo: Db, + tenant: Tenant, + user: User, + id: string +) { + if (!user.tokens.find(t => t.id === id)) { + throw new TokenNotFoundError(); + } + + return deactivateUserToken(mongo, tenant.id, user.id, id); +} + +/** + * updateUsername will update the current users username. + * + * @param mongo mongo database to interact with + * @param mailer mailer queue instance + * @param tenant Tenant where the User will be interacted with + * @param user the User we are updating + * @param username the username that we are setting on the User + */ +export async function updateUsername( + mongo: Db, + mailer: MailerQueue, + tenant: Tenant, + user: User, + username: string, + now: Date +) { + // Validate the username. + validateUsername(username); + + const canUpdate = canUpdateLocalProfile(tenant, user); + if (!canUpdate) { + throw new Error("Cannot update profile due to tenant settings"); + } + + // Get the earliest date that the username could have been edited before to/ + // allow it now. + const lastUsernameEditAllowed = DateTime.fromJSDate(now) + .plus({ seconds: -ALLOWED_USERNAME_CHANGE_FREQUENCY }) + .toJSDate(); + + const { history } = user.status.username; + if (history.length > 1) { + // If the last update was made at a date sooner than the earliest edited + // date, then we know that the last edit was conducted within the time-frame + // already. + const lastUpdate = history[history.length - 1]; + if (lastUpdate.createdAt > lastUsernameEditAllowed) { + throw new UsernameUpdatedWithinWindowError(lastUpdate.createdAt); + } + } + + const updated = await updateUserUsername( + mongo, + tenant.id, + user.id, + username, + user.id + ); + + if (user.email) { + await mailer.add({ + tenantID: tenant.id, + message: { + to: user.email, + }, + template: { + name: "account-notification/update-username", + context: { + username: user.username!, + organizationName: tenant.organization.name, + organizationURL: tenant.organization.url, + organizationContactEmail: tenant.organization.contactEmail, + }, + }, + }); + } else { + logger.warn( + { id: user.id }, + "Failed to send email: user does not have email address" + ); + } + + return updated; +} + +/** + * updateUsernameByID will update a given User's username. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param userID the User's ID that we are updating + * @param username the username that we are setting on the User + */ +export async function updateUsernameByID( + mongo: Db, + tenant: Tenant, + userID: string, + username: string, + createdBy: User +) { + // Validate the username. + validateUsername(username); + + return updateUserUsername(mongo, tenant.id, userID, username, createdBy.id); +} + +/** + * updateRole will update the given User to the specified role. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param userID the User's ID that we are updating + * @param role the role that we are setting on the User + */ +export async function updateRole( + mongo: Db, + tenant: Tenant, + user: Pick, + userID: string, + role: GQLUSER_ROLE +) { + if (user.id === userID) { + throw new Error("cannot update your own user role"); + } + + return updateUserRole(mongo, tenant.id, userID, role); +} + +/** + * enabledAuthenticationIntegrations returns enabled auth integrations for a tenant + * @param tenant Tenant where the User will be interacted with + * @param target whether to filter by stream or admin enabled. defaults to requiring both. + */ +function enabledAuthenticationIntegrations( + tenant: Tenant, + target?: "stream" | "admin" +): string[] { + return Object.keys(tenant.auth.integrations).filter((key: string) => { + const { enabled, targetFilter } = tenant.auth.integrations[ + key as keyof GQLAuthIntegrations + ]; + if (target) { + return enabled && targetFilter[target]; + } + return enabled && targetFilter.admin && targetFilter.stream; + }); +} + +/** + * canUpdateLocalProfile will determine if a user is permitted to update their email address. + * @param tenant Tenant where the User will be interacted with + * @param user the User that we are updating + */ +function canUpdateLocalProfile(tenant: Tenant, user: User): boolean { + if (!tenant.accountFeatures.changeUsername) { + return false; + } + + if (!hasLocalProfile(user)) { + return false; + } + + const streamAuthTypes = enabledAuthenticationIntegrations(tenant, "stream"); + + // user can update email if local auth is enabled or any integration other than sso is enabled + return ( + streamAuthTypes.includes("local") || + !(streamAuthTypes.length === 1 && streamAuthTypes[0] === "sso") + ); +} + +/** + * updateEmail will update the current User's email address. + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param mailer The mailer queue + * @param config Convict config + * @param user the User that we are updating + * @param email the email address that we are setting on the User + * @param password the users password for confirmation + */ +export async function updateEmail( + mongo: Db, + tenant: Tenant, + mailer: MailerQueue, + config: Config, + signingConfig: JWTSigningConfig, + user: User, + emailAddress: string, + password: string, + now = new Date() +) { + const email = emailAddress.toLowerCase(); + validateEmail(email); + + const canUpdate = canUpdateLocalProfile(tenant, user); + if (!canUpdate) { + throw new Error("Cannot update profile due to tenant settings"); + } + + const passwordVerified = await verifyUserPassword(user, password); + if (!passwordVerified) { + // We throw a PasswordIncorrect error here instead of an + // InvalidCredentialsError because the current user is already signed in. + throw new PasswordIncorrect(); + } + + const updated = await updateUserEmail(mongo, tenant.id, user.id, email); + + await sendConfirmationEmail( + mongo, + mailer, + tenant, + config, + signingConfig, + updated as Required, + now + ); + return updated; +} + +/** + * updateUserEmail will update the given User's email address. This should not + * trigger and email notifications as it's designed to be used by administrators + * to update a user's email address. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param userID the User's ID that we are updating + * @param email the email address that we are setting on the User + */ +export async function updateEmailByID( + mongo: Db, + tenant: Tenant, + userID: string, + email: string +) { + // Validate the email address. + validateEmail(email); + + return updateUserEmail(mongo, tenant.id, userID, email, true); +} + +/** + * updateAvatar will update the given User's avatar. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param userID the User's ID that we are updating + * @param avatar the avatar that we are setting on the User + */ +export async function updateAvatar( + mongo: Db, + tenant: Tenant, + userID: string, + avatar?: string +) { + return updateUserAvatar(mongo, tenant.id, userID, avatar); +} + +/** + * addModeratorNote will add a note to the users account. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be banned on + * @param moderator the Moderator that is creating the note + * @param userID the ID of the User who is the subject of the note + * @param note the contents of the note + * @param now the current time that the note was created + */ +export async function addModeratorNote( + mongo: Db, + tenant: Tenant, + moderator: User, + userID: string, + note: string, + now = new Date() +) { + if (!note || note.length < 1) { + throw new Error("Note cannot be empty"); + } + + return createModeratorNote(mongo, tenant.id, userID, moderator.id, note, now); +} + +/** + * destroyModeratorNote will remove a note from a user + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be banned on + * @param userID id of the user who is the subjet + * @param id id of the note to delete + */ + +export async function destroyModeratorNote( + mongo: Db, + tenant: Tenant, + userID: string, + id: string, + createdBy: User +) { + return deleteModeratorNote(mongo, tenant.id, userID, id, createdBy.id); +} + +/** + * ban will ban a specific user from interacting with Coral. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be banned on + * @param user the User that is banning the User + * @param userID the ID of the User being banned + * @param message message to banned user + * @param now the current time that the ban took effect + */ +export async function ban( + mongo: Db, + mailer: MailerQueue, + tenant: Tenant, + banner: User, + userID: string, + message: string, + now = new Date() +) { + // Get the user being banned to check to see if the user already has an + // existing ban. + const targetUser = await retrieveUser(mongo, tenant.id, userID); + if (!targetUser) { + throw new UserNotFoundError(userID); + } + + // Check to see if the User is currently banned. + const banStatus = consolidateUserBanStatus(targetUser.status.ban); + if (banStatus.active) { + throw new UserAlreadyBannedError(); + } + + // Ban the user. + const user = await banUser(mongo, tenant.id, userID, banner.id, message, now); + + // If the user has an email address associated with their account, send them + // a ban notification email. + if (user.email) { + // Send the ban user email. + await mailer.add({ + tenantID: tenant.id, + message: { + to: user.email, + }, + template: { + name: "account-notification/ban", + context: { + // TODO: (wyattjoh) possibly reevaluate the use of a required username. + username: user.username!, + organizationName: tenant.organization.name, + organizationURL: tenant.organization.url, + organizationContactEmail: tenant.organization.contactEmail, + customMessage: (message || "").replace(/\n/g, "
"), + }, + }, + }); + } + + return user; +} + +/** + * premod will premod a specific user. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be banned on + * @param moderator the User that is banning the User + * @param userID the ID of the User being banned + * @param now the current time that the ban took effect + */ +export async function premod( + mongo: Db, + tenant: Tenant, + moderator: User, + userID: string, + now = new Date() +) { + // Get the user being banned to check to see if the user already has an + // existing ban. + const targetUser = await retrieveUser(mongo, tenant.id, userID); + if (!targetUser) { + throw new UserNotFoundError(userID); + } + + // Check to see if the User is currently banned. + const premodStatus = consolidateUserPremodStatus(targetUser.status.premod); + if (premodStatus.active) { + throw new UserAlreadyPremoderated(); + } + + // Ban the user. + return premodUser(mongo, tenant.id, userID, moderator.id, now); +} + +export async function removePremod( + mongo: Db, + tenant: Tenant, + moderator: User, + userID: string, + now = new Date() +) { + // Get the user being suspended to check to see if the user already has an + // existing suspension. + const targetUser = await retrieveUser(mongo, tenant.id, userID); + if (!targetUser) { + throw new UserNotFoundError(userID); + } + + // Check to see if the User is currently suspended. + const premodStatus = consolidateUserPremodStatus(targetUser.status.premod); + if (!premodStatus.active) { + // The user is not premodded currently, just return the user because we + // don't have to do anything. + return targetUser; + } + + // For each of the suspensions, remove it. + return removeUserPremod(mongo, tenant.id, userID, moderator.id, now); +} +/** + * suspend will suspend a give user from interacting with Coral. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be suspended on + * @param user the User that is suspending the User + * @param userID the ID of the user being suspended + * @param timeout the duration in seconds that the user will suspended for + * @param message message to suspended user + * @param now the current time that the suspension will take effect + */ +export async function suspend( + mongo: Db, + mailer: MailerQueue, + tenant: Tenant, + user: User, + userID: string, + timeout: number, + message: string, + now = new Date() +) { + // Convert the timeout to the until time. + const finishDateTime = DateTime.fromJSDate(now).plus({ seconds: timeout }); + + // Get the user being suspended to check to see if the user already has an + // existing suspension. + const targetUser = await retrieveUser(mongo, tenant.id, userID); + if (!targetUser) { + throw new UserNotFoundError(userID); + } + + // Check to see if the User is currently suspended. + const suspended = consolidateUserSuspensionStatus( + targetUser.status.suspension, + now + ); + if (suspended.active && suspended.until) { + throw new UserAlreadySuspendedError(suspended.until); + } + + const updatedUser = await suspendUser( + mongo, + tenant.id, + userID, + user.id, + finishDateTime.toJSDate(), + message, + now + ); + + // If the user has an email address associated with their account, send them + // a suspend notification email. + if (updatedUser.email) { + // Send the suspend user email. + await mailer.add({ + tenantID: tenant.id, + message: { + to: updatedUser.email, + }, + template: { + name: "account-notification/suspend", + context: { + // TODO: (wyattjoh) possibly reevaluate the use of a required username. + username: updatedUser.username!, + until: finishDateTime.toRFC2822(), + organizationName: tenant.organization.name, + organizationURL: tenant.organization.url, + organizationContactEmail: tenant.organization.contactEmail, + customMessage: (message || "").replace(/\n/g, "
"), + }, + }, + }); + } + + return updatedUser; +} + +export async function removeSuspension( + mongo: Db, + tenant: Tenant, + user: User, + userID: string, + now = new Date() +) { + // Get the user being suspended to check to see if the user already has an + // existing suspension. + const targetUser = await retrieveUser(mongo, tenant.id, userID); + if (!targetUser) { + throw new UserNotFoundError(userID); + } + + // Check to see if the User is currently suspended. + const suspended = consolidateUserSuspensionStatus( + targetUser.status.suspension, + now + ); + if (!suspended.active) { + // The user is not suspended currently, just return the user because we + // don't have to do anything. + return targetUser; + } + + // For each of the suspensions, remove it. + return removeActiveUserSuspensions(mongo, tenant.id, userID, user.id, now); +} + +export async function removeBan( + mongo: Db, + tenant: Tenant, + user: User, + userID: string, + now = new Date() +) { + // Get the user being un-banned to check if they are even banned. + const targetUser = await retrieveUser(mongo, tenant.id, userID); + if (!targetUser) { + throw new UserNotFoundError(userID); + } + + // Check to see if the User is currently banned. + const banStatus = consolidateUserBanStatus(targetUser.status.ban); + if (!banStatus.active) { + // The user is not ban currently, just return the user because we don't + // have to do anything. + return targetUser; + } + + return removeUserBan(mongo, tenant.id, userID, user.id, now); +} + +export async function ignore( + mongo: Db, + tenant: Tenant, + user: User, + userID: string, + now = new Date() +) { + // Get the user being ignored to check if they exist. + const targetUser = await retrieveUser(mongo, tenant.id, userID); + if (!targetUser) { + throw new UserNotFoundError(userID); + } + + const userToBeIgnoredIsStaff = hasStaffRole(targetUser); + if (userToBeIgnoredIsStaff) { + throw new UserCannotBeIgnoredError(userID); + } + + // TODO: extract function + if (user.ignoredUsers && user.ignoredUsers.some(u => u.id === userID)) { + // TODO: improve error + throw new Error("user already ignored"); + } + + await ignoreUser(mongo, tenant.id, user.id, userID, now); + + return targetUser; +} + +export async function removeIgnore( + mongo: Db, + tenant: Tenant, + user: User, + userID: string +) { + // Get the user being un-ignored to check if they exist. + const targetUser = await retrieveUser(mongo, tenant.id, userID); + if (!targetUser) { + throw new UserNotFoundError(userID); + } + + if (user.ignoredUsers && user.ignoredUsers.every(u => u.id !== userID)) { + // TODO: improve error + throw new Error("user already not ignored"); + } + + await removeUserIgnore(mongo, tenant.id, user.id, userID); + + return targetUser; +} + +export async function requestCommentsDownload( + mongo: Db, + mailer: MailerQueue, + tenant: Tenant, + config: Config, + signingConfig: JWTSigningConfig, + user: User, + now: Date +) { + if (!tenant.accountFeatures.downloadComments) { + throw new Error("Downloading comments is not enabled"); + } + // Check to see if the user is allowed to download this now. + if ( + user.lastDownloadedAt && + DateTime.fromJSDate(user.lastDownloadedAt) + .plus({ seconds: DOWNLOAD_LIMIT_TIMEFRAME }) + .toSeconds() >= DateTime.fromJSDate(now).toSeconds() + ) { + throw new Error("requested download too early"); + } + + const downloadUrl = await generateDownloadLink( + user.id, + tenant, + config, + signingConfig, + now + ); + + await setUserLastDownloadedAt(mongo, tenant.id, user.id, now); + + if (user.email) { + await mailer.add({ + tenantID: tenant.id, + message: { + to: user.email, + }, + template: { + name: "account-notification/download-comments", + context: { + username: user.username!, + date: Intl.DateTimeFormat(tenant.locale).format(now), + downloadUrl, + organizationName: tenant.organization.name, + organizationURL: tenant.organization.url, + }, + }, + }); + } else { + logger.error( + { userID: user.id }, + "could not send download email because the user does not have an email address" + ); + } + + return user; +} + +export async function requestUserCommentsDownload( + mongo: Db, + tenant: Tenant, + config: Config, + signingConfig: JWTSigningConfig, + userID: string, + now: Date +) { + const downloadUrl = await generateAdminDownloadLink( + userID, + tenant, + config, + signingConfig, + now + ); + + return downloadUrl; +} + +export async function updateNotificationSettings( + mongo: Db, + tenant: Tenant, + user: User, + settings: NotificationSettingsInput +) { + return updateUserNotificationSettings(mongo, tenant.id, user.id, settings); +} + +function userLastWroteCommentTimestampKey( + tenant: Pick, + user: Pick +) { + return `${tenant.id}:lastCommentTimestamp:${user.id}`; +} + +/** + * retrieveUserLastWroteCommentTimestamp will return the timestamp (if set) that + * the user last wrote a comment on. This will return null if the comment was + * written more than COMMENT_LIMIT_WINDOW_SECONDS seconds ago. + * + * @param redis the Redis instance that Coral interacts with + * @param tenant the Tenant to operate on + * @param user the User that we're looking up the limit for + */ +export async function retrieveUserLastWroteCommentTimestamp( + redis: AugmentedRedis, + tenant: Tenant, + user: User +): Promise { + // Try to get the timestamp for the author. + const timestamp: string | null = await redis.get( + userLastWroteCommentTimestampKey(tenant, user) + ); + if (!timestamp) { + return null; + } + + return DateTime.fromISO(timestamp).toJSDate(); +} + +/** + * updateUserLastWroteCommentTimestamp will update the last time that the user + * wrote a comment, and will throw an error if the rate limit was exceeded. If + * this throws an error, it means that the user has written a comment within + * COMMENT_LIMIT_WINDOW_SECONDS seconds, and should be prevented from writing + * another comment. + * + * @param redis the Redis instance that Coral interacts with + * @param tenant the Tenant to operate on + * @param user the User that we're setting the limit for + * @param when the date that the user wrote the comment + */ +export async function updateUserLastWroteCommentTimestamp( + redis: AugmentedRedis, + tenant: Tenant, + user: User, + when: Date +) { + const key = userLastWroteCommentTimestampKey(tenant, user); + + // Try to set the last wrote comment timestamp. + const [[, set]] = await redis + .multi() + .setnx(key, when.toISOString()) + .expire(key, COMMENT_LIMIT_WINDOW_SECONDS) + .exec(); + if (!set) { + throw new RateLimitExceeded("createComment", 1); + } +}