[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
This commit is contained in:
Tessa Thornton
2019-10-16 15:51:34 +00:00
committed by Wyatt Johnson
parent 1f7fdb0d00
commit 9df473178b
18 changed files with 224 additions and 21 deletions
@@ -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,
},
},
},
@@ -32,7 +32,7 @@ const markers: Array<
c =>
(c.status === "PREMOD" && (
<Localized id="moderate-marker-preMod" key={keyCounter++}>
<Marker color="primary">Pre-Mod</Marker>
<Marker color="primary">Pre-mod</Marker>
</Localized>
)) ||
null,
@@ -48,7 +48,7 @@ const markers: Array<
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD && (
<Localized id="moderate-marker-bannedWord" key={keyCounter++}>
<Marker color="error">Banned Word</Marker>
<Marker color="error">Banned word</Marker>
</Localized>
)) ||
null,
@@ -57,7 +57,7 @@ const markers: Array<
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && (
<Localized id="moderate-marker-suspectWord" key={keyCounter++}>
<Marker color="error" variant="filled">
Suspect Word
Suspect word
</Marker>
</Localized>
)) ||
@@ -66,7 +66,7 @@ const markers: Array<
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SPAM && (
<Localized id="moderate-marker-spamDetected" key={keyCounter++}>
<Marker color="error">Spam Detected</Marker>
<Marker color="error">Spam detected</Marker>
</Localized>
)) ||
null,
@@ -78,11 +78,19 @@ const markers: Array<
</Localized>
)) ||
null,
c =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_REPEAT_POST && (
<Localized id="moderate-marker-repeatPost" key={keyCounter++}>
<Marker color="error">Repeat comment</Marker>
</Localized>
)) ||
null,
c =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_RECENT_HISTORY && (
<Localized id="moderate-marker-recentHistory" key={keyCounter++}>
<Marker color="error">Recent History</Marker>
<Marker color="error">Recent history</Marker>
</Localized>
)) ||
null,
@@ -162,6 +170,7 @@ const enhanced = withFragmentContainer<MarkersContainerProps>({
COMMENT_DETECTED_SUSPECT_WORD
COMMENT_REPORTED_OFFENSIVE
COMMENT_REPORTED_SPAM
COMMENT_DETECTED_REPEAT_POST
}
}
}
@@ -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`] = `
<withPropsOnChange(Marker)
color="primary"
>
Pre-Mod
Pre-mod
</withPropsOnChange(Marker)>
</Localized>
<Localized
@@ -72,7 +73,7 @@ exports[`renders all markers 1`] = `
<withPropsOnChange(Marker)
color="error"
>
Banned Word
Banned word
</withPropsOnChange(Marker)>
</Localized>
<Localized
@@ -82,7 +83,7 @@ exports[`renders all markers 1`] = `
color="error"
variant="filled"
>
Suspect Word
Suspect word
</withPropsOnChange(Marker)>
</Localized>
<Localized
@@ -91,7 +92,7 @@ exports[`renders all markers 1`] = `
<withPropsOnChange(Marker)
color="error"
>
Spam Detected
Spam detected
</withPropsOnChange(Marker)>
</Localized>
<Localized
@@ -103,13 +104,22 @@ exports[`renders all markers 1`] = `
Toxic
</withPropsOnChange(Marker)>
</Localized>
<Localized
id="moderate-marker-repeatPost"
>
<withPropsOnChange(Marker)
color="error"
>
Repeat comment
</withPropsOnChange(Marker)>
</Localized>
<Localized
id="moderate-marker-recentHistory"
>
<withPropsOnChange(Marker)
color="error"
>
Recent History
Recent history
</withPropsOnChange(Marker)>
</Localized>
<withPropsOnChange(Marker)
@@ -161,6 +171,7 @@ exports[`renders some markers 1`] = `
"COMMENT_DETECTED_BANNED_WORD": 1,
"COMMENT_DETECTED_LINKS": 0,
"COMMENT_DETECTED_RECENT_HISTORY": 1,
"COMMENT_DETECTED_REPEAT_POST": 0,
"COMMENT_DETECTED_SPAM": 0,
"COMMENT_DETECTED_SUSPECT_WORD": 0,
"COMMENT_DETECTED_TOXIC": 1,
@@ -199,7 +210,7 @@ exports[`renders some markers 1`] = `
<withPropsOnChange(Marker)
color="primary"
>
Pre-Mod
Pre-mod
</withPropsOnChange(Marker)>
</Localized>
<Localized
@@ -208,7 +219,7 @@ exports[`renders some markers 1`] = `
<withPropsOnChange(Marker)
color="error"
>
Banned Word
Banned word
</withPropsOnChange(Marker)>
</Localized>
<Localized
@@ -226,7 +237,7 @@ exports[`renders some markers 1`] = `
<withPropsOnChange(Marker)
color="error"
>
Recent History
Recent history
</withPropsOnChange(Marker)>
</Localized>
<withPropsOnChange(Marker)
+1
View File
@@ -526,6 +526,7 @@ export const baseComment = createFixture<GQLComment>({
COMMENT_DETECTED_SUSPECT_WORD: 0,
COMMENT_REPORTED_OFFENSIVE: 0,
COMMENT_REPORTED_SPAM: 0,
COMMENT_DETECTED_REPEAT_POST: 0,
},
},
},
@@ -1550,7 +1550,7 @@ exports[`renders reported queue with comments and load more 1`] = `
<span
className="Marker-root Marker-colorPrimary Marker-variantRegular"
>
Pre-Mod
Pre-mod
</span>
<span
className="Marker-root Marker-colorError Marker-variantRegular"
+2
View File
@@ -100,6 +100,7 @@ export function createComment(author?: GQLUser) {
COMMENT_DETECTED_SUSPECT_WORD: 0,
COMMENT_REPORTED_OFFENSIVE: 0,
COMMENT_REPORTED_SPAM: 0,
COMMENT_DETECTED_REPEAT_POST: 0,
},
},
},
@@ -147,6 +148,7 @@ export function createComment(author?: GQLUser) {
COMMENT_DETECTED_BANNED_WORD: 0,
COMMENT_DETECTED_SUSPECT_WORD: 0,
COMMENT_DETECTED_PREMOD_USER: 0,
COMMENT_DETECTED_REPEAT_POST: 0,
},
},
},
+6
View File
@@ -63,3 +63,9 @@ 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.
*/
export const DEFAULT_SESSION_LENGTH = 7776000;
/**
* COMMENT_REPEAT_POST_TIMESPAN is the length of time in seconds that a previous comment ID is stored for a
* user to prevent them from posting the same comment repeatedly.
*/
export const COMMENT_REPEAT_POST_TIMESPAN = 21600;
+6
View File
@@ -316,4 +316,10 @@ export enum ERROR_CODES {
RAW_QUERY_NOT_AUTHORIZED = "RAW_QUERY_NOT_AUTHORIZED",
USER_ALREADY_PREMOD = "USER_ALREADY_PREMOD",
/**
* REPEAT_POST is returned if a user attempts to post the same comment more than once
* in a row within a given time frame
*/
REPEAT_POST = "REPEAT_POST",
}
+10
View File
@@ -580,6 +580,16 @@ export class SpamCommentError extends CoralError {
}
}
export class RepeatPostCommentError extends CoralError {
constructor() {
super({
code: ERROR_CODES.REPEAT_POST,
type: ERROR_TYPES.MODERATION_NUDGE_ERROR,
status: 400,
});
}
}
export class UserAlreadySuspendedError extends CoralError {
constructor(until: Date) {
super({
+1
View File
@@ -55,4 +55,5 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
PERSISTED_QUERY_NOT_FOUND: "error-persistedQueryNotFound",
RAW_QUERY_NOT_AUTHORIZED: "error-rawQueryNotAuthorized",
USER_ALREADY_PREMOD: "error-userAlreadyPremod",
REPEAT_POST: "error-repeatPost",
};
@@ -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 {
+1
View File
@@ -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.
@@ -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,
@@ -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);
@@ -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,
@@ -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<IntermediatePhaseResult | void> => {
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"
);
}
};
+52
View File
@@ -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<Tenant, "id">,
user: Pick<User, "id">
) {
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<Readonly<Comment> | null> {
const id: string | null = await redis.get(userLastCommentIDKey(tenant, user));
if (!id) {
return null;
}
return retrieveComment(mongo, tenant.id, id);
}
+7 -6
View File
@@ -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