diff --git a/gulpfile.js b/gulpfile.js index c1abd43..a5853d1 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -3,7 +3,7 @@ const sass = require('gulp-sass'); const ts = require('gulp-typescript'); const minify = require('gulp-minify'); const del = require('delete'); - +const gulp = require('gulp'); function clearDist(cb) { del('dist/*', cb); diff --git a/package-lock.json b/package-lock.json index 7baeb51..8e55c5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,15 @@ "@types/node": "*" } }, + "@types/compression": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.0.1.tgz", + "integrity": "sha512-GuoIYzD70h+4JUqUabsm31FGqvpCYHGKcLtor7nQ/YvUyNX0o9SJZ9boFI5HjFfbOda5Oe/XOvNK6FES8Y/79w==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/connect": { "version": "3.4.32", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.32.tgz", @@ -150,10 +159,32 @@ "integrity": "sha512-UoCovaxbJIxagCvVfalfK7YaNhmxj3BQFRQ2RHQKLiu+9wNXhJnlbspsLHt/YQM99IaLUUFJNzCwzc6W0ypMeQ==", "dev": true }, + "@types/http-status": { + "version": "0.2.30", + "resolved": "https://registry.npmjs.org/@types/http-status/-/http-status-0.2.30.tgz", + "integrity": "sha512-wcBc5XEOMmhuoWfNhwnpw8+tVAsueUeARxCTcRQ0BCN5V/dyKQBJNWdxmvcZW5IJWoeU47UWQ+ACCg48KKnqyA==", + "dev": true + }, "@types/js-yaml": { "version": "3.12.1", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.1.tgz", - "integrity": "sha512-SGGAhXLHDx+PK4YLNcNGa6goPf9XRWQNAUUbffkwVGGXIxmDKWyGGL4inzq2sPmExu431Ekb9aEMn9BkPqEYFA==" + "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", @@ -162,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": { @@ -215,6 +246,7 @@ "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/winston/-/winston-2.4.4.tgz", "integrity": "sha512-BVGCztsypW8EYwJ+Hq+QNYiT/MUyCif0ouBH+flrY66O5W+KIXAMML6E/0fJpm7VjIzgangahl5S03bJJQGrZw==", + "dev": true, "requires": { "winston": "*" } @@ -1310,6 +1342,40 @@ "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz", "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=" }, + "compressible": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.17.tgz", + "integrity": "sha512-BGHeLCK1GV7j1bSmQQAi26X+GgWcTjLr/0tzSvMCl3LH1w1IJ4PFSPoV5316b30cneTziC+B1a+3OjoSUcQYmw==", + "requires": { + "mime-db": ">= 1.40.0 < 2" + } + }, + "compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "dependencies": { + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1361,9 +1427,9 @@ } }, "connect-pg-simple": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-6.0.0.tgz", - "integrity": "sha512-6pQnRSGFyswyHMdKQp5C+g78fjU/1/6eY05VeixXwMixw5KYhAcoOCXyf8TdPE1IzRLNDBMQi64vojXK/HMXVw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-6.0.1.tgz", + "integrity": "sha512-zW5AOtRNOLcXxphSmQ+oYj0snlLs1Je3u5K2NWyF7WhMVoPvnQXraK2wzS8f7qLwhMcmYukah2ymu0Gdxf7Qsg==", "requires": { "pg": "^7.4.3" } @@ -1843,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", @@ -2481,8 +2552,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -2503,14 +2573,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2525,20 +2593,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -2655,8 +2720,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -2668,7 +2732,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -2683,7 +2746,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -2795,8 +2857,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -2808,7 +2869,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -2930,7 +2990,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -2950,7 +3009,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -2994,8 +3052,7 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.3", @@ -3633,6 +3690,11 @@ "sshpk": "^1.7.0" } }, + "http-status": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/http-status/-/http-status-1.3.2.tgz", + "integrity": "sha512-vR1YTaDyi2BukI0UiH01xy92oiZi4in7r0dmSPnrZg72Vu1SzyOLalwWP5NUk1rNiB2L+XVK2lcSVOqaertX8A==" + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -4166,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", @@ -4272,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", @@ -4312,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", @@ -6849,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 0fb8448..52e9130 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "author": "SoftEngI", "license": "ISC", "devDependencies": { + "@types/compression": "^1.0.1", "@types/connect-pg-simple": "^4.2.0", "@types/cookie-parser": "^1.4.2", "@types/express": "^4.17.1", @@ -26,9 +27,13 @@ "@types/express-socket.io-session": "^1.3.2", "@types/fs-extra": "^8.0.0", "@types/graphql": "^14.2.3", - "@types/node": "^12.7.2", + "@types/http-status": "^0.2.30", + "@types/js-yaml": "^3.12.1", + "@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-minify": "^3.1.0", @@ -40,9 +45,8 @@ "typescript": "^3.5.3" }, "dependencies": { - "@types/js-yaml": "^3.12.1", - "@types/winston": "^2.4.4", - "connect-pg-simple": "^6.0.0", + "compression": "^1.7.4", + "connect-pg-simple": "^6.0.1", "cookie-parser": "^1.4.4", "express": "^4.17.1", "express-graphql": "^0.9.0", @@ -52,7 +56,10 @@ "g": "^2.0.1", "graphql": "^14.4.2", "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/app.ts b/src/app.ts index c7079ac..5801771 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,11 +1,23 @@ +import * as compression from "compression"; +import connectPgSimple = require("connect-pg-simple"); +import * as cookieParser from "cookie-parser"; import * as express from "express"; +import * as graphqlHTTP from "express-graphql"; +import * as session from "express-session"; +import sharedsession = require("express-socket.io-session"); +import {buildSchema} from "graphql"; +import {importSchema} from "graphql-import"; import * as http from "http"; import * as path from "path"; import * as socketIo from "socket.io"; -import dataaccess from "./lib/dataaccess"; +import dataaccess, {queryHelper} from "./lib/dataaccess"; import globals from "./lib/globals"; import routes from "./routes"; +const logger = globals.logger; + +const PgSession = connectPgSimple(session); + class App { public app: express.Application; public io: socketIo.Server; @@ -23,10 +35,47 @@ class App { public async init() { await dataaccess.init(); await routes.ioListeners(this.io); + + const appSession = session({ + cookie: { + maxAge: Number(globals.config.session.cookieMaxAge) || 604800000, + secure: "auto", + }, + resave: false, + saveUninitialized: false, + secret: globals.config.session.secret, + store: new PgSession({ + pool: dataaccess.pool, + tableName: "user_sessions", + }), + }); + + this.io.use(sharedsession(appSession, {autoSave: true})); + this.app.set("views", path.join(__dirname, "views")); this.app.set("view engine", "pug"); + this.app.set("trust proxy", 1); + + this.app.use(compression()); + this.app.use(express.json()); + this.app.use(express.urlencoded({extended: false})); this.app.use(express.static(path.join(__dirname, "public"))); + this.app.use(cookieParser()); + this.app.use(appSession); + this.app.use((req, res, next) => { + logger.verbose(`${req.method} ${req.url}`); + next(); + }); this.app.use(routes.router); + this.app.use("/graphql", graphqlHTTP((request, response) => { + return { + // @ts-ignore all + context: {session: request.session}, + graphiql: true, + rootValue: routes.resolvers(request, response), + schema: buildSchema(importSchema(path.join(__dirname, "./public/graphql/schema.graphql"))), + }; + })); } /** @@ -34,11 +83,11 @@ class App { */ public start() { if (globals.config.server.port) { - globals.logger.info(`Starting server...`); + logger.info(`Starting server...`); this.app.listen(globals.config.server.port); - globals.logger.info(`Server running on port ${globals.config.server.port}`); + logger.info(`Server running on port ${globals.config.server.port}`); } else { - globals.logger.error("No port specified in the config." + + logger.error("No port specified in the config." + "Please configure a port in the config.yaml."); } } diff --git a/src/default-config.yaml b/src/default-config.yaml index 417ccda..fda957a 100644 --- a/src/default-config.yaml +++ b/src/default-config.yaml @@ -9,3 +9,14 @@ database: # http server configuration server: port: 8080 + +session: + secret: REPLACE WITH SAFE RANDOM GENERATED SECRET + cookieMaxAge: 604800000‬ # 7 days + +markdown: + plugins: + - 'markdown-it-emoji' + +logging: + level: info diff --git a/src/lib/MemoryCache.ts b/src/lib/MemoryCache.ts new file mode 100644 index 0000000..17890d8 --- /dev/null +++ b/src/lib/MemoryCache.ts @@ -0,0 +1,73 @@ +import * as crypto from "crypto"; +import {EventEmitter} from "events"; + +export class MemoryCache extends EventEmitter { + private cacheItems: any = {}; + private cacheExpires: any = {}; + private expireCheck: NodeJS.Timeout; + + /** + * Creates interval function. + * @param ttl + */ + constructor(private ttl: number = 500) { + super(); + this.expireCheck = setInterval(() => this.checkExpires(), ttl / 2); + } + + /** + * Creates a md5 hash of the given key. + * @param key + */ + public hashKey(key: string): string { + const hash = crypto.createHash("md5"); + const data = hash.update(key, "utf8"); + return data.digest("hex"); + } + + /** + * Sets an entry. + * @param key + * @param value + */ + public set(key: string, value: any) { + this.cacheItems[key] = value; + this.cacheExpires[key] = Date.now() + this.ttl; + this.emit("set", key, value); + } + + /** + * Returns the entry stored with the given key. + * @param key + */ + public get(key: string) { + if (this.cacheItems.hasOwnProperty(key)) { + this.emit("hit", key, this.cacheItems[key]); + return this.cacheItems[key]; + } else { + this.emit("miss", key); + } + } + + /** + * Deletes a cache item. + * @param key + */ + public delete(key: string) { + this.emit("delete", key); + delete this.cacheItems[key]; + } + + /** + * Checks expires and clears items that are over the expire value. + */ + private checkExpires() { + for (const [key, value] of Object.entries(this.cacheExpires)) { + if (value < Date.now()) { + this.emit("delete", key); + delete this.cacheItems[key]; + delete this.cacheExpires[key]; + } + } + } +} diff --git a/src/lib/QueryHelper.ts b/src/lib/QueryHelper.ts index 5970f70..01c98b8 100644 --- a/src/lib/QueryHelper.ts +++ b/src/lib/QueryHelper.ts @@ -11,6 +11,10 @@ import globals from "./globals"; const logger = globals.logger; +export interface IAdvancedQueryConfig extends QueryConfig { + cache?: boolean; +} + /** * Transaction class to wrap SQL transactions. */ @@ -54,7 +58,7 @@ export class SqlTransaction { /** * Releases the client back to the pool. */ - public async release() { + public release() { this.client.release(); } } @@ -101,7 +105,7 @@ export class QueryHelper { * executes the sql query with values and returns all results. * @param query */ - public async all(query: QueryConfig): Promise { + public async all(query: IAdvancedQueryConfig): Promise { const result = await this.query(query); return result.rows; } @@ -110,7 +114,7 @@ export class QueryHelper { * executes the sql query with values and returns the first result. * @param query */ - public async first(query: QueryConfig): Promise { + public async first(query: IAdvancedQueryConfig): Promise { const result = await this.query(query); if (result.rows && result.rows.length > 0) { return result.rows[0]; @@ -129,11 +133,26 @@ export class QueryHelper { * Queries the database with error handling. * @param query - the sql and values to execute */ - private async query(query: QueryConfig): Promise { + private async query(query: IAdvancedQueryConfig): Promise { try { - return await this.pool.query(query); + query.text = query.text.replace(/[\r\n]/g, " "); + globals.logger.silly(`Executing sql '${JSON.stringify(query)}'`); + + if (query.cache) { + const key = globals.cache.hashKey(JSON.stringify(query)); + const cacheResult = globals.cache.get(key); + if (cacheResult) { + return cacheResult; + } else { + const result = await this.pool.query(query); + globals.cache.set(key, result); + return result; + } + } else { + return await this.pool.query(query); + } } catch (err) { - logger.debug(`Error on query "${query}".`); + logger.debug(`Error on query "${JSON.stringify(query)}".`); logger.error(`Sql query failed: ${err}`); logger.verbose(err.stack); return { diff --git a/src/lib/dataaccess/ChatMessage.ts b/src/lib/dataaccess/ChatMessage.ts new file mode 100644 index 0000000..bbad8ad --- /dev/null +++ b/src/lib/dataaccess/ChatMessage.ts @@ -0,0 +1,18 @@ +import markdown from "../markdown"; +import {Chatroom} from "./Chatroom"; +import {User} from "./User"; + +export class ChatMessage { + constructor( + public readonly author: User, + public readonly chat: Chatroom, + public readonly createdAt: number, + public readonly 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 new file mode 100644 index 0000000..0787637 --- /dev/null +++ b/src/lib/dataaccess/Chatroom.ts @@ -0,0 +1,74 @@ +import globals from "../globals"; +import {ChatMessage} from "./ChatMessage"; +import {queryHelper} from "./index"; +import {User} from "./User"; + +export class Chatroom { + + constructor(private readonly id: number) { + this.id = Number(id); + } + + /** + * 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. + */ + public async members(): Promise { + const result = await queryHelper.all({ + cache: true, + text: `SELECT * FROM chat_members + JOIN users ON (chat_members.member = users.id) + WHERE chat_members.chat = $1;`, + values: [this.id], + }); + const chatMembers = []; + for (const row of result) { + const user = new User(row.id, row); + chatMembers.push(user); + } + return chatMembers; + } + + /** + * Returns messages of the chat + * @param limit - the limit of messages to return + * @param offset - the offset of messages to return + * @param containing - filter by containing + */ + public async messages({first, offset, containing}: {first?: number, offset?: number, containing?: string}) { + const lim = first || 16; + const offs = offset || 0; + + const result = await queryHelper.all({ + cache: true, + text: "SELECT * FROM chat_messages WHERE chat = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + values: [this.id, lim, offs], + }); + + const messages = []; + const users: any = {}; + for (const row of result) { + if (!users[row.author]) { + const user = new User(row.author); + await user.exists(); + users[row.author] = user; + } + messages.push(new ChatMessage(users[row.author], this, row.created_at, row.content)); + } + if (containing) { + return messages.filter((x) => x.content.includes(containing)); + } else { + return messages; + } + } +} diff --git a/src/lib/dataaccess/DataObject.ts b/src/lib/dataaccess/DataObject.ts index 1890425..26fc809 100644 --- a/src/lib/dataaccess/DataObject.ts +++ b/src/lib/dataaccess/DataObject.ts @@ -1,10 +1,23 @@ /** * abstact DataObject class */ -export abstract class DataObject { +import {EventEmitter} from "events"; + +export abstract class DataObject extends EventEmitter { protected dataLoaded: boolean = false; + private loadingData: boolean = false; constructor(public id: number, protected row?: any) { + super(); + this.id = Number(id); + } + + /** + * Returns if the object extists by trying to load data. + */ + public async exists() { + await this.loadDataIfNotExists(); + return this.dataLoaded; } protected abstract loadData(): Promise; @@ -12,9 +25,16 @@ export abstract class DataObject { /** * Loads data from the database if data has not been loaded */ - protected loadDataIfNotExists() { - if (this.dataLoaded) { - this.loadData(); + protected async loadDataIfNotExists() { + if (!this.dataLoaded && !this.loadingData) { + this.loadingData = true; + await this.loadData(); + this.loadingData = false; + this.emit("loaded"); + } else if (this.loadingData) { + return new Promise((res) => { + this.on("loaded", () => res()); + }); } } } diff --git a/src/lib/dataaccess/Post.ts b/src/lib/dataaccess/Post.ts index b4193c4..7908f62 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"; @@ -5,8 +6,6 @@ import {User} from "./User"; export class Post extends DataObject { public readonly id: number; - private $upvotes: number; - private $downvotes: number; private $createdAt: string; private $content: string; private $author: number; @@ -16,31 +15,47 @@ export class Post extends DataObject { * Returns the upvotes of a post. */ public async upvotes(): Promise { - this.loadDataIfNotExists(); - return this.$upvotes; + const result = await queryHelper.first({ + cache: true, + text: "SELECT COUNT(*) count FROM votes WHERE item_id = $1 AND vote_type = 'UPVOTE'", + values: [this.id], + }); + return result.count; } /** * Returns the downvotes of the post */ public async downvotes(): Promise { - this.loadDataIfNotExists(); - return this.$downvotes; + const result = await queryHelper.first({ + cache: true, + text: "SELECT COUNT(*) count FROM votes WHERE item_id = $1 AND vote_type = 'DOWNVOTE'", + values: [this.id], + }); + return result.count; } /** * The content of the post (markdown) */ public async content(): Promise { - this.loadDataIfNotExists(); + await this.loadDataIfNotExists(); 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. */ public async createdAt(): Promise { - this.loadDataIfNotExists(); + await this.loadDataIfNotExists(); return this.$createdAt; } @@ -48,7 +63,7 @@ export class Post extends DataObject { * The autor of the post. */ public async author(): Promise { - this.loadDataIfNotExists(); + await this.loadDataIfNotExists(); return new User(this.$author); } @@ -67,6 +82,7 @@ export class Post extends DataObject { */ public async userVote(userId: number): Promise { const result = await queryHelper.first({ + cache: true, text: "SELECT vote_type FROM votes WHERE user_id = $1 AND item_id = $2", values: [userId, this.id], }); @@ -77,6 +93,34 @@ export class Post extends DataObject { } } + /** + * Performs a vote on a post. + * @param userId + * @param type + */ + public async vote(userId: number, type: dataaccess.VoteType): Promise { + const uVote = await this.userVote(userId); + if (uVote === type) { + await queryHelper.first({ + text: "DELETE FROM votes WHERE item_id = $1 AND user_id = $2", + values: [this.id, userId], + }); + } else { + if (uVote) { + await queryHelper.first({ + text: "UPDATE votes SET vote_type = $1 WHERE user_id = $2 AND item_id = $3", + values: [type, userId, this.id], + }); + } else { + await queryHelper.first({ + text: "INSERT INTO votes (user_id, item_id, vote_type) values ($1, $2, $3)", + values: [userId, this.id, type], + }); + } + return type; + } + } + /** * Loads the data from the database if needed. */ @@ -86,6 +130,7 @@ export class Post extends DataObject { result = this.row; } else { result = await queryHelper.first({ + cache: true, text: "SELECT * FROM posts WHERE posts.id = $1", values: [this.id], }); @@ -93,8 +138,6 @@ export class Post extends DataObject { if (result) { this.$author = result.author; this.$content = result.content; - this.$downvotes = result.downvotes; - this.$upvotes = result.upvotes; this.$createdAt = result.created_at; this.$type = result.type; this.dataLoaded = true; diff --git a/src/lib/dataaccess/Profile.ts b/src/lib/dataaccess/Profile.ts new file mode 100644 index 0000000..090e6da --- /dev/null +++ b/src/lib/dataaccess/Profile.ts @@ -0,0 +1,163 @@ +import {RequestNotFoundError} from "../errors/RequestNotFoundError"; +import {Chatroom} from "./Chatroom"; +import dataaccess, {queryHelper} from "./index"; +import {User} from "./User"; +import {Request} from "./Request"; + +export class Profile extends User { + + /** + * Returns all chatrooms (with pagination). + * Skips the query if the user doesn't exist. + * @param first + * @param offset + */ + public async chats({first, offset}: {first: number, offset?: number}): Promise { + if (!(await this.exists())) { + return []; + } + 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 []; + } + } + + /** + * Returns all open requests the user has send. + */ + public async sentRequests() { + const result = await queryHelper.all({ + cache: true, + text: "SELECT * FROM requests WHERE sender = $1", + values: [this.id], + }); + return this.getRequests(result); + } + + /** + * Returns all received requests of the user. + */ + public async receivedRequests() { + const result = await queryHelper.all({ + cache: true, + text: "SELECT * FROM requests WHERE receiver = $1", + values: [this.id], + }); + return this.getRequests(result); + } + + /** + * Sets the greenpoints of a user. + * @param points + */ + public async setGreenpoints(points: number): Promise { + const result = await queryHelper.first({ + text: "UPDATE users SET greenpoints = $1 WHERE id = $2 RETURNING greenpoints", + values: [points, this.id], + }); + return result.greenpoints; + } + + /** + * Sets the email of the user + * @param email + */ + public async setEmail(email: string): Promise { + const result = await queryHelper.first({ + text: "UPDATE users SET email = $1 WHERE users.id = $2 RETURNING email", + values: [email, this.id], + }); + return result.email; + } + + /** + * Updates the handle of the user + */ + public async setHandle(handle: string): Promise { + const result = await queryHelper.first({ + text: "UPDATE users SET handle = $1 WHERE id = $2", + values: [handle, this.id], + }); + return result.handle; + } + + /** + * Sets the username of the user + * @param name + */ + public async setName(name: string): Promise { + const result = await queryHelper.first({ + text: "UPDATE users SET name = $1 WHERE id = $2", + values: [name, this.id], + }); + return result.name; + } + + /** + * Denys a request. + * @param sender + * @param type + */ + public async denyRequest(sender: number, type: dataaccess.RequestType) { + await queryHelper.first({ + text: "DELETE FROM requests WHERE receiver = $1 AND sender = $2 AND type = $3", + values: [this.id, sender, type], + }); + } + + /** + * Accepts a request. + * @param sender + * @param type + */ + public async acceptRequest(sender: number, type: dataaccess.RequestType) { + const exists = await queryHelper.first({ + cache: true, + text: "SELECT 1 FROM requests WHERE receiver = $1 AND sender = $2 AND type = $3", + values: [this.id, sender, type], + }); + if (exists) { + if (type === dataaccess.RequestType.FRIENDREQUEST) { + await queryHelper.first({ + text: "INSERT INTO user_friends (user_id, friend_id) VALUES ($1, $2)", + values: [this.id, sender], + }); + } + } else { + throw new RequestNotFoundError(sender, this.id, type); + } + } + + /** + * Returns request wrapper for a row database request result. + * @param rows + */ + private getRequests(rows: any) { + const requests = []; + const requestUsers: any = {}; + + for (const row of rows) { + let sender = requestUsers[row.sender]; + + if (!sender) { + sender = new User(row.sender); + requestUsers[row.sender] = sender; + } + let receiver = requestUsers[row.receiver]; + if (!receiver) { + receiver = new User(row.receiver); + requestUsers[row.receiver] = receiver; + } + requests.push(new Request(sender, receiver, row.type)); + } + return requests; + } +} diff --git a/src/lib/dataaccess/Request.ts b/src/lib/dataaccess/Request.ts new file mode 100644 index 0000000..f161757 --- /dev/null +++ b/src/lib/dataaccess/Request.ts @@ -0,0 +1,12 @@ +import dataaccess from "./index"; +import {User} from "./User"; + +/** + * Represents a request to a user. + */ +export class Request { + constructor( + public readonly sender: User, + public readonly receiver: User, + public readonly type: dataaccess.RequestType) {} +} diff --git a/src/lib/dataaccess/User.ts b/src/lib/dataaccess/User.ts index bb680c4..b3501cc 100644 --- a/src/lib/dataaccess/User.ts +++ b/src/lib/dataaccess/User.ts @@ -1,3 +1,4 @@ +import globals from "../globals"; import {DataObject} from "./DataObject"; import {queryHelper} from "./index"; import {Post} from "./Post"; @@ -8,102 +9,91 @@ export class User extends DataObject { private $email: string; private $greenpoints: number; private $joinedAt: string; + private $exists: boolean; /** * The name of the user */ public async name(): Promise { - this.loadDataIfNotExists(); + await this.loadDataIfNotExists(); return this.$name; } - /** - * Sets the username of the user - * @param name - */ - public async setName(name: string): Promise { - const result = await queryHelper.first({ - text: "UPDATE TABLE users SET name = $1 WHERE id = $2", - values: [name, this.id], - }); - return result.name; - } - /** * The unique handle of the user. */ public async handle(): Promise { - this.loadDataIfNotExists(); + await this.loadDataIfNotExists(); return this.$handle; } - /** - * Updates the handle of the user - */ - public async setHandle(handle: string): Promise { - const result = await queryHelper.first({ - text: "UPDATE TABLE users SET handle = $1 WHERE id = $2", - values: [handle, this.id], - }); - return result.handle; - } - /** * The email of the user */ public async email(): Promise { - this.loadDataIfNotExists(); + await this.loadDataIfNotExists(); return this.$email; } - /** - * Sets the email of the user - * @param email - */ - public async setEmail(email: string): Promise { - const result = await queryHelper.first({ - text: "UPDATE TABLE users SET email = $1 WHERE users.id = $2 RETURNING email", - values: [email, this.id], - }); - return result.email; - } - /** * The number of greenpoints of the user */ public async greenpoints(): Promise { - this.loadDataIfNotExists(); + await this.loadDataIfNotExists(); return this.$greenpoints; } /** - * Sets the greenpoints of a user. - * @param points + * Returns the number of posts the user created */ - public async setGreenpoints(points: number): Promise { + public async numberOfPosts(): Promise { const result = await queryHelper.first({ - text: "UPDATE users SET greenpoints = $1 WHERE id = $2 RETURNING greenpoints", - values: [points, this.id], + cache: true, + text: "SELECT COUNT(*) count FROM posts WHERE author = $1", + values: [this.id], }); - return result.greenpoints; + return result.count; } /** * The date the user joined the platform */ public async joinedAt(): Promise { - this.loadDataIfNotExists(); + await this.loadDataIfNotExists(); return new Date(this.$joinedAt); } /** - * Returns all posts for a user. + * Returns all friends of the user. */ - public async posts(): Promise { + public async friends(): Promise { const result = await queryHelper.all({ - text: "SELECT * FROM posts WHERE author = $1", + cache: true, + text: "SELECT * FROM user_friends WHERE user_id = $1 OR friend_id = $1", values: [this.id], }); + const userFriends = []; + for (const row of result) { + if (row.user_id === this.id) { + userFriends.push(new User(row.friend_id)); + } else { + userFriends.push(new User(row.user_id)); + } + } + return userFriends; + } + + /** + * Returns all posts for a user. + */ + public async posts({first, offset}: {first: number, offset: number}): Promise { + first = first || 10; + offset = offset || 0; + const result = await queryHelper.all({ + cache: true, + text: "SELECT * FROM posts WHERE author = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + values: [this.id, first, offset], + }); const posts = []; for (const row of result) { @@ -121,7 +111,8 @@ export class User extends DataObject { result = this.row; } else { result = await queryHelper.first({ - text: "SELECT * FROM users WHERE user.id = $1", + cache: true, + text: "SELECT * FROM users WHERE users.id = $1", values: [this.id], }); } diff --git a/src/lib/dataaccess/index.ts b/src/lib/dataaccess/index.ts index 1d610ef..28ae27c 100644 --- a/src/lib/dataaccess/index.ts +++ b/src/lib/dataaccess/index.ts @@ -1,6 +1,13 @@ 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"; +import {Request} from "./Request"; import {User} from "./User"; const config = globals.config; @@ -16,33 +23,182 @@ const dbClient: Pool = new Pool({ }); export const queryHelper = new QueryHelper(dbClient, tableCreationFile, tableUpdateFile); +/** + * Generates a new handle from the username and a base64 string of the current time. + * @param username + */ +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; + /** * Initializes everything that needs to be initialized asynchronous. */ export async function init() { - await queryHelper.updateTableDefinitions(); await queryHelper.createTables(); - } - - /** - * Returns the user by id - * @param userId - */ - export function getUser(userId: number) { - return new User(userId); + await queryHelper.updateTableDefinitions(); } /** * Returns the user by handle. * @param userHandle */ - export async function getUserByHandle(userHandle: string) { - const result = await this.queryHelper.first({ + 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); + } + } + + /** + * Returns the user by email and password + * @param email + * @param password + */ + export async function getUserByLogin(email: string, password: string): Promise { + const result = await queryHelper.first({ + text: "SELECT * FROM users WHERE email = $1 AND password = $2", + values: [email, password], + }); + if (result) { + return new Profile(result.id, result); + } else { + throw new UserNotFoundError(email); + } + } + + /** + * Registers a user with a username and password returning a user + * @param username + * @param email + * @param password + */ + export async function registerUser(username: string, email: string, password: string) { + const result = await queryHelper.first({ + text: "INSERT INTO users (name, handle, password, email) VALUES ($1, $2, $3, $4) RETURNING *", + values: [username, generateHandle(username), password, email], + }); + return new Profile(result.id, result); + } + + /** + * Returns a post for a given postId.s + * @param postId + */ + export async function getPost(postId: number): Promise { + const result = await queryHelper.first({ + text: "SELECT * FROM posts WHERE id = $1", + values: [postId], + }); + if (result) { + return new Post(result.id, result); + } else { + return null; + } + } + + /** + * Creates a post + * @param content + * @param authorId + * @param type + */ + export async function createPost(content: string, authorId: number, type?: string): Promise { + type = type || "MISC"; + const result = await queryHelper.first({ + text: "INSERT INTO posts (content, author, type) VALUES ($1, $2, $3) RETURNING *", + values: [content, authorId, type], + }); + return new Post(result.id, result); + } + + /** + * Deletes a post + * @param postId + */ + export async function deletePost(postId: number): Promise { + const result = await queryHelper.first({ + text: "DELETE FROM posts WHERE posts.id = $1", + values: [postId], + }); + return true; + } + + /** + * Creates a chatroom containing two users + * @param members + */ + export async function createChat(...members: number[]): Promise { + const idResult = await queryHelper.first({ + text: "INSERT INTO chats (id) values (default) RETURNING *;", + }); + const id = idResult.id; + const transaction = await queryHelper.createTransaction(); + try { + await transaction.begin(); + for (const member of members) { + await transaction.query({ + name: "chat-member-insert", + text: "INSERT INTO chat_members (chat, member) VALUES ($1, $2);", + values: [id, member], + }); + } + await transaction.commit(); + } catch (err) { + globals.logger.warn(`Failed to insert chatmember into database: ${err.message}`); + globals.logger.debug(err.stack); + await transaction.rollback(); + } finally { + transaction.release(); + } + 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) values ($1, $2, $3) RETURNING *", + values: [chatId, authorId, content], + }); + return new ChatMessage(new User(result.author), chat, result.created_at, result.content); + } else { + throw new ChatNotFoundError(chatId); + } + } + + /** + * Sends a request to a user. + * @param sender + * @param receiver + * @param type + */ + export async function createRequest(sender: number, receiver: number, type?: RequestType) { + type = type || RequestType.FRIENDREQUEST; + + const result = await queryHelper.first({ + text: "INSERT INTO requests (sender, receiver, type) VALUES ($1, $2, $3) RETURNING *", + values: [sender, receiver, type], + }); + return new Request(new User(result.sender), new User(result.receiver), result.type); } /** 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/RequestNotFoundError.ts b/src/lib/errors/RequestNotFoundError.ts new file mode 100644 index 0000000..8a020d1 --- /dev/null +++ b/src/lib/errors/RequestNotFoundError.ts @@ -0,0 +1,9 @@ +import dataaccess from "../dataaccess"; +import {BaseError} from "./BaseError"; + +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.`); + } + +} 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/globals.ts b/src/lib/globals.ts index a85ffea..d3df954 100644 --- a/src/lib/globals.ts +++ b/src/lib/globals.ts @@ -8,6 +8,7 @@ import * as fsx from "fs-extra"; import * as yaml from "js-yaml"; import * as winston from "winston"; +import {MemoryCache} from "./MemoryCache"; const configPath = "config.yaml"; const defaultConfig = __dirname + "/../default-config.yaml"; @@ -15,6 +16,10 @@ const defaultConfig = __dirname + "/../default-config.yaml"; // ensure that the config exists by copying the default config. if (!(fsx.pathExistsSync(configPath))) { fsx.copySync(defaultConfig, configPath); +} else { + const conf = yaml.safeLoad(fsx.readFileSync(configPath, "utf-8")); + const defConf = yaml.safeLoad(fsx.readFileSync(defaultConfig, "utf-8")); + fsx.writeFileSync(configPath, yaml.safeDump(Object.assign(defConf, conf))); } /** @@ -22,19 +27,24 @@ if (!(fsx.pathExistsSync(configPath))) { */ namespace globals { export const config = yaml.safeLoad(fsx.readFileSync("config.yaml", "utf-8")); + export const cache = new MemoryCache(1200); export const logger = winston.createLogger({ transports: [ new winston.transports.Console({ format: winston.format.combine( winston.format.timestamp(), winston.format.colorize(), - winston.format.printf(({ level, message, label, timestamp }) => { + winston.format.printf(({ level, message, timestamp }) => { return `${timestamp} ${level}: ${message}`; }), ), + level: config.logging.level, }), ], }); + cache.on("set", (key) => logger.debug(`Caching '${key}'.`)); + cache.on("miss", (key) => logger.debug(`Cache miss for '${key}'`)); + cache.on("hit", (key) => logger.debug(`Cache hit for '${key}'`)); } export default globals; 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/lib/regex.ts b/src/lib/regex.ts new file mode 100644 index 0000000..1ed6c11 --- /dev/null +++ b/src/lib/regex.ts @@ -0,0 +1,11 @@ +export namespace is { + const emailRegex = /\S+?@\S+?(\.\S+?)?\.\w{2,3}(.\w{2-3})?/g + + /** + * Tests if a string is a valid email. + * @param testString + */ + export function email(testString: string) { + return emailRegex.test(testString) + } +} diff --git a/src/public/graphql/schema.graphql b/src/public/graphql/schema.graphql index ff60fd3..19a19eb 100644 --- a/src/public/graphql/schema.graphql +++ b/src/public/graphql/schema.graphql @@ -1,15 +1,15 @@ type Query { - "returns the user object for a given user id" - getUser(userId: ID): User + "returns the user object for a given user id or a handle (only one required)" + getUser(userId: ID, handle: String): User + + "returns the logged in user" + getSelf: Profile "returns the post object for a post id" - getPost(postId: ID): Post + getPost(postId: ID!): Post "returns the chat object for a chat id" - getChat(chatId: ID): ChatRoom - - "returns the request object for a request id" - getRequest(requestId: ID): Request + getChat(chatId: ID!): ChatRoom "find a post by the posted date or content" findPost(first: Int, offset: Int, text: String!, postedDate: String): [Post] @@ -19,35 +19,76 @@ type Query { } type Mutation { + "Accepts the usage of cookies." + acceptCookies: Boolean + + "Login of the user. The passwordHash should be a sha512 hash of the password." + login(email: String, passwordHash: String): Profile + + "Registers the user." + register(username: String, email: String, passwordHash: String): Profile + + "Logout of the user." + logout: Boolean + "Upvote/downvote a Post" - vote(postId: ID!, type: [VoteType!]!): Boolean + vote(postId: ID!, type: VoteType!): VoteType "Report the post" report(postId: ID!): Boolean "send a request" - sendRequest(reciever: ID!, type: RequestType): Boolean + sendRequest(receiver: ID!, type: RequestType): Request "lets you accept a request for a given request id" - acceptRequest(requestId: ID!): Boolean + acceptRequest(sender: ID!, type: RequestType): Boolean "lets you deny a request for a given request id" 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(text: String, picture: String, tags: [String]): Boolean + createPost(content: String!): Post "delete the post for a given post id" deletePost(postId: ID!): Boolean + + "Creates a chat between the user (and optional an other user)" + createChat(members: [ID!]): ChatRoom +} + +interface UserData { + "url for the Profile picture of the User" + profilePicture: String + + "name of the User" + name: String! + + "unique identifier name from the User" + handle: String! + + "Id of the User" + id: ID! + + "the total number of posts the user posted" + numberOfPosts: Int + + "returns a given number of posts of a user" + posts(first: Int=10, offset: Int): [Post] + + "creation date of the user account" + joinedAt: String! + + "all friends of the user" + friends: [User] } "represents a single user account" -type User { +type User implements UserData{ "url for the Profile picture of the User" - profilePicture: String! + profilePicture: String "name of the User" name: String! @@ -62,28 +103,61 @@ type User { numberOfPosts: Int "returns a given number of posts of a user" - getAllPosts(first: Int=10, offset: Int): [Post] + posts(first: Int=10, offset: Int): [Post] "creation date of the user account" - joinedDate: String! + joinedAt: String! - "returns chats the user pinned" - pinnedChats: [ChatRoom] + "all friends of the user" + friends: [User] +} + +type Profile implements UserData { + "url for the Profile picture of the User" + profilePicture: String - "returns all friends of the 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! + + "Id of the User" + id: ID! + + "the total number of posts the user posted" + numberOfPosts: Int + + "returns a given number of posts of a user" + posts(first: Int=10, offset: Int): [Post] + + "creation date of the user account" + joinedAt: String! + + "all friends of the user" friends: [User] - "all request for groupChats/friends/events" - requests: [Request] + "all sent request for groupChats/friends/events" + sentRequests: [Request] + + "all received request for groupChats/friends/events" + receivedRequests: [Request] } "represents a single user post" type Post { - "returns the path to the posts picture if it has one" - picture: String - "returns the text of the post" - text: String + "The id of the post." + id: ID! + + "the text of the post" + content: String + + "the content of the post rendered by markdown-it" + htmlContent: String "upvotes of the Post" upvotes: Int! @@ -95,20 +169,14 @@ type Post { author: User! "date the post was created" - creationDate: String! + createdAt: String! - "returns the type of vote the user performed on the post" + "the type of vote the user performed on the post" userVote: VoteType - - "returns the tags of the post" - tags: [String] } "represents a request of any type" type Request { - "id of the request" - id: ID! - "Id of the user who sended the request" sender: User! @@ -116,7 +184,7 @@ type Request { receiver: User! "type of the request" - requestType: RequestType! + type: RequestType! } "represents a chatroom" @@ -125,19 +193,39 @@ type ChatRoom { members: [User!] "return a specfic range of messages posted in the chat" - getMessages(first: Int, offset: Int): [String] + messages(first: Int = 10, offset: Int, containing: String): [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)." + createdAt: String! + + "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 DOWNVOTE } -"represents the type of request that the user has received" +""" +represents the type of request that the user has received +Currently on Friend Requests are implemented. +""" enum RequestType { FRIENDREQUEST GROUPINVITE diff --git a/src/public/javascripts/main.js b/src/public/javascripts/main.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/routes/home.ts b/src/routes/home.ts index 13bb254..88ce8b0 100644 --- a/src/routes/home.ts +++ b/src/routes/home.ts @@ -1,5 +1,15 @@ import {Router} from "express"; +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"; /** @@ -12,7 +22,6 @@ class HomeRoute extends Route { constructor() { super(); this.router = Router(); - this.configure(); } /** @@ -36,25 +45,205 @@ class HomeRoute extends Route { * @param req - the request object * @param res - the response object */ - public async resolver(req: any, res: any): Promise { + public resolver(req: any, res: any): any { return { - // TODO: Define grapql resolvers - }; - } + getSelf() { + if (req.session.userId) { + return new Profile(req.session.userId); + } else { + res.status(status.UNAUTHORIZED); + return new NotLoggedInGqlError(); + } + }, + async getUser({userId, handle}: {userId: number, handle: string}) { + if (handle) { + return await dataaccess.getUserByHandle(handle); + } else if (userId) { + return new User(userId); + } else { + res.status(status.BAD_REQUEST); + return new GraphQLError("No userId or handle provided."); + } + }, + async getPost({postId}: {postId: number}) { + if (postId) { + return await dataaccess.getPost(postId); + } else { + res.status(status.BAD_REQUEST); + 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) { + try { + const user = await dataaccess.getUserByLogin(email, passwordHash); + req.session.userId = user.id; + return user; + } catch (err) { + globals.logger.verbose(`Failed to login user '${email}'`); + res.status(status.BAD_REQUEST); + return err.graphqlError; + } + } else { + res.status(status.BAD_REQUEST); + return new GraphQLError("No email or password given."); + } + }, + logout() { + if (req.session.user) { + delete req.session.user; + return true; + } else { + res.status(status.UNAUTHORIZED); + return new NotLoggedInGqlError(); + } + }, + async register({username, email, passwordHash}: {username: string, email: string, passwordHash: string}) { + if (username && email && passwordHash) { + if (!is.email(email)) { + res.status(status.BAD_REQUEST); + return new GraphQLError(`'${email}' is not a valid email address!`); + } + const user = await dataaccess.registerUser(username, email, passwordHash); + if (user) { + req.session.userId = user.id; + return user; + } else { + res.status(status.INTERNAL_SERVER_ERROR); + return new GraphQLError("Failed to create account."); + } + } else { + res.status(status.BAD_REQUEST); + return new GraphQLError("No username, email or password given."); + } + }, + async vote({postId, type}: {postId: number, type: dataaccess.VoteType}) { + if (postId && type) { + if (req.session.userId) { + return await (new Post(postId)).vote(req.session.userId, type); + } else { + res.status(status.UNAUTHORIZED); + return new NotLoggedInGqlError(); + } + } else { + res.status(status.BAD_REQUEST); + return new GraphQLError("No postId or type given."); + } + }, + async createPost({content}: {content: string}) { + if (content) { + if (req.session.userId) { + return await dataaccess.createPost(content, req.session.userId); + } else { + res.status(status.UNAUTHORIZED); + return new NotLoggedInGqlError(); + } + } else { + res.status(status.BAD_REQUEST); + return new GraphQLError("Can't create empty post."); + } + }, + async deletePost({postId}: {postId: number}) { + if (postId) { + const post = new Post(postId); + if ((await post.author()).id === req.session.userId) { + return await dataaccess.deletePost(post.id); + } else { + res.status(status.FORBIDDEN); + return new GraphQLError("User is not author of the post."); + } + } else { + return new GraphQLError("No postId given."); + } + }, + async createChat({members}: {members: number[]}) { + if (req.session.userId) { + const chatMembers = [req.session.userId]; + if (members) { + chatMembers.push(...members); + } + return await dataaccess.createChat(...chatMembers); - /** - * Configures the route. - */ - private configure() { - this.router.get("/", (req, res) => { - res.render("home"); - }); - this.router.get("/login", (req, res) => { - res.render("login"); - }); - this.router.get("/register", (req, res) => { - res.render("register"); - }); + } else { + res.status(status.UNAUTHORIZED); + return new NotLoggedInGqlError(); + } + }, + async sendMessage({chatId, content}: {chatId: number, content: string}) { + if (!req.session.userId) { + res.status(status.UNAUTHORIZED); + 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."); + } + }, + async sendRequest({receiver, type}: {receiver: number, type: dataaccess.RequestType}) { + if (!req.session.userId) { + res.status(status.UNAUTHORIZED); + return new NotLoggedInGqlError(); + } + if (receiver && type) { + return await dataaccess.createRequest(req.session.userId, receiver, type); + } else { + res.status(status.BAD_REQUEST); + return new GraphQLError("No receiver or type given."); + } + }, + async denyRequest({sender, type}: {sender: number, type: dataaccess.RequestType}) { + if (!req.session.userId) { + res.status(status.UNAUTHORIZED); + return new NotLoggedInGqlError(); + } + if (sender && type) { + const profile = new Profile(req.session.userId); + await profile.denyRequest(sender, type); + return true; + } else { + res.status(status.BAD_REQUEST); + return new GraphQLError("No sender or type given."); + } + }, + async acceptRequest({sender, type}: {sender: number, type: dataaccess.RequestType}) { + if (!req.session.userId) { + res.status(status.UNAUTHORIZED); + return new NotLoggedInGqlError(); + } + if (sender && type) { + try { + const profile = new Profile(req.session.userId); + await profile.acceptRequest(sender, type); + return true; + } catch (err) { + res.status(status.BAD_REQUEST); + return err.graphqlError; + } + } else { + res.status(status.BAD_REQUEST); + return new GraphQLError("No sender or type given."); + } + }, + }; } } diff --git a/src/routes/index.ts b/src/routes/index.ts index 84cc7c5..eb65750 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -28,9 +28,9 @@ namespace routes { * @param request * @param response */ - export const resolvers = async (request: any, response: any): Promise => { + export function resolvers(request: any, response: any): Promise { return homeRoute.resolver(request, response); - }; + } /** * Assigns the io listeners or namespaces to the routes diff --git a/src/sql/create-tables.sql b/src/sql/create-tables.sql index 7c1c97a..128492b 100644 --- a/src/sql/create-tables.sql +++ b/src/sql/create-tables.sql @@ -1,58 +1,131 @@ -CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - name varchar(128) NOT NULL, - handle varchar(128) UNIQUE NOT NULL, - password varchar(1024) NOT NULL, - email varchar(128) UNIQUE NOT NULL, - greenpoints INTEGER DEFAULT 0, - joined_at TIMESTAMP DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS posts ( - id BIGSERIAL PRIMARY KEY, - upvotes INTEGER DEFAULT 0, - downvotes INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT now(), - content text, - author SERIAL REFERENCES users (id) ON DELETE CASCADE, - type varchar(16) NOT NULL -); - -CREATE TABLE IF NOT EXISTS votes ( - user_id SERIAL REFERENCES users (id) ON DELETE CASCADE, - item_id BIGSERIAL REFERENCES posts (id) ON DELETE CASCADE, - vote_type varchar(8) DEFAULT 'upvote' -); - -CREATE TABLE IF NOT EXISTS events ( - id BIGSERIAL PRIMARY KEY, - time TIMESTAMP, - owner SERIAL REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS event_members ( - event BIGSERIAL REFERENCES events (id), - member SERIAL REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS chats ( - id BIGSERIAL PRIMARY KEY -); - -CREATE TABLE IF NOT EXISTS chat_messages ( - chat BIGSERIAL REFERENCES chats (id) ON DELETE CASCADE, - author SERIAL REFERENCES users (id) ON DELETE SET NULL, - content VARCHAR(1024) NOT NULL, - created_at TIMESTAMP DEFAULT now(), - PRIMARY KEY (chat, author, created_at) -); - -CREATE TABLE IF NOT EXISTS chat_members ( - chat BIGSERIAL REFERENCES chats (id) ON DELETE CASCADE, - member SERIAL REFERENCES users (id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS user_friends ( - user_id SERIAL REFERENCES users (id) ON DELETE CASCADE, - friend_id SERIAL REFERENCES users (id) ON DELETE CASCADE -); +--create functions +DO $$BEGIN + + IF NOT EXISTS(SELECT 1 from pg_proc WHERE proname = 'function_exists') THEN + CREATE FUNCTION function_exists(text) RETURNS boolean LANGUAGE plpgsql AS $BODY$ + BEGIN + RETURN EXISTS(SELECT 1 from pg_proc WHERE proname = $1); + END $BODY$; + END IF; + + IF NOT function_exists('type_exists') THEN + CREATE FUNCTION type_exists(text) RETURNS boolean LANGUAGE plpgsql AS $BODY$ + BEGIN + RETURN EXISTS (SELECT 1 FROM pg_type WHERE typname = $1); + END $BODY$; + END IF; + + IF NOT function_exists('cast_to_votetype') THEN + CREATE FUNCTION cast_to_votetype(text) RETURNS votetype LANGUAGE plpgsql AS $BODY$ + BEGIN + RETURN CASE WHEN $1::votetype IS NULL THEN 'UPVOTE' ELSE $1::votetype END; + END $BODY$; + END IF; + + IF NOT function_exists('cast_to_posttype') THEN + CREATE FUNCTION cast_to_posttype(text) RETURNS posttype LANGUAGE plpgsql AS $BODY$ + BEGIN + RETURN CASE WHEN $1::posttype IS NULL THEN 'MISC' ELSE $1::posttype END; + END $BODY$; + END IF; + +END$$; + +--create types +DO $$ BEGIN + + IF NOT type_exists('votetype') THEN + CREATE TYPE votetype AS enum ('DOWNVOTE', 'UPVOTE'); + END IF; + + IF NOT type_exists('posttype') THEN + CREATE TYPE posttype AS enum ('MISC', 'ACTION', 'IMAGE', 'TEXT'); + END IF; + + IF NOT type_exists('requesttype') THEN + CREATE TYPE requesttype AS enum ('FRIENDREQUEST'); + END IF; + +END$$; + +-- create tables +DO $$ BEGIN + + CREATE TABLE IF NOT EXISTS "user_sessions" ( + "sid" varchar NOT NULL, + "sess" json NOT NULL, + "expire" timestamp(6) NOT NULL, + PRIMARY KEY ("sid") NOT DEFERRABLE INITIALLY IMMEDIATE + ) WITH (OIDS=FALSE); + + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + name varchar(128) NOT NULL, + handle varchar(128) UNIQUE NOT NULL, + password varchar(1024) NOT NULL, + email varchar(128) UNIQUE NOT NULL, + greenpoints INTEGER DEFAULT 0, + joined_at TIMESTAMP DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS posts ( + id BIGSERIAL PRIMARY KEY, + upvotes INTEGER DEFAULT 0, + downvotes INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT now(), + content text, + author SERIAL REFERENCES users (id) ON DELETE CASCADE, + type posttype NOT NULL DEFAULT 'MISC' + ); + + CREATE TABLE IF NOT EXISTS votes ( + user_id SERIAL REFERENCES users (id) ON DELETE CASCADE, + item_id BIGSERIAL REFERENCES posts (id) ON DELETE CASCADE, + vote_type votetype DEFAULT 'DOWNVOTE', + PRIMARY KEY (user_id, item_id) + ); + + CREATE TABLE IF NOT EXISTS events ( + id BIGSERIAL PRIMARY KEY, + time TIMESTAMP, + owner SERIAL REFERENCES users (id) + ); + + CREATE TABLE IF NOT EXISTS event_members ( + event BIGSERIAL REFERENCES events (id), + member SERIAL REFERENCES users (id), + PRIMARY KEY (event, member) + ); + + CREATE TABLE IF NOT EXISTS chats ( + id BIGSERIAL PRIMARY KEY + ); + + CREATE TABLE IF NOT EXISTS chat_messages ( + chat BIGSERIAL REFERENCES chats (id) ON DELETE CASCADE, + author SERIAL REFERENCES users (id) ON DELETE SET NULL, + content VARCHAR(1024) NOT NULL, + created_at TIMESTAMP DEFAULT now(), + PRIMARY KEY (chat, author, created_at) + ); + + CREATE TABLE IF NOT EXISTS chat_members ( + chat BIGSERIAL REFERENCES chats (id) ON DELETE CASCADE, + member SERIAL REFERENCES users (id) ON DELETE CASCADE, + PRIMARY KEY (chat, member) + ); + + CREATE TABLE IF NOT EXISTS user_friends ( + user_id SERIAL REFERENCES users (id) ON DELETE CASCADE, + friend_id SERIAL REFERENCES users (id) ON DELETE CASCADE, + PRIMARY KEY (user_id, friend_id) + ); + + CREATE TABLE IF NOT EXISTS requests ( + sender SERIAL REFERENCES users (id) ON DELETE CASCADE, + receiver SERIAL REFERENCES users (id) ON DELETE CASCADE, + type requesttype DEFAULT 'FRIENDREQUEST', + PRIMARY KEY (sender, receiver, type) + ); + +END $$; diff --git a/src/sql/update-tables.sql b/src/sql/update-tables.sql index 52ce2f6..858a214 100644 --- a/src/sql/update-tables.sql +++ b/src/sql/update-tables.sql @@ -1,3 +1,19 @@ -ALTER TABLE IF EXISTS votes - ADD COLUMN IF NOT EXISTS vote_type varchar(8) DEFAULT 'upvote', - ALTER COLUMN vote_type SET DEFAULT 'upvote'; +DO $$ BEGIN + + ALTER TABLE IF EXISTS votes + ADD COLUMN IF NOT EXISTS vote_type votetype DEFAULT 'UPVOTE', + ALTER COLUMN vote_type TYPE votetype USING cast_to_votetype(vote_type::text), + ALTER COLUMN vote_type DROP DEFAULT, + ALTER COLUMN vote_type SET DEFAULT 'UPVOTE'; + + ALTER TABLE IF EXISTS posts + ALTER COLUMN type TYPE posttype USING cast_to_posttype(type::text), + ALTER COLUMN type DROP DEFAULT, + ALTER COLUMN type SET DEFAULT 'MISC', + DROP COLUMN IF EXISTS upvotes, + DROP COLUMN IF EXISTS downvotes; + + ALTER TABLE requests + ADD COLUMN IF NOT EXISTS type requesttype DEFAULT 'FRIENDREQUEST'; + +END $$; diff --git a/src/views/home/feed.pug b/src/views/home/feed.pug deleted file mode 100644 index e3758e1..0000000 --- a/src/views/home/feed.pug +++ /dev/null @@ -1,13 +0,0 @@ -div#feedcontainer - div.postinput - input(type=text placeholder='Post something') - button.submitbutton Submit - div.feeditem - div.itemhead - span.title Testuser - span.handle - a(href='#') @testuser - span.date 23.09.19 10:07 - p.text - | Example Test text. - | This is a test diff --git a/src/views/home/friends.pug b/src/views/home/friends.pug deleted file mode 100644 index ed36fb7..0000000 --- a/src/views/home/friends.pug +++ /dev/null @@ -1 +0,0 @@ -div#friendscontainer diff --git a/src/views/home/index.pug b/src/views/home/index.pug deleted file mode 100644 index 0fc45ad..0000000 --- a/src/views/home/index.pug +++ /dev/null @@ -1,9 +0,0 @@ -html - head - title Greenvironment Network - include ../includes/head - body - div#content - include stylebar - include feed - include friends diff --git a/src/views/home/stylebar.pug b/src/views/home/stylebar.pug deleted file mode 100644 index c6c36e3..0000000 --- a/src/views/home/stylebar.pug +++ /dev/null @@ -1,2 +0,0 @@ -div.stylebar - h1 Greenvironment diff --git a/src/views/includes/head.pug b/src/views/includes/head.pug deleted file mode 100644 index 9e917e6..0000000 --- a/src/views/includes/head.pug +++ /dev/null @@ -1 +0,0 @@ -link(rel='stylesheet' href='stylesheets/style.css') diff --git a/src/views/login/index.pug b/src/views/login/index.pug deleted file mode 100644 index 41fe877..0000000 --- a/src/views/login/index.pug +++ /dev/null @@ -1,8 +0,0 @@ -html - head - title Greenvironment Network Login - include ../includes/head - body - div#content - include stylebar - include login diff --git a/src/views/login/login.pug b/src/views/login/login.pug deleted file mode 100644 index f205c95..0000000 --- a/src/views/login/login.pug +++ /dev/null @@ -1,6 +0,0 @@ -div#input-login - input(type=text placeholder='username') - input(type=text placeholder='password') - button.loginButton Login - a(href="/register" ) - | You aren´t part of greenvironment yet? - create a new account diff --git a/src/views/login/stylebar.pug b/src/views/login/stylebar.pug deleted file mode 100644 index c6c36e3..0000000 --- a/src/views/login/stylebar.pug +++ /dev/null @@ -1,2 +0,0 @@ -div.stylebar - h1 Greenvironment diff --git a/src/views/register/index.pug b/src/views/register/index.pug deleted file mode 100644 index d8e9dd1..0000000 --- a/src/views/register/index.pug +++ /dev/null @@ -1,8 +0,0 @@ -html - head - title Greenvironment Network Register - include ../includes/head - body - div#content - include stylebar - include register diff --git a/src/views/register/register.pug b/src/views/register/register.pug deleted file mode 100644 index baaf1e6..0000000 --- a/src/views/register/register.pug +++ /dev/null @@ -1,8 +0,0 @@ -div#input-register - input(type=text placeholder='username') - input(type=text placeholder='email') - input(type=text placeholder='password') - input(type=text placeholder='repeat password') - button.registerButton Register - a(href="/login" ) - | You are already part of greenvironment? - login diff --git a/src/views/register/stylebar.pug b/src/views/register/stylebar.pug deleted file mode 100644 index c6c36e3..0000000 --- a/src/views/register/stylebar.pug +++ /dev/null @@ -1,2 +0,0 @@ -div.stylebar - h1 Greenvironment