From c97d0ffe55e399ec11ee6057e36e707857b2bdd4 Mon Sep 17 00:00:00 2001 From: Trivernis Date: Sat, 28 Sep 2019 22:08:48 +0200 Subject: [PATCH] Api functions - added chat message functions - added markdown rendering - added custom error classes --- package-lock.json | 67 +++++++++++++++++++++++++---- package.json | 6 ++- src/default-config.yaml | 4 ++ src/lib/dataaccess/ChatMessage.ts | 8 ++++ src/lib/dataaccess/Chatroom.ts | 11 +++++ src/lib/dataaccess/DataObject.ts | 8 ++++ src/lib/dataaccess/Post.ts | 9 ++++ src/lib/dataaccess/Profile.ts | 22 ++++++++++ src/lib/dataaccess/User.ts | 1 + src/lib/dataaccess/index.ts | 51 +++++++++++++++------- src/lib/errors/BaseError.ts | 13 ++++++ src/lib/errors/ChatNotFoundError.ts | 7 +++ src/lib/errors/UserNotFoundError.ts | 7 +++ src/lib/errors/graphqlErrors.ts | 9 ++++ src/lib/markdown.ts | 36 ++++++++++++++++ src/public/graphql/schema.graphql | 33 +++++++++++--- src/routes/home.ts | 45 +++++++++++++++---- 17 files changed, 298 insertions(+), 39 deletions(-) create mode 100644 src/lib/errors/BaseError.ts create mode 100644 src/lib/errors/ChatNotFoundError.ts create mode 100644 src/lib/errors/UserNotFoundError.ts create mode 100644 src/lib/errors/graphqlErrors.ts create mode 100644 src/lib/markdown.ts diff --git a/package-lock.json b/package-lock.json index 4d87280..8e55c5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -171,6 +171,21 @@ "integrity": "sha512-SGGAhXLHDx+PK4YLNcNGa6goPf9XRWQNAUUbffkwVGGXIxmDKWyGGL4inzq2sPmExu431Ekb9aEMn9BkPqEYFA==", "dev": true }, + "@types/linkify-it": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-2.1.0.tgz", + "integrity": "sha512-Q7DYAOi9O/+cLLhdaSvKdaumWyHbm7HAk/bFwwyTuU0arR5yyCeW5GOoqt4tJTpDRxhpx9Q8kQL6vMpuw9hDSw==", + "dev": true + }, + "@types/markdown-it": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-0.0.9.tgz", + "integrity": "sha512-IFSepyZXbF4dgSvsk8EsgaQ/8Msv1I5eTL0BZ0X3iGO9jw6tCVtPG8HchIPm3wrkmGdqZOD42kE0zplVi1gYDA==", + "dev": true, + "requires": { + "@types/linkify-it": "*" + } + }, "@types/mime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", @@ -178,9 +193,9 @@ "dev": true }, "@types/node": { - "version": "12.7.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.2.tgz", - "integrity": "sha512-dyYO+f6ihZEtNPDcWNR1fkoTDf3zAK3lAABDze3mz6POyIercH0lEUawUFXlG8xaQZmm1yEBON/4TsYv/laDYg==", + "version": "12.7.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.8.tgz", + "integrity": "sha512-FMdVn84tJJdV+xe+53sYiZS4R5yn1mAIxfj+DVoNiQjTYz1+OYmjwEZr1ev9nU0axXwda0QDbYl06QHanRVH3A==", "dev": true }, "@types/pg": { @@ -1894,6 +1909,11 @@ "has-binary2": "~1.0.2" } }, + "entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz", + "integrity": "sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw==" + }, "env-variable": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/env-variable/-/env-variable-0.0.5.tgz", @@ -3329,12 +3349,6 @@ } } }, - "gulp-angular": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/gulp-angular/-/gulp-angular-0.1.2.tgz", - "integrity": "sha1-ljV2ul7qoDZqMf6l7S7AHvC0O3g=", - "dev": true - }, "gulp-minify": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/gulp-minify/-/gulp-minify-3.1.0.tgz", @@ -4214,6 +4228,14 @@ "resolve": "^1.1.7" } }, + "linkify-it": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz", + "integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==", + "requires": { + "uc.micro": "^1.0.1" + } + }, "load-json-file": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", @@ -4320,6 +4342,23 @@ "object-visit": "^1.0.0" } }, + "markdown-it": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz", + "integrity": "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==", + "requires": { + "argparse": "^1.0.7", + "entities": "~2.0.0", + "linkify-it": "^2.0.0", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + } + }, + "markdown-it-emoji": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/markdown-it-emoji/-/markdown-it-emoji-1.4.0.tgz", + "integrity": "sha1-m+4OmpkKljupbfaYDE/dsF37Tcw=" + }, "matchdep": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/matchdep/-/matchdep-2.0.0.tgz", @@ -4360,6 +4399,11 @@ "resolve-dir": "^1.0.0" } }, + "mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=" + }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -6897,6 +6941,11 @@ "integrity": "sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g==", "dev": true }, + "uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" + }, "uglify-js": { "version": "2.8.29", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", diff --git a/package.json b/package.json index 1d42af9..52e9130 100644 --- a/package.json +++ b/package.json @@ -29,13 +29,13 @@ "@types/graphql": "^14.2.3", "@types/http-status": "^0.2.30", "@types/js-yaml": "^3.12.1", - "@types/node": "^12.7.2", + "@types/markdown-it": "0.0.9", + "@types/node": "^12.7.8", "@types/pg": "^7.11.0", "@types/socket.io": "^2.1.2", "@types/winston": "^2.4.4", "delete": "^1.1.0", "gulp": "^4.0.2", - "gulp-angular": "^0.1.2", "gulp-minify": "^3.1.0", "gulp-sass": "^4.0.2", "gulp-typescript": "^5.0.1", @@ -58,6 +58,8 @@ "graphql-import": "^0.7.1", "http-status": "^1.3.2", "js-yaml": "^3.13.1", + "markdown-it": "^10.0.0", + "markdown-it-emoji": "^1.4.0", "pg": "^7.12.1", "pug": "^2.0.4", "socket.io": "^2.2.0", diff --git a/src/default-config.yaml b/src/default-config.yaml index e232512..e607f15 100644 --- a/src/default-config.yaml +++ b/src/default-config.yaml @@ -13,3 +13,7 @@ server: session: secret: REPLACE WITH SAFE RANDOM GENERATED SECRET cookieMaxAge: 604800000‬ # 7 days + +markdown: + plugins: + - 'markdown-it-emoji' diff --git a/src/lib/dataaccess/ChatMessage.ts b/src/lib/dataaccess/ChatMessage.ts index 5930679..afbc600 100644 --- a/src/lib/dataaccess/ChatMessage.ts +++ b/src/lib/dataaccess/ChatMessage.ts @@ -1,7 +1,15 @@ +import markdown from "../markdown"; import {Chatroom} from "./Chatroom"; import {User} from "./User"; export class ChatMessage { constructor(public author: User, public chat: Chatroom, public timestamp: number, public content: string) { } + + /** + * The content rendered by markdown-it. + */ + public htmlContent(): string { + return markdown.renderInline(this.content); + } } diff --git a/src/lib/dataaccess/Chatroom.ts b/src/lib/dataaccess/Chatroom.ts index c6fe144..080a285 100644 --- a/src/lib/dataaccess/Chatroom.ts +++ b/src/lib/dataaccess/Chatroom.ts @@ -6,6 +6,17 @@ export class Chatroom { constructor(private id: number) {} + /** + * Returns if the chat exists. + */ + public async exists(): Promise { + const result = await queryHelper.first({ + text: "SELECT id FROM chats WHERE id = $1", + values: [this.id], + }); + return !!result.id; + } + /** * Returns all members of a chatroom. */ diff --git a/src/lib/dataaccess/DataObject.ts b/src/lib/dataaccess/DataObject.ts index 019bcc3..9c598ac 100644 --- a/src/lib/dataaccess/DataObject.ts +++ b/src/lib/dataaccess/DataObject.ts @@ -7,6 +7,14 @@ export abstract class DataObject { constructor(public id: number, protected row?: any) { } + /** + * Returns if the object extists by trying to load data. + */ + public async exists() { + await this.loadDataIfNotExists(); + return this.dataLoaded; + } + protected abstract loadData(): Promise; /** diff --git a/src/lib/dataaccess/Post.ts b/src/lib/dataaccess/Post.ts index 08f9c4b..dffec19 100644 --- a/src/lib/dataaccess/Post.ts +++ b/src/lib/dataaccess/Post.ts @@ -1,3 +1,4 @@ +import markdown from "../markdown"; import {DataObject} from "./DataObject"; import {queryHelper} from "./index"; import dataaccess from "./index"; @@ -40,6 +41,14 @@ export class Post extends DataObject { return this.$content; } + /** + * the content rendered by markdown-it. + */ + public async htmlContent(): Promise { + await this.loadDataIfNotExists(); + return markdown.render(this.$content); + } + /** * The date the post was created at. */ diff --git a/src/lib/dataaccess/Profile.ts b/src/lib/dataaccess/Profile.ts index a3f3a46..33eb7eb 100644 --- a/src/lib/dataaccess/Profile.ts +++ b/src/lib/dataaccess/Profile.ts @@ -1,7 +1,29 @@ +import {Chatroom} from "./Chatroom"; import {queryHelper} from "./index"; import {User} from "./User"; export class Profile extends User { + + /** + * Returns all chatrooms (with pagination). + * @param first + * @param offset + */ + public async chats({first, offset}: {first: number, offset?: number}) { + first = first || 10; + offset = offset || 0; + + const result = await queryHelper.all({ + text: "SELECT chat FROM chat_members WHERE member = $1 LIMIT $2 OFFSET $3", + values: [this.id, first, offset], + }); + if (result) { + return result.map((row) => new Chatroom(row.chat)); + } else { + return []; + } + } + /** * Sets the greenpoints of a user. * @param points diff --git a/src/lib/dataaccess/User.ts b/src/lib/dataaccess/User.ts index de5b25e..b29dfcc 100644 --- a/src/lib/dataaccess/User.ts +++ b/src/lib/dataaccess/User.ts @@ -8,6 +8,7 @@ export class User extends DataObject { private $email: string; private $greenpoints: number; private $joinedAt: string; + private $exists: boolean; /** * The name of the user diff --git a/src/lib/dataaccess/index.ts b/src/lib/dataaccess/index.ts index db72696..3db84ab 100644 --- a/src/lib/dataaccess/index.ts +++ b/src/lib/dataaccess/index.ts @@ -1,6 +1,9 @@ import {Pool} from "pg"; +import {ChatNotFoundError} from "../errors/ChatNotFoundError"; +import {UserNotFoundError} from "../errors/UserNotFoundError"; import globals from "../globals"; import {QueryHelper} from "../QueryHelper"; +import {ChatMessage} from "./ChatMessage"; import {Chatroom} from "./Chatroom"; import {Post} from "./Post"; import {Profile} from "./Profile"; @@ -27,6 +30,9 @@ function generateHandle(username: string) { return `${username}.${Buffer.from(Date.now().toString()).toString("base64")}`; } +/** + * Namespace with functions to fetch initial data for wrapping. + */ namespace dataaccess { export const pool: Pool = dbClient; @@ -39,24 +45,20 @@ namespace dataaccess { await queryHelper.createTables(); } - /** - * Returns the user by id - * @param userId - */ - export function getUser(userId: number) { - return new User(userId); - } - /** * Returns the user by handle. * @param userHandle */ - export async function getUserByHandle(userHandle: string) { + export async function getUserByHandle(userHandle: string): Promise { const result = await queryHelper.first({ text: "SELECT * FROM users WHERE users.handle = $1", values: [userHandle], }); - return new User(result.id, result); + if (result) { + return new User(result.id, result); + } else { + throw new UserNotFoundError(userHandle); + } } /** @@ -72,7 +74,7 @@ namespace dataaccess { if (result) { return new Profile(result.id, result); } else { - return null; + throw new UserNotFoundError(email); } } @@ -94,7 +96,7 @@ namespace dataaccess { * Returns a post for a given postId.s * @param postId */ - export async function getPost(postId: number) { + export async function getPost(postId: number): Promise { const result = await queryHelper.first({ text: "SELECT * FROM posts WHERE id = $1", values: [postId], @@ -112,7 +114,7 @@ namespace dataaccess { * @param authorId * @param type */ - export async function createPost(content: string, authorId: number, type?: string) { + export async function createPost(content: string, authorId: number, type?: string): Promise { const result = await queryHelper.first({ text: "INSERT INTO posts (content, author, type) VALUES ($1, $2, $3) RETURNING *", values: [content, authorId, type], @@ -124,7 +126,7 @@ namespace dataaccess { * Deletes a post * @param postId */ - export async function deletePost(postId: number) { + export async function deletePost(postId: number): Promise { const result = await queryHelper.first({ text: "DELETE FROM posts WHERE posts.id = $1", values: [postId], @@ -136,7 +138,7 @@ namespace dataaccess { * Creates a chatroom containing two users * @param members */ - export async function createChat(...members: number[]) { + export async function createChat(...members: number[]): Promise { const idResult = await queryHelper.first({ text: "INSERT INTO chats (id) values (nextval('chats_id_seq'::regclass)) RETURNING *;", }); @@ -161,6 +163,25 @@ namespace dataaccess { return new Chatroom(id); } + /** + * Sends a message into a chat. + * @param authorId + * @param chatId + * @param content + */ + export async function sendChatMessage(authorId: number, chatId: number, content: string) { + const chat = new Chatroom(chatId); + if ((await chat.exists())) { + const result = await queryHelper.first({ + text: "INSERT INTO chat_messages (chat, author, content, created_at) values ($1, $2, $3) RETURNING *", + values: [chatId, authorId, content], + }); + return new ChatMessage(new User(result.author), chat, result.timestamp, result.content); + } else { + throw new ChatNotFoundError(chatId); + } + } + /** * Enum representing the types of votes that can be performed on a post. */ diff --git a/src/lib/errors/BaseError.ts b/src/lib/errors/BaseError.ts new file mode 100644 index 0000000..f99171d --- /dev/null +++ b/src/lib/errors/BaseError.ts @@ -0,0 +1,13 @@ +import {GraphQLError} from "graphql"; + +/** + * Base error class. + */ +export class BaseError extends Error { + public readonly graphqlError: GraphQLError; + + constructor(message?: string, friendlyMessage?: string) { + super(message); + this.graphqlError = new GraphQLError(friendlyMessage || message); + } +} diff --git a/src/lib/errors/ChatNotFoundError.ts b/src/lib/errors/ChatNotFoundError.ts new file mode 100644 index 0000000..1d03525 --- /dev/null +++ b/src/lib/errors/ChatNotFoundError.ts @@ -0,0 +1,7 @@ +import {BaseError} from "./BaseError"; + +export class ChatNotFoundError extends BaseError { + constructor(chatId: number) { + super(`Chat with id ${chatId} not found.`); + } +} diff --git a/src/lib/errors/UserNotFoundError.ts b/src/lib/errors/UserNotFoundError.ts new file mode 100644 index 0000000..7869242 --- /dev/null +++ b/src/lib/errors/UserNotFoundError.ts @@ -0,0 +1,7 @@ +import {BaseError} from "./BaseError"; + +export class UserNotFoundError extends BaseError { + constructor(username: string) { + super(`User ${username} not found!`); + } +} diff --git a/src/lib/errors/graphqlErrors.ts b/src/lib/errors/graphqlErrors.ts new file mode 100644 index 0000000..9784712 --- /dev/null +++ b/src/lib/errors/graphqlErrors.ts @@ -0,0 +1,9 @@ +import {GraphQLError} from "graphql"; +import {BaseError} from "./BaseError"; + +export class NotLoggedInGqlError extends GraphQLError { + + constructor() { + super("Not logged in"); + } +} diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts new file mode 100644 index 0000000..fb164ec --- /dev/null +++ b/src/lib/markdown.ts @@ -0,0 +1,36 @@ +import * as MarkdownIt from "markdown-it/lib"; +import globals from "./globals"; + +namespace markdown { + + const md = new MarkdownIt(); + + for (const pluginName of globals.config.markdown.plugins) { + try { + const plugin = require(pluginName); + if (plugin) { + md.use(plugin); + } + } catch (err) { + globals.logger.warn(`Markdown-it plugin '${pluginName}' not found!`); + } + } + + /** + * Renders the markdown string inline (without blocks). + * @param markdownString + */ + export function renderInline(markdownString: string) { + return md.renderInline(markdownString); + } + + /** + * Renders the markdown string. + * @param markdownString + */ + export function render(markdownString: string) { + return md.render(markdownString); + } +} + +export default markdown; diff --git a/src/public/graphql/schema.graphql b/src/public/graphql/schema.graphql index 60c32e3..2ff19cf 100644 --- a/src/public/graphql/schema.graphql +++ b/src/public/graphql/schema.graphql @@ -53,7 +53,7 @@ type Mutation { denyRequest(requestId: ID!): Boolean "send a message in a Chatroom" - sendMessage(chatId: ID!, content: String!): Boolean + sendMessage(chatId: ID!, content: String!): ChatMessage "create the post" createPost(content: String!): Boolean @@ -70,6 +70,9 @@ type User { "name of the User" name: String! + "returns the chatrooms the user joined." + chats(first: Int=10, offset: Int): [ChatRoom] + "unique identifier name from the User" handle: String! @@ -85,7 +88,7 @@ type User { "creation date of the user account" joinedAt: String! - "returns all friends of the user" + "all friends of the user" friends: [User] "all request for groupChats/friends/events" @@ -95,9 +98,12 @@ type User { "represents a single user post" type Post { - "returns the text of the post" + "the text of the post" content: String + "the content of the post rendered by markdown-it" + htmlContent: String + "upvotes of the Post" upvotes: Int! @@ -110,7 +116,7 @@ type Post { "date the post was created" creationDate: String! - "returns the type of vote the user performed on the post" + "the type of vote the user performed on the post" userVote: VoteType } @@ -135,12 +141,29 @@ type ChatRoom { members: [User!] "return a specfic range of messages posted in the chat" - getMessages(first: Int, offset: Int): [String] + getMessages(first: Int, offset: Int): [ChatMessage] "id of the chat" id: ID! } +type ChatMessage { + "The author of the chat message." + author: User + + "The chatroom the message was posted in" + chat: ChatRoom + + "The timestamp when the message was posted (epoch)." + timestamp: Int + + "The content of the message." + content: String + + "The content of the message rendered by markdown-it." + htmlContent: String +} + "represents the type of vote performed on a post" enum VoteType { UPVOTE diff --git a/src/routes/home.ts b/src/routes/home.ts index 1bb397b..cd9fec9 100644 --- a/src/routes/home.ts +++ b/src/routes/home.ts @@ -3,8 +3,12 @@ import {GraphQLError} from "graphql"; import * as status from "http-status"; import {Server} from "socket.io"; import dataaccess from "../lib/dataaccess"; +import {Chatroom} from "../lib/dataaccess/Chatroom"; import {Post} from "../lib/dataaccess/Post"; import {Profile} from "../lib/dataaccess/Profile"; +import {User} from "../lib/dataaccess/User"; +import {NotLoggedInGqlError} from "../lib/errors/graphqlErrors"; +import globals from "../lib/globals"; import {is} from "../lib/regex"; import Route from "../lib/Route"; @@ -48,14 +52,14 @@ class HomeRoute extends Route { return new Profile(req.session.userId); } else { res.status(status.UNAUTHORIZED); - return new GraphQLError("Not logged in"); + return new NotLoggedInGqlError(); } }, async getUser({userId, handle}: {userId: number, handle: string}) { if (handle) { return await dataaccess.getUserByHandle(handle); } else if (userId) { - return dataaccess.getUser(userId); + return new User(userId); } else { res.status(status.BAD_REQUEST); return new GraphQLError("No userId or handle provided."); @@ -69,19 +73,28 @@ class HomeRoute extends Route { return new GraphQLError("No postId given."); } }, + async getChat({chatId}: {chatId: number}) { + if (chatId) { + return new Chatroom(chatId); + } else { + res.status(status.BAD_REQUEST); + return new GraphQLError("No chatId given."); + } + }, acceptCookies() { req.session.cookiesAccepted = true; return true; }, async login({email, passwordHash}: {email: string, passwordHash: string}) { if (email && passwordHash) { - const user = await dataaccess.getUserByLogin(email, passwordHash); - if (user && user.id) { + try { + const user = await dataaccess.getUserByLogin(email, passwordHash); req.session.userId = user.id; return user; - } else { + } catch (err) { + globals.logger.verbose(`Failed to login user '${email}'`); res.status(status.BAD_REQUEST); - return new GraphQLError("Invalid login data."); + return err.graphqlError; } } else { res.status(status.BAD_REQUEST); @@ -94,7 +107,7 @@ class HomeRoute extends Route { return true; } else { res.status(status.UNAUTHORIZED); - return new GraphQLError("Not logged in."); + return new NotLoggedInGqlError(); } }, async register({username, email, passwordHash}: {username: string, email: string, passwordHash: string}) { @@ -165,7 +178,23 @@ class HomeRoute extends Route { } else { res.status(status.UNAUTHORIZED); - return new GraphQLError("Not logged in."); + return new NotLoggedInGqlError(); + } + }, + async sendChatMessage({chatId, content}: {chatId: number, content: string}) { + if (!req.session.userId) { + return new NotLoggedInGqlError(); + } + if (chatId && content) { + try { + return await dataaccess.sendChatMessage(req.session.userId, chatId, content); + } catch (err) { + res.status(status.BAD_REQUEST); + return err.graphqlError; + } + } else { + res.status(status.BAD_REQUEST); + return new GraphQLError("No chatId or content given."); } }, };