From f58dc4a33c7ca21f33f339ac6f375949195a6e6a Mon Sep 17 00:00:00 2001 From: trivernis Date: Sat, 18 Jan 2020 16:05:34 +0100 Subject: [PATCH] Add Blacklists - Add BlacklistedPhrase model to store blacklisted phrases - Add checking for blacklisted phrases inside of posts content, usernames, groupnames, eventnames - Add api to create, delete phrases and check if phrases contain blacklisted phrases --- src/graphql/resolvers.ts | 58 ++++++++++++++++++++++++++++- src/graphql/schema.graphql | 47 +++++++++++++++++------ src/lib/dataAccess.ts | 27 +++++++++++++- src/lib/errors/BlacklistedError.ts | 10 +++++ src/lib/errors/graphqlErrors.ts | 11 +++++- src/lib/models/BlacklistedPhrase.ts | 33 ++++++++++++++++ src/lib/models/User.ts | 2 +- src/lib/models/index.ts | 1 + 8 files changed, 173 insertions(+), 16 deletions(-) create mode 100644 src/lib/errors/BlacklistedError.ts create mode 100644 src/lib/models/BlacklistedPhrase.ts diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index 832fd3d..a6ae294 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -4,7 +4,8 @@ import * as yaml from "js-yaml"; import {Op} from "sequelize"; import isEmail from "validator/lib/isEmail"; import dataaccess from "../lib/dataAccess"; -import {NotLoggedInGqlError, PostNotFoundGqlError} from "../lib/errors/graphqlErrors"; +import {BlacklistedError} from "../lib/errors/BlacklistedError"; +import {NotAnAdminGqlError, NotLoggedInGqlError, PostNotFoundGqlError} from "../lib/errors/graphqlErrors"; import {InvalidLoginError} from "../lib/errors/InvalidLoginError"; import globals from "../lib/globals"; import {InternalEvents} from "../lib/InternalEvents"; @@ -113,6 +114,16 @@ export function resolver(req: any, res: any): any { return new GraphQLError("No requestId given."); } }, + async blacklisted({phrase}: {phrase: string}) { + const phrases = await dataaccess.checkBlacklisted(phrase); + return { + blacklisted: phrases.length > 0, + phrases: phrases.map((p) => p.phrase), + }; + }, + async getBlacklistedPhrases({first, offset}: {first: number, offset: number}) { + return (await models.BlacklistedPhrase.findAll({limit: first, offset})).map((p) => p.phrase); + }, acceptCookies() { req.session.cookiesAccepted = true; return true; @@ -476,6 +487,11 @@ export function resolver(req: any, res: any): any { const date = new Date(Number(dueDate)); const group = await models.Group.findByPk(groupId, {include: [{association: "rAdmins"}]}); if (group.rAdmins.find((x) => x.id === req.session.userId)) { + const blacklisted = await dataaccess.checkBlacklisted(name); + if (blacklisted.length > 0) { + res.status(status.BAD_REQUEST); + return new BlacklistedError(blacklisted.map((p) => p.phrase), "event name").graphqlError; + } return group.$create("rEvent", {name, dueDate: date}); } else { res.status(status.FORBIDDEN); @@ -524,7 +540,45 @@ export function resolver(req: any, res: any): any { } } else { res.status(status.FORBIDDEN); - return new GraphQLError("You are not an admin."); + return new NotAnAdminGqlError(); + } + } else { + res.status(status.UNAUTHORIZED); + return new NotLoggedInGqlError(); + } + }, + async addToBlacklist({phrase, languageCode}: {phrase: string, languageCode: string}) { + if (req.session.userId) { + const user = await models.User.findByPk(req.session.userId); + if (user.isAdmin) { + const phraseExists = await models.BlacklistedPhrase.findOne( + {where: {phrase, language: languageCode}}); + if (!phraseExists) { + await models.BlacklistedPhrase.create({phrase, language: languageCode}); + return true; + } else { + return false; + } + } else { + return new NotAnAdminGqlError(); + } + } else { + res.status(status.UNAUTHORIZED); + return new NotLoggedInGqlError(); + } + }, + async removeFromBlacklist({phrase, languageCode}: {phrase: string, languageCode: string}) { + if (req.session.userId) { + const user = await models.User.findByPk(req.session.userId); + if (user.isAdmin) { + const phraseEntry = await models.BlacklistedPhrase.findOne( + {where: {phrase, language: languageCode}}); + if (phraseEntry) { + await phraseEntry.destroy(); + return true; + } else { + return false; + } } } else { res.status(status.UNAUTHORIZED); diff --git a/src/graphql/schema.graphql b/src/graphql/schema.graphql index 7bbd02e..376ed7d 100644 --- a/src/graphql/schema.graphql +++ b/src/graphql/schema.graphql @@ -37,6 +37,12 @@ type Query { "Returns an access token for the user that can be used in requests. To user the token in requests, it has to be set in the HTTP header 'Authorization' with the format Bearer ." getToken(email: String!, passwordHash: String!): Token! + + "Checks if the input phrase contains blacklisted words" + blacklisted(phrase: String!): BlacklistedResult! + + "Returns the blacklist with pagination." + getBlacklistedPhrases(first: Int = 20, offset: Int = 0): [String!]! @complexity(value: 1, multipliers: ["first"]) } type Mutation { @@ -111,6 +117,12 @@ type Mutation { "Creates an activity. Can only be used by admins." createActivity(name: String!, description: String!, points: Int!): Activity + + "Adds a phrase to the blacklist. Returns true if the phrase didn't exist and was inserted." + addToBlacklist(phrase: String!, languageCode: String = "en"): Boolean! + + "Removes a phrase from the blacklist. Returns true if the phrase could be found and deleted." + removeFromBlacklist(phrase: String!, languageCode: String = "en"): Boolean! } interface UserData { @@ -431,6 +443,22 @@ type Token { expires: String! } +"An activity that grants points" +type Activity { + + "the id of the activity" + id: ID! + + "the name of the activity" + name: String! + + "the description of the activity" + description: String! + + "the number of points the activity grants" + points: Int! +} + "The result of a search." type SearchResult { "The users that were found in the search." @@ -456,21 +484,18 @@ type VoteResult { post: Post! } -"An activity that grants points" -type Activity { +"The result of checking if a phrase is blacklisted" +type BlacklistedResult { - "the id of the activity" - id: ID! + "If the phrase contains blacklisted words." + blacklisted: Boolean! + + "The specific blacklisted phrase." + phrases: [String!]! +} - "the name of the activity" - name: String! - "the description of the activity" - description: String! - "the number of points the activity grants" - points: Int! -} "represents the type of vote performed on a post" enum VoteType { diff --git a/src/lib/dataAccess.ts b/src/lib/dataAccess.ts index b713f8a..96ba97a 100644 --- a/src/lib/dataAccess.ts +++ b/src/lib/dataAccess.ts @@ -3,6 +3,7 @@ import {GraphQLError} from "graphql"; import * as sqz from "sequelize"; import {Sequelize} from "sequelize-typescript"; import {ActivityNotFoundError} from "./errors/ActivityNotFoundError"; +import {BlacklistedError} from "./errors/BlacklistedError"; import {ChatNotFoundError} from "./errors/ChatNotFoundError"; import {DuplicatedRequestError} from "./errors/DuplicatedRequestError"; import {EmailAlreadyRegisteredError} from "./errors/EmailAlreadyRegisteredError"; @@ -15,7 +16,7 @@ import {UserNotFoundError} from "./errors/UserNotFoundError"; import globals from "./globals"; import {InternalEvents} from "./InternalEvents"; import * as models from "./models"; -import {Activity} from "./models"; +import {Activity, BlacklistedPhrase} from "./models"; // tslint:disable:completed-docs @@ -62,6 +63,7 @@ namespace dataaccess { models.EventParticipant, models.Event, models.Activity, + models.BlacklistedPhrase, ]); } catch (err) { globals.logger.error(err.message); @@ -118,6 +120,10 @@ namespace dataaccess { * @param password */ export async function registerUser(username: string, email: string, password: string): Promise { + const blacklisted = await checkBlacklisted(username); + if (blacklisted.length > 0) { + throw new BlacklistedError(blacklisted.map((p) => p.phrase), "username"); + } const hash = crypto.createHash("sha512"); hash.update(password); password = hash.digest("hex"); @@ -176,6 +182,10 @@ namespace dataaccess { * @param activityId */ export async function createPost(content: string, authorId: number, activityId?: number): Promise { + const blacklisted = await checkBlacklisted(content); + if (blacklisted.length > 0) { + throw new BlacklistedError(blacklisted.map((p) => p.phrase), "content"); + } const activity = await models.Activity.findByPk(activityId); if (!activityId || activity) { const post = await models.Post.create({content, authorId, activityId}); @@ -284,6 +294,10 @@ namespace dataaccess { * @param members */ export async function createGroup(name: string, creator: number, members: number[]): Promise { + const blacklisted = await checkBlacklisted(name); + if (blacklisted.length > 0) { + throw new BlacklistedError(blacklisted.map((p) => p.phrase), "group name"); + } const groupNameExists = !!await models.Group.findOne({where: {name}}); if (!groupNameExists) { members = members || []; @@ -337,6 +351,17 @@ namespace dataaccess { } } + /** + * Checks if a given phrase is blacklisted. + * @param phrase + * @param language + */ + export async function checkBlacklisted(phrase: string, language: string = "en"): Promise { + return sequelize.query(` + SELECT * FROM blacklisted_phrases WHERE ? ~* phrase AND language = ?`, + {replacements: [phrase, language], mapToModel: true, model: BlacklistedPhrase}); + } + /** * Enum representing the types of votes that can be performed on a post. */ diff --git a/src/lib/errors/BlacklistedError.ts b/src/lib/errors/BlacklistedError.ts new file mode 100644 index 0000000..288af38 --- /dev/null +++ b/src/lib/errors/BlacklistedError.ts @@ -0,0 +1,10 @@ +import {BaseError} from "./BaseError"; + +/** + * Represents an error that is thrown when a blacklisted phrase is used. + */ +export class BlacklistedError extends BaseError { + constructor(public phrases: string[], field: string = "input") { + super(`The ${field} contains the blacklisted words: ${phrases.join(",")}`); + } +} diff --git a/src/lib/errors/graphqlErrors.ts b/src/lib/errors/graphqlErrors.ts index c336ae0..26c08aa 100644 --- a/src/lib/errors/graphqlErrors.ts +++ b/src/lib/errors/graphqlErrors.ts @@ -19,10 +19,19 @@ export class PostNotFoundGqlError extends GraphQLError { } /** - * An error for the forntend that is thrown when a group was not found + * An error for the frontend that is thrown when a group was not found */ export class GroupNotFoundGqlError extends GraphQLError { constructor(groupId: number) { super(`Group '${groupId}' not found!`); } } + +/** + * An error for the frontend that is thrown when a nonadmin tries to perform an admin operation. + */ +export class NotAnAdminGqlError extends GraphQLError { + constructor() { + super("You are not an admin."); + } +} diff --git a/src/lib/models/BlacklistedPhrase.ts b/src/lib/models/BlacklistedPhrase.ts new file mode 100644 index 0000000..5591155 --- /dev/null +++ b/src/lib/models/BlacklistedPhrase.ts @@ -0,0 +1,33 @@ +import * as sqz from "sequelize"; +import { + BelongsTo, + BelongsToMany, + Column, + ForeignKey, + HasMany, + Model, + NotNull, + Table, + Unique, +} from "sequelize-typescript"; + +/** + * Represents a blacklisted phrase + */ +@Table({underscored: true}) +export class BlacklistedPhrase extends Model { + + /** + * The phrase that is blacklisted + */ + @NotNull + @Unique + @Column({allowNull: false, unique: true}) + public phrase: string; + + /** + * An optional language + */ + @Column({type: sqz.STRING(2), defaultValue: "en"}) + public language: string; +} diff --git a/src/lib/models/User.ts b/src/lib/models/User.ts index 986222e..76d22f4 100644 --- a/src/lib/models/User.ts +++ b/src/lib/models/User.ts @@ -80,7 +80,7 @@ export class User extends Model { * The auth token for bearer authentication */ @Unique - @Column({defaultValue: uuidv4, unique: true}) + @Column({defaultValue: uuidv4, unique: true, type: sqz.UUIDV4}) public authToken: string; /** diff --git a/src/lib/models/index.ts b/src/lib/models/index.ts index 823dfd7..7aba87a 100644 --- a/src/lib/models/index.ts +++ b/src/lib/models/index.ts @@ -12,3 +12,4 @@ export {GroupMember} from "./GroupMember"; export {Event} from "./Event"; export {EventParticipant} from "./EventParticipant"; export {Activity} from "./Activity"; +export {BlacklistedPhrase} from "./BlacklistedPhrase";