diff --git a/src/graphql/BaseResolver.ts b/src/graphql/BaseResolver.ts new file mode 100644 index 0000000..753af99 --- /dev/null +++ b/src/graphql/BaseResolver.ts @@ -0,0 +1,17 @@ +import {NotLoggedInGqlError} from "../lib/errors/graphqlErrors"; + +/** + * Base resolver class to provide common methods to all resolver classes + */ +export abstract class BaseResolver { + + /** + * Checks if the user is logged in and throws an exception if not + * @param request + */ + protected ensureLoggedIn(request: any) { + if (!request.session.userId) { + throw new NotLoggedInGqlError(); + } + } +} diff --git a/src/graphql/BlacklistedResult.ts b/src/graphql/BlacklistedResult.ts new file mode 100644 index 0000000..4139424 --- /dev/null +++ b/src/graphql/BlacklistedResult.ts @@ -0,0 +1,6 @@ +export class BlacklistedResult { + constructor( + public blacklisted: boolean, + public phrases: string[], + ) {} +} diff --git a/src/graphql/MutationResolver.ts b/src/graphql/MutationResolver.ts index 017f822..f70de4d 100644 --- a/src/graphql/MutationResolver.ts +++ b/src/graphql/MutationResolver.ts @@ -1,6 +1,467 @@ +import {GraphQLError} from "graphql"; +import * as yaml from "js-yaml"; +import isEmail from "validator/lib/isEmail"; +import dataaccess from "../lib/dataAccess"; +import {BlacklistedError} from "../lib/errors/BlacklistedError"; +import {GroupNotFoundError} from "../lib/errors/GroupNotFoundError"; +import {InvalidEmailError} from "../lib/errors/InvalidEmailError"; +import {NotAGroupAdminError} from "../lib/errors/NotAGroupAdminError"; +import {NotAnAdminError} from "../lib/errors/NotAnAdminError"; +import {NotTheGroupCreatorError} from "../lib/errors/NotTheGroupCreatorError"; +import {PostNotFoundError} from "../lib/errors/PostNotFoundError"; +import globals from "../lib/globals"; +import {InternalEvents} from "../lib/InternalEvents"; +import {Activity, BlacklistedPhrase, ChatMessage, ChatRoom, Event, Group, Post, Request, User} from "../lib/models"; +import {BaseResolver} from "./BaseResolver"; + +const legit = require("legit"); + /** * A class that provides methods to resolve mutations */ -export class MutationResolver { +export class MutationResolver extends BaseResolver { + + /** + * Accepts the usage of cookies and stores the session + * @param args + * @param request + */ + public acceptCookies(args: null, request: any): boolean { + request.session.cookiesAccepted = true; + return true; + } + + /** + * Loggs in and appends the user id to the session + * @param email + * @param passwordHash + * @param request + */ + public async login({email, passwordHash}: { email: string, passwordHash: string }, request: any): Promise { + const user = await dataaccess.getUserByLogin(email, passwordHash); + request.session.userId = user.id; + return user; + } + + /** + * Loggs out by removing the user from the session + * @param args + * @param request + */ + public logout(args: null, request: any) { + this.ensureLoggedIn(request); + delete request.session.userId; + request.session.save((err: any) => { + if (err) { + globals.logger.error(err.message); + globals.logger.debug(err.stack); + } + }); + } + + /** + * Registers a new user account + * @param username + * @param email + * @param passwordHash + * @param request + */ + public async register({username, email, passwordHash}: { username: string, email: string, passwordHash: string }, + request: any): Promise { + let mailValid = isEmail(email); + if (mailValid) { + try { + mailValid = (await legit(email)).isValid; + } catch (err) { + globals.logger.warn(`Mail legit check returned: ${err.message}`); + globals.logger.debug(err.stack); + mailValid = false; + } + } + if (!mailValid) { + throw new InvalidEmailError(email); + } + const user = await dataaccess.registerUser(username, email, passwordHash); + request.session.userId = user.id; + return user; + } + + /** + * Sets the frontend settings for the logged in user + * @param settings + * @param request + */ + public async setUserSettings({settings}: { settings: string }, request: any): Promise { + this.ensureLoggedIn(request); + const user = await User.findByPk(request.session.userId); + try { + user.frontendSettings = yaml.safeLoad(settings); + await user.save(); + return user.settings; + } catch (err) { + throw new GraphQLError("Invalid settings json."); + } + } + + /** + * Toggles a vote of a specific type on a post and returns the post and the result + * @param postId + * @param type + * @param request + */ + public async vote({postId, type}: { postId: number, type: dataaccess.VoteType }, request: any): + Promise<{ post: Post, voteType: dataaccess.VoteType }> { + this.ensureLoggedIn(request); + const post = await Post.findByPk(postId); + if (post) { + const voteType = await post.vote(request.session.userId, type); + return { + post, + voteType, + }; + } else { + throw new PostNotFoundError(postId); + } + } + + /** + * Creates a new post + * @param content + * @param activityId + * @param request + */ + public async createPost({content, activityId}: { content: string, activityId?: number }, request: any): + Promise { + this.ensureLoggedIn(request); + if (content.length > 2048) { + throw new GraphQLError("Content too long."); + } + const post = await dataaccess.createPost(content, request.session.userId, activityId); + globals.internalEmitter.emit(InternalEvents.GQLPOSTCREATE, post); + return post; + } + + /** + * Deletes a post if the user is either the author or a site admin. + * @param postId + * @param request + */ + public async deletePost({postId}: { postId: number }, request: any): Promise { + this.ensureLoggedIn(request); + const post = await Post.findByPk(postId, { + include: [{ + as: "rAuthor", + model: User, + }], + }); + const isAdmin = (await User.findOne({where: {id: request.session.userId}})).isAdmin; + if (post.rAuthor.id === request.session.userId || isAdmin) { + return await dataaccess.deletePost(post.id); + } else { + throw new GraphQLError("User is not author of the post."); + } + } + + /** + * Creates a chat with several members + * @param members + * @param request + */ + public async createChat({members}: { members?: number[] }, request: any): Promise { + this.ensureLoggedIn(request); + const chatMembers = [request.session.userId]; + if (members) { + chatMembers.push(...members); + } + return await dataaccess.createChat(...chatMembers); + } + + /** + * Sends a message into a chat the user has joined + * @param chatId + * @param content + * @param request + */ + public async sendMessage({chatId, content}: { chatId: number, content: string }, request: any): + Promise { + this.ensureLoggedIn(request); + const message = await dataaccess.sendChatMessage(request.session.userId, chatId, content); + globals.internalEmitter.emit(InternalEvents.GQLCHATMESSAGE, message); + return message; + } + + /** + * Sends a request to a specific user + * @param receiver + * @param type + * @param request + */ + public async sendRequest({receiver, type}: { receiver: number, type: dataaccess.RequestType }, request: any): + Promise { + this.ensureLoggedIn(request); + return dataaccess.createRequest(request.session.userId, receiver, type); + } + + /** + * Denies a request + * @param sender + * @param type + * @param request + */ + public async denyRequest({sender, type}: { sender: number, type: dataaccess.RequestType }, request: any) { + this.ensureLoggedIn(request); + const user = await User.findByPk(request.session.userId); + await user.acceptRequest(sender, type); + return true; + } + + /** + * Accepts a request + * @param sender + * @param type + * @param request + */ + public async acceptRequest({sender, type}: { sender: number, type: dataaccess.RequestType }, request: any) { + this.ensureLoggedIn(request); + const user = await User.findByPk(request.session.userId); + await user.acceptRequest(sender, type); + return true; + } + + /** + * Removes a friend + * @param friendId + * @param request + */ + public async removeFriend({friendId}: { friendId: number }, request: any): Promise { + this.ensureLoggedIn(request); + const user = await User.findByPk(request.session.userId); + return user.removeFriend(friendId); + } + + /** + * Creates a new group + * @param name + * @param members + * @param request + */ + public async createGroup({name, members}: { name: string, members: number[] }, request: any): Promise { + this.ensureLoggedIn(request); + return await dataaccess.createGroup(name, request.session.userId, members); + } + + /** + * Deletes a group if the user is either the creator or a site admin + * @param groupId + * @param request + */ + public async deleteGroup({groupId}: { groupId: number }, request: any): Promise { + this.ensureLoggedIn(request); + const user = await User.findByPk(request.session.userId); + const group = await Group.findByPk(groupId); + if (group) { + if (user.isAdmin || group.creatorId === user.id) { + await group.destroy(); + return true; + } + } else { + throw new GroupNotFoundError(groupId); + } + } + + /** + * Joins a group + * @param groupId + * @param request + */ + public async joinGroup({groupId}: { groupId: number }, request: any): Promise { + this.ensureLoggedIn(request); + return dataaccess.changeGroupMembership(groupId, request.session.userId, + dataaccess.MembershipChangeAction.ADD); + } + + /** + * Leaves a group + * @param groupId + * @param request + */ + public async leaveGroup({groupId}: { groupId: number }, request: any): Promise { + this.ensureLoggedIn(request); + return dataaccess.changeGroupMembership(groupId, request.session.userId, + dataaccess.MembershipChangeAction.REMOVE); + } + + /** + * Adds a user to the group admins + * @param groupId + * @param userId + * @param request + */ + public async addGroupAdmin({groupId, userId}: { groupId: number, userId: number }, request: any): Promise { + this.ensureLoggedIn(request); + const group = await Group.findByPk(groupId); + const user: User = await User.findByPk(request.session.userId); + if (group && !(await group.$has("rAdmins", user)) && (await group.creator()) !== user.id) { + throw new NotAGroupAdminError(groupId); + } + return dataaccess.changeGroupMembership(groupId, userId, + dataaccess.MembershipChangeAction.OP); + } + + /** + * Removes an admin from a group + * @param groupId + * @param userId + * @param request + */ + public async removeGroupAdmin({groupId, userId}: { groupId: number, userId: number }, + request: any): Promise { + this.ensureLoggedIn(request); + const group = await Group.findByPk(groupId); + const isCreator = Number(group.creatorId) === Number(request.session.userId); + const userIsCreator = Number(group.creatorId) === Number(userId); + if (group && !isCreator && Number(userId) !== Number(request.session.userId)) { + throw new NotTheGroupCreatorError(groupId); + } else if (userIsCreator) { + throw new GraphQLError( + "You are not allowed to remove a creator as an admin."); + } + return await dataaccess.changeGroupMembership(groupId, userId, + dataaccess.MembershipChangeAction.DEOP); + } + + /** + * Creates a new event for a specific group + * @param name + * @param dueDate + * @param groupId + * @param request + */ + public async createEvent({name, dueDate, groupId}: { name: string, dueDate: string, groupId: number }, + request: any): Promise { + this.ensureLoggedIn(request); + const date = new Date(Number(dueDate)); + const user: User = await User.findByPk(request.session.userId); + const group = await Group.findByPk(groupId, {include: [{association: "rAdmins"}]}); + if (!(await group.$has("rAdmins", user))) { + throw new NotAGroupAdminError(groupId); + } + const blacklisted = await dataaccess.checkBlacklisted(name); + if (blacklisted.length > 0) { + throw new BlacklistedError(blacklisted.map((p) => p.phrase), "event name"); + } + return group.$create("rEvent", {name, dueDate: date}); + } + + /** + * Deletes an event + * @param eventId + * @param request + */ + public async deleteEvent({eventId}: { eventId: number }, request: any): Promise { + this.ensureLoggedIn(request); + const event = await Event.findByPk(eventId, {include: [Group]}); + const user = await User.findByPk(request.session.userId); + const group = await event.group(); + if (await group.$has("rAdmins", user)) { + await event.destroy(); + return true; + } else { + throw new NotAGroupAdminError(group.id); + } + } + + /** + * Joins an event + * @param eventId + * @param request + */ + public async joinEvent({eventId}: { eventId: number }, request: any): Promise { + this.ensureLoggedIn(request); + const event = await Event.findByPk(eventId); + const self = await User.findByPk(request.session.userId); + await event.$add("rParticipants", self); + return event; + } + + /** + * Leaves an event + * @param eventId + * @param request + */ + public async leaveEvent({eventId}: { eventId: number }, request: any): Promise { + this.ensureLoggedIn(request); + const event = await Event.findByPk(eventId); + const self = await User.findByPk(request.session.userId); + await event.$remove("rParticipants", self); + return event; + } + + /** + * Creates a new activity or throws an error if the activity already exists + * @param name + * @param description + * @param points + * @param request + */ + public async createActivity({name, description, points}: { name: string, description: string, points: number }, + request: any): Promise { + this.ensureLoggedIn(request.session.userId); + const user = await User.findByPk(request.session.userId); + if (!user.isAdmin) { + throw new NotAnAdminError(); + } + const nameExists = await Activity.findOne({where: {name}}); + if (!nameExists) { + return Activity.create({name, description, points}); + } else { + throw new GraphQLError(`An activity with the name '${name}' already exists.`); + } + } + + /** + * Adds a phrase to the blaclist + * @param phrase + * @param languageCode + * @param request + */ + public async addToBlacklist({phrase, languageCode}: {phrase: string, languageCode?: string}, request: any): + Promise { + this.ensureLoggedIn(request); + const user = await User.findByPk(request.session.userId); + if (!user.isAdmin) { + throw new NotAnAdminError(); + } + const phraseExists = await BlacklistedPhrase.findOne( + {where: {phrase, language: languageCode}}); + if (!phraseExists) { + await BlacklistedPhrase.create({phrase, language: languageCode}); + return true; + } else { + return false; + } + } + /** + * Removes a phrase from the blacklist + * @param phrase + * @param languageCode + * @param request + */ + public async removeFromBlacklist({phrase, languageCode}: {phrase: string, languageCode: string}, request: any): + Promise { + this.ensureLoggedIn(request); + const user = await User.findByPk(request.session.userId); + if (!user.isAdmin) { + throw new NotAnAdminError(); + } + const phraseEntry = await BlacklistedPhrase.findOne( + {where: {phrase, language: languageCode}}); + if (phraseEntry) { + await phraseEntry.destroy(); + return true; + } else { + return false; + } + } } diff --git a/src/graphql/QueryResolver.ts b/src/graphql/QueryResolver.ts index 51a9828..91b94b6 100644 --- a/src/graphql/QueryResolver.ts +++ b/src/graphql/QueryResolver.ts @@ -1,5 +1,196 @@ +import {GraphQLError} from "graphql"; +import {Op} from "sequelize"; +import dataaccess from "../lib/dataAccess"; +import {ChatNotFoundError} from "../lib/errors/ChatNotFoundError"; +import {PostNotFoundGqlError} from "../lib/errors/graphqlErrors"; +import {GroupNotFoundError} from "../lib/errors/GroupNotFoundError"; +import {InvalidLoginError} from "../lib/errors/InvalidLoginError"; +import {RequestNotFoundError} from "../lib/errors/RequestNotFoundError"; +import {UserNotFoundError} from "../lib/errors/UserNotFoundError"; +import { + Activity, + BlacklistedPhrase, + ChatRoom, + Event, + Group, + Post, + Request, + User, +} from "../lib/models"; +import {BaseResolver} from "./BaseResolver"; +import {BlacklistedResult} from "./BlacklistedResult"; +import {SearchResult} from "./SearchResult"; +import {Token} from "./Token"; + /** * A class that provides functions to resolve queries */ -export class QueryResolver { +export class QueryResolver extends BaseResolver { + + /** + * Gets a user by id or handle + * @param userId + * @param handle + */ + public async getUser({userId, handle}: {userId?: number, handle?: string}): Promise { + let user: User; + if (userId) { + user = await User.findByPk(userId); + } else if (handle) { + user = await User.findOne({where: {handle}}); + } else { + throw new GraphQLError("No handle or userId provided"); + } + if (user) { + return user; + } else { + throw new UserNotFoundError(userId ?? handle); + } + } + + /** + * Returns the instance of the currently logged in user + * @param args + * @param request + */ + public async getSelf(args: null, request: any): Promise { + this.ensureLoggedIn(request); + return User.findByPk(request.session.userId); + } + + /** + * Returns a post for a given post id. + * @param postId + */ + public async getPost({postId}: {postId: number}): Promise { + const post = await Post.findByPk(postId); + if (post) { + return post; + } else { + throw new PostNotFoundGqlError(postId); + } + } + + /** + * Returns a chat for a given chat id + * @param chatId + */ + public async getChat({chatId}: {chatId: number}): Promise { + const chat = await ChatRoom.findByPk(chatId); + if (chat) { + return chat; + } else { + throw new ChatNotFoundError(chatId); + } + } + + /** + * Returns a group for a given group id. + * @param groupId + */ + public async getGroup({groupId}: {groupId: number}): Promise { + const group = await Group.findByPk(groupId); + if (group) { + return group; + } else { + throw new GroupNotFoundError(groupId); + } + } + + /** + * Returns the request for a given id. + * @param requestId + */ + public async getRequest({requestId}: {requestId: number}): Promise { + const request = await Request.findByPk(requestId); + if (request) { + return request; + } else { + throw new RequestNotFoundError(requestId); + } + } + + /** + * Searches for posts, groups, users, events and returns a search result. + * @param query + * @param first + * @param offset + */ + public async search({query, first, offset}: {query: number, first: number, offset: number}): Promise { + const limit = first; + const users = await User.findAll({ + limit, + offset, + where: { + [Op.or]: [ + {handle: {[Op.iRegexp]: query}}, + {username: {[Op.iRegexp]: query}}, + ], + }, + }); + const groups = await Group.findAll({ + limit, + offset, + where: {name: {[Op.iRegexp]: query}}, + }); + const posts = await Post.findAll({ + limit, + offset, + where: {content: {[Op.iRegexp]: query}}, + }); + const events = await Event.findAll({ + limit, + offset, + where: {name: {[Op.iRegexp]: query}}, + }); + return new SearchResult(users, groups, posts, events); + } + + /** + * Returns the posts with a specific sorting + * @param first + * @param offset + * @param sort + */ + public async getPosts({first, offset, sort}: {first: number, offset: number, sort: dataaccess.SortType}): + Promise { + return await dataaccess.getPosts(first, offset, sort); + } + + /** + * Returns all activities + */ + public async getActivities(): Promise { + return Activity.findAll(); + } + + /** + * Returns the token for a user by login + * @param email + * @param passwordHash + */ + public async getToken({email, passwordHash}: {email: string, passwordHash: string}): Promise { + const user = await dataaccess.getUserByLogin(email, passwordHash); + return new Token(await user.token(), Number(user.authExpire).toString()); + } + + /** + * Returns if a input phrase contains blacklisted phrases and which one + * @param phrase + */ + public async blacklisted({phrase}: {phrase: string}): Promise { + const phrases = await dataaccess.checkBlacklisted(phrase); + return new BlacklistedResult(phrases.length > 0, phrases + .map((p) => p.phrase)); + } + + /** + * Returns all blacklisted phrases with pagination + * @param first + * @param offset + */ + public async getBlacklistedPhrases({first, offset}: {first: number, offset: number}): Promise { + return (await BlacklistedPhrase.findAll({limit: first, offset})) + .map((p) => p.phrase); + } } diff --git a/src/graphql/SearchResult.ts b/src/graphql/SearchResult.ts new file mode 100644 index 0000000..739f023 --- /dev/null +++ b/src/graphql/SearchResult.ts @@ -0,0 +1,13 @@ +import {Group, Post, User, Event} from "../lib/models"; + +/** + * A class to wrap search results returned by the search resolver + */ +export class SearchResult { + constructor( + public users: User[], + public groups: Group[], + public posts: Post[], + public events: Event[], + ) {} +} diff --git a/src/graphql/Token.ts b/src/graphql/Token.ts new file mode 100644 index 0000000..f56d369 --- /dev/null +++ b/src/graphql/Token.ts @@ -0,0 +1,9 @@ +/** + * A class representing a token that can be used with bearer authentication + */ +export class Token { + constructor( + public value: string, + public expires: string, + ) {} +} diff --git a/src/lib/dataAccess.ts b/src/lib/dataAccess.ts index 11b7d46..8fc11c9 100644 --- a/src/lib/dataAccess.ts +++ b/src/lib/dataAccess.ts @@ -205,7 +205,7 @@ namespace dataaccess { * Deletes a post * @param postId */ - export async function deletePost(postId: number): Promise { + export async function deletePost(postId: number): Promise { try { const post = await models.Post.findByPk(postId, {include: [{model: Activity}, {association: "rAuthor"}]}); const activity = await post.activity(); diff --git a/src/lib/errors/InvalidEmailError.ts b/src/lib/errors/InvalidEmailError.ts new file mode 100644 index 0000000..ea97b66 --- /dev/null +++ b/src/lib/errors/InvalidEmailError.ts @@ -0,0 +1,10 @@ +import {BaseError} from "./BaseError"; + +/** + * An error that is thrown when a user tries to register with an invalid email + */ +export class InvalidEmailError extends BaseError { + constructor(email: string) { + super(`'${email}' is not a valid email address!`); + } +} diff --git a/src/lib/errors/NotAGroupAdminError.ts b/src/lib/errors/NotAGroupAdminError.ts new file mode 100644 index 0000000..2538c37 --- /dev/null +++ b/src/lib/errors/NotAGroupAdminError.ts @@ -0,0 +1,14 @@ +import * as status from "http-status"; +import {BaseError} from "./BaseError"; + +/** + * An error that is thrown when a non-admin tries to perform an admin action + */ +export class NotAGroupAdminError extends BaseError { + public readonly statusCode = status.FORBIDDEN; + + constructor(groupId: number) { + super(`You are not an admin of '${groupId}'`); + } + +} diff --git a/src/lib/errors/NotAnAdminError.ts b/src/lib/errors/NotAnAdminError.ts new file mode 100644 index 0000000..b9454d0 --- /dev/null +++ b/src/lib/errors/NotAnAdminError.ts @@ -0,0 +1,13 @@ +import {BaseError} from "./BaseError"; + +/** + * An error that is thrown when a non admin tries to perform an admin action + */ +export class NotAnAdminError extends BaseError { + + public readonly statusCode = httpStatus.FORBIDDEN; + + constructor() { + super("You are not a site admin!"); + } +} diff --git a/src/lib/errors/NotTheGroupCreatorError.ts b/src/lib/errors/NotTheGroupCreatorError.ts new file mode 100644 index 0000000..7b1e748 --- /dev/null +++ b/src/lib/errors/NotTheGroupCreatorError.ts @@ -0,0 +1,14 @@ +import * as status from "http-status"; +import {BaseError} from "./BaseError"; + +/** + * An error that is thrown when a non-admin tries to perform an admin action + */ +export class NotTheGroupCreatorError extends BaseError { + public readonly statusCode = status.FORBIDDEN; + + constructor(groupId: number) { + super(`You are not the creator of '${groupId}'`); + } + +} diff --git a/src/lib/errors/PostNotFoundError.ts b/src/lib/errors/PostNotFoundError.ts new file mode 100644 index 0000000..76fcf77 --- /dev/null +++ b/src/lib/errors/PostNotFoundError.ts @@ -0,0 +1,10 @@ +import {BaseError} from "./BaseError"; + +/** + * An error that is thrown when a post was not found + */ +export class PostNotFoundError extends BaseError { + constructor(postId: number) { + super(`Post '${postId}' not found!`); + } +} diff --git a/src/lib/errors/RequestNotFoundError.ts b/src/lib/errors/RequestNotFoundError.ts index cd94fc5..46fa064 100644 --- a/src/lib/errors/RequestNotFoundError.ts +++ b/src/lib/errors/RequestNotFoundError.ts @@ -5,7 +5,11 @@ import {BaseError} from "./BaseError"; * An error that is thrown when a request for a sender, receiver and type was not found */ export class RequestNotFoundError extends BaseError { - constructor(sender: number, receiver: number, type: dataaccess.RequestType) { - super(`Request with sender '${sender}' and receiver '${receiver}' of type '${type}' not found.`); + constructor(sender: number, receiver?: number, type?: dataaccess.RequestType) { + if (!receiver) { + super(`Request with id '${sender} not found.'`); + } else { + super(`Request with sender '${sender}' and receiver '${receiver}' of type '${type}' not found.`); + } } }