From 9df473178b70387cccc8dcc9a595f7673491a639 Mon Sep 17 00:00:00 2001 From: Tessa Thornton Date: Wed, 16 Oct 2019 11:51:34 -0400 Subject: [PATCH] [CORL-8] prevent repeat comments (#2632) * prevent users from reposting the same comment * fix tests * fix lints * update comments for users service * make updates based on review --- .../ModerateCard/MarkersContainer.spec.tsx | 2 + .../ModerateCard/MarkersContainer.tsx | 19 ++-- .../MarkersContainer.spec.tsx.snap | 27 ++++-- src/core/client/admin/test/fixtures.ts | 1 + .../__snapshots__/regularQueue.spec.tsx.snap | 2 +- src/core/client/test/helpers/fixture.ts | 2 + src/core/common/constants.ts | 6 ++ src/core/common/errors.ts | 6 ++ src/core/server/errors/index.ts | 10 +++ src/core/server/errors/translations.ts | 1 + .../server/graph/tenant/schema/schema.graphql | 7 ++ src/core/server/locales/en-US/errors.ftl | 1 + .../action/__snapshots__/comment.spec.ts.snap | 1 + src/core/server/services/comments/comments.ts | 7 +- .../comments/pipeline/phases/index.ts | 2 + .../comments/pipeline/phases/repeatPost.ts | 86 +++++++++++++++++++ src/core/server/services/users/users.ts | 52 +++++++++++ src/locales/en-US/admin.ftl | 13 +-- 18 files changed, 224 insertions(+), 21 deletions(-) create mode 100644 src/core/server/services/comments/pipeline/phases/repeatPost.ts diff --git a/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx b/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx index 3df1c8d8c..5ce58dfac 100644 --- a/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx +++ b/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx @@ -28,6 +28,7 @@ it("renders all markers", () => { COMMENT_DETECTED_SUSPECT_WORD: 1, COMMENT_REPORTED_OFFENSIVE: 2, COMMENT_REPORTED_SPAM: 3, + COMMENT_DETECTED_REPEAT_POST: 1, }, }, }, @@ -71,6 +72,7 @@ it("renders some markers", () => { COMMENT_DETECTED_SUSPECT_WORD: 0, COMMENT_REPORTED_OFFENSIVE: 2, COMMENT_REPORTED_SPAM: 0, + COMMENT_DETECTED_REPEAT_POST: 0, }, }, }, diff --git a/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx b/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx index 43fdc8c7b..4143b8c0a 100644 --- a/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx +++ b/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx @@ -32,7 +32,7 @@ const markers: Array< c => (c.status === "PREMOD" && ( - Pre-Mod + Pre-mod )) || null, @@ -48,7 +48,7 @@ const markers: Array< (c.revision && c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD && ( - Banned Word + Banned word )) || null, @@ -57,7 +57,7 @@ const markers: Array< c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && ( - Suspect Word + Suspect word )) || @@ -66,7 +66,7 @@ const markers: Array< (c.revision && c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SPAM && ( - Spam Detected + Spam detected )) || null, @@ -78,11 +78,19 @@ const markers: Array< )) || null, + c => + (c.revision && + c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_REPEAT_POST && ( + + Repeat comment + + )) || + null, c => (c.revision && c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_RECENT_HISTORY && ( - Recent History + Recent history )) || null, @@ -162,6 +170,7 @@ const enhanced = withFragmentContainer({ COMMENT_DETECTED_SUSPECT_WORD COMMENT_REPORTED_OFFENSIVE COMMENT_REPORTED_SPAM + COMMENT_DETECTED_REPEAT_POST } } } diff --git a/src/core/client/admin/components/ModerateCard/__snapshots__/MarkersContainer.spec.tsx.snap b/src/core/client/admin/components/ModerateCard/__snapshots__/MarkersContainer.spec.tsx.snap index 53ec9f5c7..7d600f664 100644 --- a/src/core/client/admin/components/ModerateCard/__snapshots__/MarkersContainer.spec.tsx.snap +++ b/src/core/client/admin/components/ModerateCard/__snapshots__/MarkersContainer.spec.tsx.snap @@ -16,6 +16,7 @@ exports[`renders all markers 1`] = ` "COMMENT_DETECTED_BANNED_WORD": 1, "COMMENT_DETECTED_LINKS": 1, "COMMENT_DETECTED_RECENT_HISTORY": 1, + "COMMENT_DETECTED_REPEAT_POST": 1, "COMMENT_DETECTED_SPAM": 1, "COMMENT_DETECTED_SUSPECT_WORD": 1, "COMMENT_DETECTED_TOXIC": 1, @@ -54,7 +55,7 @@ exports[`renders all markers 1`] = ` - Pre-Mod + Pre-mod - Banned Word + Banned word - Suspect Word + Suspect word - Spam Detected + Spam detected + + + Repeat comment + + - Recent History + Recent history - Pre-Mod + Pre-mod - Banned Word + Banned word - Recent History + Recent history ({ COMMENT_DETECTED_SUSPECT_WORD: 0, COMMENT_REPORTED_OFFENSIVE: 0, COMMENT_REPORTED_SPAM: 0, + COMMENT_DETECTED_REPEAT_POST: 0, }, }, }, diff --git a/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap b/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap index e150ac3f1..6c7327d41 100644 --- a/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap +++ b/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap @@ -1550,7 +1550,7 @@ exports[`renders reported queue with comments and load more 1`] = ` - Pre-Mod + Pre-mod = { PERSISTED_QUERY_NOT_FOUND: "error-persistedQueryNotFound", RAW_QUERY_NOT_AUTHORIZED: "error-rawQueryNotAuthorized", USER_ALREADY_PREMOD: "error-userAlreadyPremod", + REPEAT_POST: "error-repeatPost", }; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 464ca2493..554b2bbbc 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -146,6 +146,11 @@ enum COMMENT_FLAG_DETECTED_REASON { COMMENT_DETECTED_PREMOD_USER is used when a Comment author has been tagged as requiring premoderation """ COMMENT_DETECTED_PREMOD_USER + + """ + COMMENT_DETECTED_REPEAT_POST is used when a Comment's text exactly matches a previous recent comment from the same author + """ + COMMENT_DETECTED_REPEAT_POST } """ @@ -163,6 +168,7 @@ enum COMMENT_FLAG_REASON { COMMENT_DETECTED_SUSPECT_WORD COMMENT_DETECTED_RECENT_HISTORY COMMENT_DETECTED_PREMOD_USER + COMMENT_DETECTED_REPEAT_POST } """ @@ -198,6 +204,7 @@ type FlagReasonActionCounts { COMMENT_DETECTED_SUSPECT_WORD: Int! COMMENT_DETECTED_RECENT_HISTORY: Int! COMMENT_DETECTED_PREMOD_USER: Int! + COMMENT_DETECTED_REPEAT_POST: Int! } type Flag { diff --git a/src/core/server/locales/en-US/errors.ftl b/src/core/server/locales/en-US/errors.ftl index baee0b902..ae0e998ff 100644 --- a/src/core/server/locales/en-US/errors.ftl +++ b/src/core/server/locales/en-US/errors.ftl @@ -57,3 +57,4 @@ error-passwordIncorrect = Incorrect password. Please try again. error-usernameAlreadyUpdated = You may only change your username once every { framework-timeago-time }. error-persistedQueryNotFound = The persisted query with ID { $id } was not found. error-rawQueryNotAuthorized = You are not authorized to execute this query. +error-repeatPost = Are you sure? This comment is very similar to your previous comment. diff --git a/src/core/server/models/action/__snapshots__/comment.spec.ts.snap b/src/core/server/models/action/__snapshots__/comment.spec.ts.snap index 7dd9099da..231b48750 100644 --- a/src/core/server/models/action/__snapshots__/comment.spec.ts.snap +++ b/src/core/server/models/action/__snapshots__/comment.spec.ts.snap @@ -21,6 +21,7 @@ Object { "COMMENT_DETECTED_LINKS": 0, "COMMENT_DETECTED_PREMOD_USER": 0, "COMMENT_DETECTED_RECENT_HISTORY": 0, + "COMMENT_DETECTED_REPEAT_POST": 0, "COMMENT_DETECTED_SPAM": 0, "COMMENT_DETECTED_SUSPECT_WORD": 0, "COMMENT_DETECTED_TOXIC": 0, diff --git a/src/core/server/services/comments/comments.ts b/src/core/server/services/comments/comments.ts index e1b212105..8e1abca98 100644 --- a/src/core/server/services/comments/comments.ts +++ b/src/core/server/services/comments/comments.ts @@ -49,7 +49,10 @@ import { import { AugmentedRedis } from "coral-server/services/redis"; import { Request } from "coral-server/types/express"; -import { updateUserLastWroteCommentTimestamp } from "../users"; +import { + updateUserLastCommentID, + updateUserLastWroteCommentTimestamp, +} from "../users"; import { addCommentActions, CreateAction } from "./actions"; import { calculateCounts, calculateCountsDiff } from "./moderation/counts"; import { PhaseResult, processForModeration } from "./pipeline"; @@ -186,6 +189,8 @@ export async function create( now ); + await updateUserLastCommentID(redis, tenant, author, comment.id); + // Pull the revision out. const revision = getLatestRevision(comment); diff --git a/src/core/server/services/comments/pipeline/phases/index.ts b/src/core/server/services/comments/pipeline/phases/index.ts index 9778e8f37..0895cb926 100644 --- a/src/core/server/services/comments/pipeline/phases/index.ts +++ b/src/core/server/services/comments/pipeline/phases/index.ts @@ -8,6 +8,7 @@ import { preModerate } from "./preModerate"; import { premodUser } from "./preModerateUser"; import { purify } from "./purify"; import { recentCommentHistory } from "./recentCommentHistory"; +import { repeatPost } from "./repeatPost"; import { spam } from "./spam"; import { staff } from "./staff"; import { storyClosed } from "./storyClosed"; @@ -25,6 +26,7 @@ export const moderationPhases: IntermediateModerationPhase[] = [ commentingDisabled, linkify, purify, + repeatPost, wordList, staff, toxic, diff --git a/src/core/server/services/comments/pipeline/phases/repeatPost.ts b/src/core/server/services/comments/pipeline/phases/repeatPost.ts new file mode 100644 index 000000000..6bd39ed77 --- /dev/null +++ b/src/core/server/services/comments/pipeline/phases/repeatPost.ts @@ -0,0 +1,86 @@ +import { RepeatPostCommentError } from "coral-server/errors"; +import { + GQLCOMMENT_FLAG_REASON, + GQLCOMMENT_STATUS, +} from "coral-server/graph/tenant/schema/__generated__/types"; +import logger from "coral-server/logger"; +import { ACTION_TYPE } from "coral-server/models/action/comment"; +import { getLatestRevision } from "coral-server/models/comment/helpers"; +import { + IntermediateModerationPhase, + IntermediatePhaseResult, +} from "coral-server/services/comments/pipeline"; +import { retrieveUserLastComment } from "coral-server/services/users"; + +export const repeatPost: IntermediateModerationPhase = async ({ + story, + mongo, + tenant, + comment, + author, + req, + nudge, + redis, +}): Promise => { + const log = logger.child( + { + tenantID: tenant.id, + }, + true + ); + + if (!comment.body) { + return; + } + + try { + log.trace("checking comment for repeat content"); + + const lastComment = await retrieveUserLastComment( + mongo, + redis, + tenant, + author + ); + + if (!lastComment) { + return; + } + + const revision = getLatestRevision(lastComment); + const isRepeatComment = revision.body === comment.body; + + if (isRepeatComment) { + log.trace({ isRepeatComment }, "comment contains repeat content"); + + // Throw an error if we're nudging instead of recording. + if (nudge) { + throw new RepeatPostCommentError(); + } + + return { + status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, + actions: [ + { + userID: null, + actionType: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_REPEAT_POST, + }, + ], + metadata: {}, + }; + } + + log.trace({ isRepeatComment }, "comment is not repeated"); + } catch (err) { + // Rethrow any RepeatPostError. + if (err instanceof RepeatPostCommentError) { + throw err; + } + + log.error( + { err }, + "could not determine if comment contained duplicate content" + ); + } +}; diff --git a/src/core/server/services/users/users.ts b/src/core/server/services/users/users.ts index e48e76f4b..de1173db7 100644 --- a/src/core/server/services/users/users.ts +++ b/src/core/server/services/users/users.ts @@ -4,6 +4,7 @@ import { Db } from "mongodb"; import { ALLOWED_USERNAME_CHANGE_FREQUENCY, COMMENT_LIMIT_WINDOW_SECONDS, + COMMENT_REPEAT_POST_TIMESPAN, DOWNLOAD_LIMIT_TIMEFRAME, } from "coral-common/constants"; import { SCHEDULED_DELETION_TIMESPAN_DAYS } from "coral-common/constants"; @@ -31,6 +32,7 @@ import { GQLUSER_ROLE, } from "coral-server/graph/tenant/schema/__generated__/types"; import logger from "coral-server/logger"; +import { Comment, retrieveComment } from "coral-server/models/comment"; import { Tenant } from "coral-server/models/tenant"; import { banUser, @@ -1190,6 +1192,13 @@ function userLastWroteCommentTimestampKey( return `${tenant.id}:lastCommentTimestamp:${user.id}`; } +function userLastCommentIDKey( + tenant: Pick, + user: Pick +) { + return `${tenant.id}:lastCommentID:${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 @@ -1245,3 +1254,46 @@ export async function updateUserLastWroteCommentTimestamp( throw new RateLimitExceeded("createComment", 1); } } + +/** + * updateUserLastCommentID will update the id of the users most recent 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 commentID the id of the comment + */ +export async function updateUserLastCommentID( + redis: AugmentedRedis, + tenant: Tenant, + user: User, + commentID: string +) { + const key = userLastCommentIDKey(tenant, user); + + await redis.set(key, commentID, "EX", COMMENT_REPEAT_POST_TIMESPAN); +} + +/** + * retrieveUserLastComment will return the id (if set) of the comment that + * the user last wrote. This will return null if the user has not made a comment + * within the CURRENT_REPEAT_POST_TIMESPAN. + * + * @param mongo the db + * @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 retrieveUserLastComment( + mongo: Db, + redis: AugmentedRedis, + tenant: Tenant, + user: User +): Promise | null> { + const id: string | null = await redis.get(userLastCommentIDKey(tenant, user)); + if (!id) { + return null; + } + + return retrieveComment(mongo, tenant.id, id); +} diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 0e1c2a945..5be68f0be 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -387,16 +387,17 @@ moderate-navigation-pending = Pending moderate-navigation-unmoderated = unmoderated moderate-navigation-rejected = rejected -moderate-marker-preMod = Pre-Mod +moderate-marker-preMod = Pre-mod moderate-marker-link = Link -moderate-marker-bannedWord = Banned Word -moderate-marker-suspectWord = Suspect Word +moderate-marker-bannedWord = Banned word +moderate-marker-suspectWord = Suspect word moderate-marker-spam = Spam -moderate-marker-spamDetected = Spam Detected +moderate-marker-spamDetected = Spam detected moderate-marker-toxic = Toxic -moderate-marker-recentHistory = Recent History -moderate-marker-bodyCount = Body Count +moderate-marker-recentHistory = Recent history +moderate-marker-bodyCount = Body count moderate-marker-offensive = Offensive +moderate-marker-repeatPost = Repeat comment moderate-markers-details = Details moderate-flagDetails-offensive = Offensive