mirror of
https://github.com/wassname/talk.git
synced 2026-07-15 11:26:58 +08:00
[next] Comment Moderation Actions (#2068)
* fix: renamed snake case to camel case * fix: changed case for mutators * fix: renamed all snake case to camel case for db * feat: added support for comment revisions + split comment actions * fix: updated tests * feat: implemented CommentModerationAction * fix: fixed case issues * feat: enabled WeakMap for wordList processsing * chore: npm audit
This commit is contained in:
@@ -3,7 +3,6 @@ import { Db } from "mongodb";
|
||||
import { Omit } from "talk-common/types";
|
||||
import { GQLCOMMENT_FLAG_REPORTED_REASON } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import {
|
||||
ACTION_ITEM_TYPE,
|
||||
ACTION_TYPE,
|
||||
CreateActionInput,
|
||||
createActions,
|
||||
@@ -11,8 +10,10 @@ import {
|
||||
invertEncodedActionCounts,
|
||||
removeAction,
|
||||
RemoveActionInput,
|
||||
} from "talk-server/models/action";
|
||||
retrieveUserAction,
|
||||
} from "talk-server/models/action/comment";
|
||||
import {
|
||||
getLatestRevision,
|
||||
retrieveComment,
|
||||
updateCommentActionCounts,
|
||||
} from "talk-server/models/comment";
|
||||
@@ -21,8 +22,8 @@ import { updateStoryActionCounts } from "talk-server/models/story";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { User } from "talk-server/models/user";
|
||||
|
||||
export type CreateAction = Omit<CreateActionInput, "root_item_id"> &
|
||||
Required<Pick<CreateActionInput, "root_item_id">>;
|
||||
export type CreateAction = Omit<CreateActionInput, "storyID"> &
|
||||
Required<Pick<CreateActionInput, "storyID">>;
|
||||
|
||||
export async function addCommentActions(
|
||||
mongo: Db,
|
||||
@@ -43,11 +44,15 @@ export async function addCommentActions(
|
||||
// Compute the action counts.
|
||||
const actionCounts = encodeActionCounts(...upsertedActions);
|
||||
|
||||
// Grab the last revision (the most recent).
|
||||
const revision = getLatestRevision(comment);
|
||||
|
||||
// Update the comment action counts here.
|
||||
const updatedComment = await updateCommentActionCounts(
|
||||
mongo,
|
||||
tenant.id,
|
||||
comment.id,
|
||||
revision.id,
|
||||
actionCounts
|
||||
);
|
||||
|
||||
@@ -55,7 +60,7 @@ export async function addCommentActions(
|
||||
await updateStoryActionCounts(
|
||||
mongo,
|
||||
tenant.id,
|
||||
comment.story_id,
|
||||
comment.storyID,
|
||||
actionCounts
|
||||
);
|
||||
|
||||
@@ -77,17 +82,17 @@ async function addCommentAction(
|
||||
tenant: Tenant,
|
||||
input: CreateActionInput
|
||||
): Promise<Readonly<Comment>> {
|
||||
const comment = await retrieveComment(mongo, tenant.id, input.item_id);
|
||||
const comment = await retrieveComment(mongo, tenant.id, input.commentID);
|
||||
if (!comment) {
|
||||
// TODO: replace to match error returned by the models/comments.ts
|
||||
throw new Error("comment not found");
|
||||
}
|
||||
|
||||
// Store the story ID on the action as a story_id.
|
||||
input.root_item_id = comment.story_id;
|
||||
input.storyID = comment.storyID;
|
||||
|
||||
// We have to perform a type assertion here because for some reason, the type
|
||||
// coercion is not determining that because we filled in the `root_item_id`
|
||||
// coercion is not determining that because we filled in the `storyID`
|
||||
// above, that at this point, it satisfies the CreateAction type.
|
||||
return addCommentActions(mongo, tenant, comment, [input as CreateAction]);
|
||||
}
|
||||
@@ -95,17 +100,36 @@ async function addCommentAction(
|
||||
export async function removeCommentAction(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
input: RemoveActionInput
|
||||
input: Omit<RemoveActionInput, "commentRevisionID">
|
||||
): Promise<Readonly<Comment>> {
|
||||
// Get the Comment that we are leaving the Action on.
|
||||
const comment = await retrieveComment(mongo, tenant.id, input.item_id);
|
||||
const comment = await retrieveComment(mongo, tenant.id, input.commentID);
|
||||
if (!comment) {
|
||||
// TODO: replace to match error returned by the models/comments.ts
|
||||
throw new Error("comment not found");
|
||||
}
|
||||
|
||||
// Get the revision for the specific action being removed.
|
||||
const action = await retrieveUserAction(
|
||||
mongo,
|
||||
tenant.id,
|
||||
input.userID,
|
||||
input.commentID,
|
||||
input.actionType
|
||||
);
|
||||
if (!action) {
|
||||
// The action that is trying to get removed does not exist!
|
||||
return comment;
|
||||
}
|
||||
|
||||
// Grab the revision ID out of the action.
|
||||
const { commentID, commentRevisionID } = action;
|
||||
|
||||
// Create each of the actions, returning each of the action results.
|
||||
const { wasRemoved, action } = await removeAction(mongo, tenant.id, input);
|
||||
const { wasRemoved } = await removeAction(mongo, tenant.id, {
|
||||
...input,
|
||||
commentRevisionID,
|
||||
});
|
||||
if (wasRemoved) {
|
||||
// Compute the action counts, and invert them (because we're deleting an
|
||||
// action).
|
||||
@@ -115,7 +139,8 @@ export async function removeCommentAction(
|
||||
const updatedComment = await updateCommentActionCounts(
|
||||
mongo,
|
||||
tenant.id,
|
||||
comment.id,
|
||||
commentID,
|
||||
commentRevisionID,
|
||||
actionCounts
|
||||
);
|
||||
|
||||
@@ -123,7 +148,7 @@ export async function removeCommentAction(
|
||||
await updateStoryActionCounts(
|
||||
mongo,
|
||||
tenant.id,
|
||||
comment.story_id,
|
||||
comment.storyID,
|
||||
actionCounts
|
||||
);
|
||||
|
||||
@@ -139,7 +164,10 @@ export async function removeCommentAction(
|
||||
return comment;
|
||||
}
|
||||
|
||||
export type CreateCommentReaction = Pick<CreateActionInput, "item_id">;
|
||||
export type CreateCommentReaction = Pick<
|
||||
CreateActionInput,
|
||||
"commentID" | "commentRevisionID"
|
||||
>;
|
||||
|
||||
export async function createReaction(
|
||||
mongo: Db,
|
||||
@@ -148,14 +176,14 @@ export async function createReaction(
|
||||
input: CreateCommentReaction
|
||||
) {
|
||||
return addCommentAction(mongo, tenant, {
|
||||
action_type: ACTION_TYPE.REACTION,
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
item_id: input.item_id,
|
||||
user_id: author.id,
|
||||
actionType: ACTION_TYPE.REACTION,
|
||||
commentID: input.commentID,
|
||||
commentRevisionID: input.commentRevisionID,
|
||||
userID: author.id,
|
||||
});
|
||||
}
|
||||
|
||||
export type RemoveCommentReaction = Pick<RemoveActionInput, "item_id">;
|
||||
export type RemoveCommentReaction = Pick<RemoveActionInput, "commentID">;
|
||||
|
||||
export async function removeReaction(
|
||||
mongo: Db,
|
||||
@@ -164,14 +192,16 @@ export async function removeReaction(
|
||||
input: RemoveCommentReaction
|
||||
) {
|
||||
return removeCommentAction(mongo, tenant, {
|
||||
action_type: ACTION_TYPE.REACTION,
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
item_id: input.item_id,
|
||||
user_id: author.id,
|
||||
actionType: ACTION_TYPE.REACTION,
|
||||
commentID: input.commentID,
|
||||
userID: author.id,
|
||||
});
|
||||
}
|
||||
|
||||
export type CreateCommentDontAgree = Pick<CreateActionInput, "item_id">;
|
||||
export type CreateCommentDontAgree = Pick<
|
||||
CreateActionInput,
|
||||
"commentID" | "commentRevisionID"
|
||||
>;
|
||||
|
||||
export async function createDontAgree(
|
||||
mongo: Db,
|
||||
@@ -180,14 +210,14 @@ export async function createDontAgree(
|
||||
input: CreateCommentDontAgree
|
||||
) {
|
||||
return addCommentAction(mongo, tenant, {
|
||||
action_type: ACTION_TYPE.DONT_AGREE,
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
item_id: input.item_id,
|
||||
user_id: author.id,
|
||||
actionType: ACTION_TYPE.DONT_AGREE,
|
||||
commentID: input.commentID,
|
||||
commentRevisionID: input.commentRevisionID,
|
||||
userID: author.id,
|
||||
});
|
||||
}
|
||||
|
||||
export type RemoveCommentDontAgree = Pick<RemoveActionInput, "item_id">;
|
||||
export type RemoveCommentDontAgree = Pick<RemoveActionInput, "commentID">;
|
||||
|
||||
export async function removeDontAgree(
|
||||
mongo: Db,
|
||||
@@ -196,14 +226,16 @@ export async function removeDontAgree(
|
||||
input: RemoveCommentDontAgree
|
||||
) {
|
||||
return removeCommentAction(mongo, tenant, {
|
||||
action_type: ACTION_TYPE.DONT_AGREE,
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
item_id: input.item_id,
|
||||
user_id: author.id,
|
||||
actionType: ACTION_TYPE.DONT_AGREE,
|
||||
commentID: input.commentID,
|
||||
userID: author.id,
|
||||
});
|
||||
}
|
||||
|
||||
export type CreateCommentFlag = Pick<CreateActionInput, "item_id"> & {
|
||||
export type CreateCommentFlag = Pick<
|
||||
CreateActionInput,
|
||||
"commentID" | "commentRevisionID"
|
||||
> & {
|
||||
reason: GQLCOMMENT_FLAG_REPORTED_REASON;
|
||||
};
|
||||
|
||||
@@ -214,15 +246,15 @@ export async function createFlag(
|
||||
input: CreateCommentFlag
|
||||
) {
|
||||
return addCommentAction(mongo, tenant, {
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: input.reason,
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
item_id: input.item_id,
|
||||
user_id: author.id,
|
||||
commentID: input.commentID,
|
||||
commentRevisionID: input.commentRevisionID,
|
||||
userID: author.id,
|
||||
});
|
||||
}
|
||||
|
||||
export type RemoveCommentFlag = Pick<RemoveActionInput, "item_id">;
|
||||
export type RemoveCommentFlag = Pick<RemoveActionInput, "commentID">;
|
||||
|
||||
export async function removeFlag(
|
||||
mongo: Db,
|
||||
@@ -231,9 +263,8 @@ export async function removeFlag(
|
||||
input: RemoveCommentFlag
|
||||
) {
|
||||
return removeCommentAction(mongo, tenant, {
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
item_id: input.item_id,
|
||||
user_id: author.id,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
commentID: input.commentID,
|
||||
userID: author.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Omit } from "talk-common/types";
|
||||
import { ACTION_ITEM_TYPE } from "talk-server/models/action";
|
||||
import {
|
||||
createComment,
|
||||
CreateCommentInput,
|
||||
editComment,
|
||||
EditCommentInput,
|
||||
getLatestRevision,
|
||||
pushChildCommentIDOntoParent,
|
||||
retrieveComment,
|
||||
} from "talk-server/models/comment";
|
||||
@@ -25,7 +25,7 @@ import { Request } from "talk-server/types/express";
|
||||
|
||||
export type CreateComment = Omit<
|
||||
CreateCommentInput,
|
||||
"status" | "action_counts" | "metadata" | "grandparent_ids"
|
||||
"status" | "metadata" | "grandparentIDs"
|
||||
>;
|
||||
|
||||
export async function create(
|
||||
@@ -36,7 +36,7 @@ export async function create(
|
||||
req?: Request
|
||||
) {
|
||||
// Grab the story that we'll use to check moderation pieces with.
|
||||
const story = await retrieveStory(mongo, tenant.id, input.story_id);
|
||||
const story = await retrieveStory(mongo, tenant.id, input.storyID);
|
||||
if (!story) {
|
||||
// TODO: (wyattjoh) return better error.
|
||||
throw new Error("story referenced does not exist");
|
||||
@@ -45,9 +45,9 @@ export async function create(
|
||||
// TODO: (wyattjoh) Check that the story was visible.
|
||||
|
||||
const grandparentIDs: string[] = [];
|
||||
if (input.parent_id) {
|
||||
if (input.parentID) {
|
||||
// Check to see that the reference parent ID exists.
|
||||
const parent = await retrieveComment(mongo, tenant.id, input.parent_id);
|
||||
const parent = await retrieveComment(mongo, tenant.id, input.parentID);
|
||||
if (!parent) {
|
||||
// TODO: (wyattjoh) return better error.
|
||||
throw new Error("parent comment referenced does not exist");
|
||||
@@ -56,10 +56,10 @@ export async function create(
|
||||
// TODO: (wyattjoh) Check that the parent comment was visible.
|
||||
|
||||
// Push the parent's parent id's into the comment's grandparent id's.
|
||||
grandparentIDs.push(...parent.grandparent_ids);
|
||||
if (parent.parent_id) {
|
||||
grandparentIDs.push(...parent.grandparentIDs);
|
||||
if (parent.parentID) {
|
||||
// If this parent has a parent, push it down as well.
|
||||
grandparentIDs.push(parent.parent_id);
|
||||
grandparentIDs.push(parent.parentID);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,23 +76,22 @@ export async function create(
|
||||
let comment = await createComment(mongo, tenant.id, {
|
||||
...input,
|
||||
status,
|
||||
action_counts: {},
|
||||
grandparent_ids: grandparentIDs,
|
||||
grandparentIDs,
|
||||
metadata,
|
||||
});
|
||||
|
||||
if (actions.length > 0) {
|
||||
// The actions coming from the moderation phases didn't know the item_id
|
||||
// The actions coming from the moderation phases didn't know the commentID
|
||||
// at the time, and we didn't want the repetitive nature of adding the
|
||||
// item_type each time, so this mapping function adds them!
|
||||
const inputs = actions.map(
|
||||
(action): CreateAction => ({
|
||||
...action,
|
||||
item_id: comment.id,
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
commentID: comment.id,
|
||||
commentRevisionID: getLatestRevision(comment!).id,
|
||||
|
||||
// Store the Story ID on the action.
|
||||
root_item_id: story.id,
|
||||
storyID: story.id,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -100,12 +99,12 @@ export async function create(
|
||||
comment = await addCommentActions(mongo, tenant, comment, inputs);
|
||||
}
|
||||
|
||||
if (input.parent_id) {
|
||||
if (input.parentID) {
|
||||
// Push the child's ID onto the parent.
|
||||
await pushChildCommentIDOntoParent(
|
||||
mongo,
|
||||
tenant.id,
|
||||
input.parent_id,
|
||||
input.parentID,
|
||||
comment.id
|
||||
);
|
||||
}
|
||||
@@ -120,7 +119,7 @@ export async function create(
|
||||
|
||||
export type EditComment = Omit<
|
||||
EditCommentInput,
|
||||
"status" | "author_id" | "lastEditableCommentCreatedAt"
|
||||
"status" | "authorID" | "lastEditableCommentCreatedAt"
|
||||
>;
|
||||
|
||||
export async function edit(
|
||||
@@ -138,7 +137,7 @@ export async function edit(
|
||||
}
|
||||
|
||||
// Grab the story that we'll use to check moderation pieces with.
|
||||
const story = await retrieveStory(mongo, tenant.id, comment.story_id);
|
||||
const story = await retrieveStory(mongo, tenant.id, comment.storyID);
|
||||
if (!story) {
|
||||
// TODO: (wyattjoh) return better error.
|
||||
throw new Error("story referenced does not exist");
|
||||
@@ -155,7 +154,7 @@ export async function edit(
|
||||
|
||||
let editedComment = await editComment(mongo, tenant.id, {
|
||||
id: input.id,
|
||||
author_id: author.id,
|
||||
authorID: author.id,
|
||||
body: input.body,
|
||||
status,
|
||||
metadata,
|
||||
@@ -173,7 +172,7 @@ export async function edit(
|
||||
}
|
||||
|
||||
if (actions.length > 0) {
|
||||
// The actions coming from the moderation phases didn't know the item_id
|
||||
// The actions coming from the moderation phases didn't know the commentID
|
||||
// at the time, and we didn't want the repetitive nature of adding the
|
||||
// item_type each time, so this mapping function adds them!
|
||||
const inputs = actions.map(
|
||||
@@ -181,11 +180,11 @@ export async function edit(
|
||||
...action,
|
||||
// Strict null check seems to have failed here... Null checking was done
|
||||
// above where we errored if the comment was falsely.
|
||||
item_id: comment!.id,
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
commentID: comment!.id,
|
||||
commentRevisionID: getLatestRevision(comment!).id,
|
||||
|
||||
// Store the Story ID on the action.
|
||||
root_item_id: story.id,
|
||||
storyID: story.id,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
GQLCOMMENT_FLAG_REASON,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { ACTION_TYPE } from "talk-server/models/action";
|
||||
import { ACTION_TYPE } from "talk-server/models/action/comment";
|
||||
import {
|
||||
compose,
|
||||
ModerationPhaseContext,
|
||||
@@ -51,11 +51,13 @@ describe("compose", () => {
|
||||
|
||||
const flags = [
|
||||
{
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
|
||||
},
|
||||
{
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
|
||||
},
|
||||
];
|
||||
@@ -71,7 +73,8 @@ describe("compose", () => {
|
||||
() => ({
|
||||
actions: [
|
||||
{
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
|
||||
},
|
||||
],
|
||||
@@ -85,7 +88,7 @@ describe("compose", () => {
|
||||
}
|
||||
|
||||
expect(final.actions).not.toContainEqual({
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Omit, Promiseable } from "talk-common/types";
|
||||
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { CreateActionInput } from "talk-server/models/action";
|
||||
import { Comment } from "talk-server/models/comment";
|
||||
import { CreateActionInput } from "talk-server/models/action/comment";
|
||||
import { EditCommentInput } from "talk-server/models/comment";
|
||||
import { Story } from "talk-server/models/story";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { User } from "talk-server/models/user";
|
||||
@@ -9,7 +9,10 @@ import { Request } from "talk-server/types/express";
|
||||
|
||||
import { moderationPhases } from "./phases";
|
||||
|
||||
export type ModerationAction = Omit<CreateActionInput, "item_id" | "item_type">;
|
||||
export type ModerationAction = Omit<
|
||||
CreateActionInput,
|
||||
"commentID" | "commentRevisionID"
|
||||
>;
|
||||
|
||||
export interface PhaseResult {
|
||||
actions: ModerationAction[];
|
||||
@@ -20,7 +23,7 @@ export interface PhaseResult {
|
||||
export interface ModerationPhaseContext {
|
||||
story: Story;
|
||||
tenant: Tenant;
|
||||
comment: Partial<Comment>;
|
||||
comment: Partial<EditCommentInput>;
|
||||
author: User;
|
||||
req?: Request;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
GQLCOMMENT_FLAG_REASON,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { ACTION_TYPE } from "talk-server/models/action";
|
||||
import { ACTION_TYPE } from "talk-server/models/action/comment";
|
||||
import { ModerationSettings } from "talk-server/models/settings";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
@@ -50,7 +50,8 @@ export const commentLength: IntermediateModerationPhase = ({
|
||||
status: GQLCOMMENT_STATUS.REJECTED,
|
||||
actions: [
|
||||
{
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
|
||||
metadata: {
|
||||
count: length,
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
GQLCOMMENT_FLAG_REASON,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { ACTION_TYPE } from "talk-server/models/action";
|
||||
import { ACTION_TYPE } from "talk-server/models/action/comment";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
@@ -33,7 +33,8 @@ export const karma: IntermediateModerationPhase = ({
|
||||
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
|
||||
actions: [
|
||||
{
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
|
||||
metadata: {
|
||||
trust: getCommentTrustScore(author),
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
GQLCOMMENT_FLAG_REASON,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { ACTION_TYPE } from "talk-server/models/action";
|
||||
import { ACTION_TYPE } from "talk-server/models/action/comment";
|
||||
import { ModerationSettings } from "talk-server/models/settings";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
@@ -39,7 +39,8 @@ export const links: IntermediateModerationPhase = ({
|
||||
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
|
||||
actions: [
|
||||
{
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
|
||||
metadata: {
|
||||
links: comment.body,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import logger from "talk-server/logger";
|
||||
import { ACTION_TYPE } from "talk-server/models/action";
|
||||
import { ACTION_TYPE } from "talk-server/models/action/comment";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
@@ -20,29 +20,31 @@ export const spam: IntermediateModerationPhase = async ({
|
||||
}): Promise<IntermediatePhaseResult | void> => {
|
||||
const integration = tenant.integrations.akismet;
|
||||
|
||||
const log = logger.child({
|
||||
tenantID: tenant.id,
|
||||
});
|
||||
|
||||
// We can only check for spam if this comment originated from a graphql
|
||||
// request via an HTTP call.
|
||||
if (!req) {
|
||||
logger.debug({ tenant_id: tenant.id }, "request was not available");
|
||||
log.debug("request was not available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!integration.enabled) {
|
||||
logger.debug({ tenant_id: tenant.id }, "akismet integration was disabled");
|
||||
log.debug("akismet integration was disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!integration.key) {
|
||||
logger.error(
|
||||
{ tenant_id: tenant.id },
|
||||
log.error(
|
||||
"akismet integration was enabled but the key configuration was missing"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!integration.site) {
|
||||
logger.error(
|
||||
{ tenant_id: tenant.id },
|
||||
log.error(
|
||||
"akismet integration was enabled but the site configuration was missing"
|
||||
);
|
||||
return;
|
||||
@@ -62,33 +64,24 @@ export const spam: IntermediateModerationPhase = async ({
|
||||
// Grab the properties we need.
|
||||
const userIP = req.ip;
|
||||
if (!userIP) {
|
||||
logger.debug(
|
||||
{ tenant_id: tenant.id },
|
||||
"request did not contain ip address, aborting spam check"
|
||||
);
|
||||
log.debug("request did not contain ip address, aborting spam check");
|
||||
return;
|
||||
}
|
||||
|
||||
const userAgent = req.get("User-Agent");
|
||||
if (!userAgent || userAgent.length === 0) {
|
||||
logger.debug(
|
||||
{ tenant_id: tenant.id },
|
||||
"request did not contain User-Agent header, aborting spam check"
|
||||
);
|
||||
log.debug("request did not contain User-Agent header, aborting spam check");
|
||||
return;
|
||||
}
|
||||
|
||||
const referrer = req.get("Referrer");
|
||||
if (!referrer || referrer.length === 0) {
|
||||
logger.debug(
|
||||
{ tenant_id: tenant.id },
|
||||
"request did not contain Referrer header, aborting spam check"
|
||||
);
|
||||
log.debug("request did not contain Referrer header, aborting spam check");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.trace({ tenant_id: tenant.id }, "checking comment for spam");
|
||||
log.trace("checking comment for spam");
|
||||
|
||||
// Check the comment for spam.
|
||||
const isSpam = await client.checkSpam({
|
||||
@@ -102,15 +95,13 @@ export const spam: IntermediateModerationPhase = async ({
|
||||
is_test: false,
|
||||
});
|
||||
if (isSpam) {
|
||||
logger.trace(
|
||||
{ tenant_id: tenant.id, is_spam: isSpam },
|
||||
"comment contained spam"
|
||||
);
|
||||
log.trace({ isSpam }, "comment contained spam");
|
||||
return {
|
||||
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
|
||||
actions: [
|
||||
{
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
|
||||
},
|
||||
],
|
||||
@@ -121,14 +112,8 @@ export const spam: IntermediateModerationPhase = async ({
|
||||
};
|
||||
}
|
||||
|
||||
logger.trace(
|
||||
{ tenant_id: tenant.id, is_spam: isSpam },
|
||||
"comment did not contain spam"
|
||||
);
|
||||
log.trace({ isSpam }, "comment did not contain spam");
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ tenant_id: tenant.id, err },
|
||||
"could not determine if comment contained spam"
|
||||
);
|
||||
log.error({ err }, "could not determine if comment contained spam");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("storyClosed", () => {
|
||||
|
||||
expect(() =>
|
||||
storyClosed({
|
||||
story: { created_at: new Date() } as Story,
|
||||
story: { createdAt: new Date() } as Story,
|
||||
tenant: { autoCloseStream: true, closedTimeout: -6000 } as Tenant,
|
||||
comment: {} as Comment,
|
||||
author: {} as User,
|
||||
|
||||
@@ -17,7 +17,7 @@ export const storyClosed: IntermediateModerationPhase = ({
|
||||
story.closedAt !== false &&
|
||||
tenant.autoCloseStream &&
|
||||
tenant.closedTimeout &&
|
||||
story.created_at.valueOf() + tenant.closedTimeout <= Date.now()
|
||||
story.createdAt.valueOf() + tenant.closedTimeout <= Date.now()
|
||||
) {
|
||||
// TODO: (wyattjoh) return better error.
|
||||
throw new Error("story is currently closed for commenting");
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
GQLPerspectiveExternalIntegration,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import logger from "talk-server/logger";
|
||||
import { ACTION_TYPE } from "talk-server/models/action";
|
||||
import { ACTION_TYPE } from "talk-server/models/action/comment";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
@@ -23,21 +23,19 @@ export const toxic: IntermediateModerationPhase = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
const log = logger.child({ tenantID: tenant.id });
|
||||
|
||||
const integration = tenant.integrations.perspective;
|
||||
|
||||
if (!integration.enabled) {
|
||||
// The Toxic comment plugin is not enabled.
|
||||
logger.debug(
|
||||
{ tenant_id: tenant.id },
|
||||
"perspective integration was disabled"
|
||||
);
|
||||
log.debug("perspective integration was disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!integration.key) {
|
||||
// The Toxic comment requires a key in order to communicate with the API.
|
||||
logger.error(
|
||||
{ tenant_id: tenant.id },
|
||||
log.error(
|
||||
"perspective integration was enabled but the key configuration was missing"
|
||||
);
|
||||
return;
|
||||
@@ -48,8 +46,8 @@ export const toxic: IntermediateModerationPhase = async ({
|
||||
// TODO: (wyattjoh) replace hardcoded default with config.
|
||||
endpoint = "https://commentanalyzer.googleapis.com/v1alpha1";
|
||||
|
||||
logger.trace(
|
||||
{ tenant_id: tenant.id, endpoint },
|
||||
log.trace(
|
||||
{ endpoint },
|
||||
"endpoint missing in integration settings, using defaults"
|
||||
);
|
||||
}
|
||||
@@ -59,8 +57,8 @@ export const toxic: IntermediateModerationPhase = async ({
|
||||
// TODO: (wyattjoh) replace hardcoded default with config.
|
||||
threshold = 0.8;
|
||||
|
||||
logger.trace(
|
||||
{ tenant_id: tenant.id, threshold },
|
||||
log.trace(
|
||||
{ threshold },
|
||||
"threshold missing in integration settings, using defaults"
|
||||
);
|
||||
}
|
||||
@@ -69,8 +67,8 @@ export const toxic: IntermediateModerationPhase = async ({
|
||||
if (isNil(doNotStore)) {
|
||||
doNotStore = true;
|
||||
|
||||
logger.trace(
|
||||
{ tenant_id: tenant.id, do_not_store: doNotStore },
|
||||
log.trace(
|
||||
{ doNotStore },
|
||||
"doNotStore missing in integration settings, using defaults"
|
||||
);
|
||||
}
|
||||
@@ -79,7 +77,7 @@ export const toxic: IntermediateModerationPhase = async ({
|
||||
const timeout = ms("300ms");
|
||||
|
||||
try {
|
||||
logger.trace({ tenant_id: tenant.id }, "checking comment toxicity");
|
||||
logger.trace("checking comment toxicity");
|
||||
|
||||
// Call into the Toxic comment API.
|
||||
const scores = await getScores(
|
||||
@@ -95,15 +93,13 @@ export const toxic: IntermediateModerationPhase = async ({
|
||||
const score = scores.SEVERE_TOXICITY.summaryScore;
|
||||
const isToxic = score > threshold;
|
||||
if (isToxic) {
|
||||
logger.trace(
|
||||
{ tenant_id: tenant.id, score, is_toxic: isToxic, threshold },
|
||||
"comment was toxic"
|
||||
);
|
||||
log.trace({ score, isToxic, threshold }, "comment was toxic");
|
||||
return {
|
||||
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
|
||||
actions: [
|
||||
{
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
|
||||
},
|
||||
],
|
||||
@@ -114,15 +110,9 @@ export const toxic: IntermediateModerationPhase = async ({
|
||||
};
|
||||
}
|
||||
|
||||
logger.trace(
|
||||
{ tenant_id: tenant.id, score, is_toxic: isToxic, threshold },
|
||||
"comment was not toxic"
|
||||
);
|
||||
log.trace({ score, isToxic, threshold }, "comment was not toxic");
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ tenant_id: tenant.id, err },
|
||||
"could not determine comment toxicity"
|
||||
);
|
||||
log.error({ err }, "could not determine comment toxicity");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
GQLCOMMENT_FLAG_REASON,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { ACTION_TYPE } from "talk-server/models/action";
|
||||
import { ACTION_TYPE } from "talk-server/models/action/comment";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
@@ -29,7 +29,8 @@ export const wordList: IntermediateModerationPhase = ({
|
||||
status: GQLCOMMENT_STATUS.REJECTED,
|
||||
actions: [
|
||||
{
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
|
||||
},
|
||||
],
|
||||
@@ -46,7 +47,8 @@ export const wordList: IntermediateModerationPhase = ({
|
||||
return {
|
||||
actions: [
|
||||
{
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { memoize } from "lodash";
|
||||
|
||||
// TODO: reintroduce this when we have https://github.com/DefinitelyTyped/DefinitelyTyped/pull/30035 merged
|
||||
// // Replace `memoize.Cache`.
|
||||
// memoize.Cache = WeakMap;
|
||||
// Replace `memoize.Cache`.
|
||||
memoize.Cache = WeakMap;
|
||||
|
||||
/**
|
||||
* Escape string for special regular expression characters.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Db } from "mongodb";
|
||||
import { Omit } from "talk-common/types";
|
||||
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import logger from "talk-server/logger";
|
||||
import {
|
||||
createCommentModerationAction,
|
||||
CreateCommentModerationActionInput,
|
||||
} from "talk-server/models/action/moderation/comment";
|
||||
import { updateCommentStatus } from "talk-server/models/comment";
|
||||
import { updateCommentStatusCount } from "talk-server/models/story";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
|
||||
export type Moderate = Omit<CreateCommentModerationActionInput, "status">;
|
||||
|
||||
const moderate = (status: GQLCOMMENT_STATUS) => async (
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
input: Moderate
|
||||
) => {
|
||||
// TODO: wrap these operations in a transaction?
|
||||
|
||||
// Create the logger.
|
||||
const log = logger.child({
|
||||
...input,
|
||||
tenantID: tenant.id,
|
||||
newStatus: status,
|
||||
});
|
||||
|
||||
// Update the Comment's status.
|
||||
const result = await updateCommentStatus(
|
||||
mongo,
|
||||
tenant.id,
|
||||
input.commentID,
|
||||
input.commentRevisionID,
|
||||
status
|
||||
);
|
||||
if (!result) {
|
||||
// TODO: wrap in better error?
|
||||
throw new Error("specified comment not found");
|
||||
}
|
||||
|
||||
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"
|
||||
);
|
||||
|
||||
// Update the story comment counts.
|
||||
const story = await updateCommentStatusCount(
|
||||
mongo,
|
||||
tenant.id,
|
||||
result.comment.storyID,
|
||||
{
|
||||
[result.oldStatus]: -1,
|
||||
[status]: 1,
|
||||
}
|
||||
);
|
||||
if (!story) {
|
||||
// TODO: wrap in better error?
|
||||
throw new Error("specified story not found");
|
||||
}
|
||||
|
||||
log.trace({ oldStatus: result.oldStatus }, "adjusted story comment counts");
|
||||
|
||||
return result.comment;
|
||||
};
|
||||
|
||||
export const accept = moderate(GQLCOMMENT_STATUS.ACCEPTED);
|
||||
|
||||
export const reject = moderate(GQLCOMMENT_STATUS.REJECTED);
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
import logger from "talk-server/logger";
|
||||
import {
|
||||
countTotalActionCounts,
|
||||
mergeActionCounts,
|
||||
mergeManyRootActions,
|
||||
removeRootActions,
|
||||
} from "talk-server/models/action";
|
||||
mergeCommentActionCounts,
|
||||
mergeManyStoryActions,
|
||||
removeStoryActions,
|
||||
} from "talk-server/models/action/comment";
|
||||
import {
|
||||
mergeManyCommentStories,
|
||||
removeStoryComments,
|
||||
@@ -123,8 +123,8 @@ export async function remove(
|
||||
) {
|
||||
// Create a logger for this function.
|
||||
const log = logger.child({
|
||||
story_id: storyID,
|
||||
include_comments: includeComments,
|
||||
storyID,
|
||||
includeComments,
|
||||
});
|
||||
|
||||
log.debug("starting to remove story");
|
||||
@@ -138,32 +138,24 @@ export async function remove(
|
||||
}
|
||||
|
||||
if (includeComments) {
|
||||
let removedCount: number | undefined;
|
||||
|
||||
// Remove the actions associated with the comments we just removed.
|
||||
({ deletedCount: removedCount } = await removeRootActions(
|
||||
const { deletedCount: removedActions } = await removeStoryActions(
|
||||
mongo,
|
||||
tenant.id,
|
||||
story.id
|
||||
));
|
||||
|
||||
log.debug(
|
||||
{ removed_actions: removedCount },
|
||||
"removed actions while deleting story"
|
||||
);
|
||||
|
||||
log.debug({ removedActions }, "removed actions while deleting story");
|
||||
|
||||
// Remove the comments for the story.
|
||||
({ deletedCount: removedCount } = await removeStoryComments(
|
||||
const { deletedCount: removedComments } = await removeStoryComments(
|
||||
mongo,
|
||||
tenant.id,
|
||||
story.id
|
||||
));
|
||||
|
||||
log.debug(
|
||||
{ removed_comments: removedCount },
|
||||
"removed comments while deleting story"
|
||||
);
|
||||
} else if (calculateTotalCommentCount(story.comment_counts) > 0) {
|
||||
|
||||
log.debug({ removedComments }, "removed comments while deleting story");
|
||||
} else if (calculateTotalCommentCount(story.commentCounts) > 0) {
|
||||
log.warn(
|
||||
"attempted to remove story that has linked comments without consent for deleting comments"
|
||||
);
|
||||
@@ -196,7 +188,7 @@ export async function create(
|
||||
// Ensure that the given URL is allowed.
|
||||
if (!isURLPermitted(tenant, storyURL)) {
|
||||
logger.warn(
|
||||
{ story_url: storyURL, tenant_domains: tenant.domains },
|
||||
{ storyURL, tenantDomains: tenant.domains },
|
||||
"provided story url was not in the list of permitted tenant domains, story not created"
|
||||
);
|
||||
return null;
|
||||
@@ -224,7 +216,7 @@ export async function update(
|
||||
// Ensure that the given URL is allowed.
|
||||
if (input.url && !isURLPermitted(tenant, input.url)) {
|
||||
logger.warn(
|
||||
{ story_url: input.url, tenant_domains: tenant.domains },
|
||||
{ storyURL: input.url, tenantDomains: tenant.domains },
|
||||
"provided story url was not in the list of permitted tenant domains, story not updated"
|
||||
);
|
||||
return null;
|
||||
@@ -241,8 +233,8 @@ export async function merge(
|
||||
) {
|
||||
// Create a logger for this operation.
|
||||
const log = logger.child({
|
||||
destination_id: destinationID,
|
||||
source_ids: sourceIDs,
|
||||
destinationID,
|
||||
sourceIDs,
|
||||
});
|
||||
|
||||
if (sourceIDs.length === 0) {
|
||||
@@ -259,7 +251,7 @@ export async function merge(
|
||||
zip(storyIDs, stories).some(([storyID, story]) => {
|
||||
if (!story) {
|
||||
log.warn(
|
||||
{ story_id: storyID },
|
||||
{ storyID },
|
||||
"story that was going to be merged was not found"
|
||||
);
|
||||
return true;
|
||||
@@ -271,36 +263,28 @@ export async function merge(
|
||||
return null;
|
||||
}
|
||||
|
||||
let updatedCount: number | undefined;
|
||||
|
||||
// Move all the comment's from the source stories over to the destination
|
||||
// story.
|
||||
({ modifiedCount: updatedCount } = await mergeManyCommentStories(
|
||||
const { modifiedCount: updatedComments } = await mergeManyCommentStories(
|
||||
mongo,
|
||||
tenant.id,
|
||||
destinationID,
|
||||
sourceIDs
|
||||
));
|
||||
|
||||
log.debug(
|
||||
{ updated_comments: updatedCount },
|
||||
"updated comments while merging stories"
|
||||
);
|
||||
|
||||
log.debug({ updatedComments }, "updated comments while merging stories");
|
||||
|
||||
// Update all the action's that referenced the old story to reference the new
|
||||
// story.
|
||||
({ modifiedCount: updatedCount } = await mergeManyRootActions(
|
||||
const { modifiedCount: updatedActions } = await mergeManyStoryActions(
|
||||
mongo,
|
||||
tenant.id,
|
||||
destinationID,
|
||||
sourceIDs
|
||||
));
|
||||
|
||||
log.debug(
|
||||
{ updated_actions: updatedCount },
|
||||
"updated actions while merging stories"
|
||||
);
|
||||
|
||||
log.debug({ updatedActions }, "updated actions while merging stories");
|
||||
|
||||
// Merge the comment and action counts for all the source stories.
|
||||
const [, ...sourceStories] = stories;
|
||||
|
||||
@@ -311,14 +295,16 @@ export async function merge(
|
||||
mergeCommentStatusCount(
|
||||
// We perform the type assertion here because above, we already verified
|
||||
// that none of the stories are null.
|
||||
(sourceStories as Story[]).map(({ comment_counts }) => comment_counts)
|
||||
(sourceStories as Story[]).map(({ commentCounts }) => commentCounts)
|
||||
)
|
||||
);
|
||||
|
||||
const mergedActionCounts = mergeActionCounts(
|
||||
const mergedActionCounts = mergeCommentActionCounts(
|
||||
// We perform the type assertion here because above, we already verified
|
||||
// that none of the stories are null.
|
||||
(sourceStories as Story[]).map(({ action_counts }) => action_counts)
|
||||
(sourceStories as Story[]).map(
|
||||
({ commentActionCounts }) => commentActionCounts
|
||||
)
|
||||
);
|
||||
if (countTotalActionCounts(mergedActionCounts) > 0) {
|
||||
destinationStory = await updateStoryActionCounts(
|
||||
@@ -335,13 +321,13 @@ export async function merge(
|
||||
}
|
||||
|
||||
log.debug(
|
||||
{ comment_counts: destinationStory.comment_counts },
|
||||
{ commentCounts: destinationStory.commentCounts },
|
||||
"updated destination story with new comment counts"
|
||||
);
|
||||
|
||||
const { deletedCount } = await removeStories(mongo, tenant.id, sourceIDs);
|
||||
|
||||
log.debug({ deleted_stories: deletedCount }, "deleted source stories");
|
||||
log.debug({ deletedStories: deletedCount }, "deleted source stories");
|
||||
|
||||
// Return the story that had the other stories merged into.
|
||||
return destinationStory;
|
||||
|
||||
+2
-2
@@ -195,7 +195,7 @@ export default class TenantCache {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug({ tenant_id: tenant.id }, "received updated tenant");
|
||||
logger.debug({ tenantID: tenant.id }, "received updated tenant");
|
||||
|
||||
// Update the tenant cache.
|
||||
this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant);
|
||||
@@ -261,7 +261,7 @@ export default class TenantCache {
|
||||
JSON.stringify(message)
|
||||
);
|
||||
|
||||
logger.debug({ tenant_id: tenant.id, subscribers }, "updated tenant");
|
||||
logger.debug({ tenantID: tenant.id, subscribers }, "updated tenant");
|
||||
|
||||
// Publish the event for the connected listeners.
|
||||
this.emitter.emit(EMITTER_EVENT_NAME, tenant);
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function install(
|
||||
await cache.update(redis, tenant);
|
||||
|
||||
logger.info(
|
||||
{ tenant_id: tenant.id, tenant_domain: tenant.domain },
|
||||
{ tenantID: tenant.id, tenantDomain: tenant.domain },
|
||||
"a tenant has been installed"
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user