Merge branch 'develop' of Software_Engineering_I/greenvironment-server into master

pull/2/head^2
Julius 5 years ago committed by Gitea
commit 56614f6338

4
.gitignore vendored

@ -6,3 +6,7 @@ test/*.log
dist dist
.idea .idea
config.yaml config.yaml
sqz-force
greenvironment.db
logs
logs*

@ -8,8 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Connection to Postgres Database
- Graphql Schema - Graphql Schema
- default-config file and generation of config file on startup - default-config file and generation of config file on startup
- DTOs - DTOs
- Home Route - Home Route
- session management
- Sequelize models and integration
- Sequelize-typescript integration
- error pages
- pagination for most list types

@ -8,5 +8,4 @@ RUN npm rebuild node-sass
RUN gulp RUN gulp
COPY . . COPY . .
EXPOSE 8080 EXPOSE 8080
EXPOSE 5432
CMD ["npm" , "run"] CMD ["npm" , "run"]

@ -1,15 +1,11 @@
version: "3" version: "3"
services: services:
greenvironment: greenvironment:
build: build: .
context: . user: "root"
dockerfile: ./Dockerfile
user: "node"
working_dir: /home/node/green working_dir: /home/node/green
environment: environment:
- NODE_ENV=production - NODE_ENV=production
volumes:
- ./:/home/node/green
ports: ports:
- "8080:8080" - "8080:8080"
command: "npm start" command: "npm start"

664
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -20,9 +20,11 @@
"author": "SoftEngI", "author": "SoftEngI",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@types/bluebird": "^3.5.27",
"@types/compression": "^1.0.1", "@types/compression": "^1.0.1",
"@types/connect-pg-simple": "^4.2.0", "@types/connect-pg-simple": "^4.2.0",
"@types/cookie-parser": "^1.4.2", "@types/cookie-parser": "^1.4.2",
"@types/cors": "^2.8.6",
"@types/express": "^4.17.1", "@types/express": "^4.17.1",
"@types/express-graphql": "^0.8.0", "@types/express-graphql": "^0.8.0",
"@types/express-session": "^1.15.14", "@types/express-session": "^1.15.14",
@ -32,9 +34,11 @@
"@types/http-status": "^0.2.30", "@types/http-status": "^0.2.30",
"@types/js-yaml": "^3.12.1", "@types/js-yaml": "^3.12.1",
"@types/markdown-it": "0.0.9", "@types/markdown-it": "0.0.9",
"@types/node": "^12.7.8", "@types/node": "^12.7.12",
"@types/pg": "^7.11.0", "@types/pg": "^7.11.0",
"@types/sequelize": "^4.28.5",
"@types/socket.io": "^2.1.2", "@types/socket.io": "^2.1.2",
"@types/validator": "^10.11.3",
"@types/winston": "^2.4.4", "@types/winston": "^2.4.4",
"delete": "^1.1.0", "delete": "^1.1.0",
"gulp": "^4.0.2", "gulp": "^4.0.2",
@ -48,14 +52,14 @@
}, },
"dependencies": { "dependencies": {
"compression": "^1.7.4", "compression": "^1.7.4",
"connect-pg-simple": "^6.0.1", "connect-session-sequelize": "^6.0.0",
"cookie-parser": "^1.4.4", "cookie-parser": "^1.4.4",
"cors": "^2.8.5",
"express": "^4.17.1", "express": "^4.17.1",
"express-graphql": "^0.9.0", "express-graphql": "^0.9.0",
"express-session": "^1.16.2", "express-session": "^1.16.2",
"express-socket.io-session": "^1.3.5", "express-socket.io-session": "^1.3.5",
"fs-extra": "^8.1.0", "fs-extra": "^8.1.0",
"g": "^2.0.1",
"graphql": "^14.4.2", "graphql": "^14.4.2",
"graphql-import": "^0.7.1", "graphql-import": "^0.7.1",
"http-status": "^1.3.2", "http-status": "^1.3.2",
@ -64,7 +68,12 @@
"markdown-it-emoji": "^1.4.0", "markdown-it-emoji": "^1.4.0",
"pg": "^7.12.1", "pg": "^7.12.1",
"pug": "^2.0.4", "pug": "^2.0.4",
"reflect-metadata": "^0.1.13",
"sequelize": "^5.19.6",
"sequelize-typescript": "^1.0.0",
"socket.io": "^2.2.0", "socket.io": "^2.2.0",
"winston": "^3.2.1" "sqlite3": "^4.1.0",
"winston": "^3.2.1",
"winston-daily-rotate-file": "^4.2.1"
} }
} }

@ -1,41 +1,45 @@
import * as compression from "compression"; import * as compression from "compression";
import connectPgSimple = require("connect-pg-simple");
import * as cookieParser from "cookie-parser"; import * as cookieParser from "cookie-parser";
import * as cors from "cors";
import {Request, Response} from "express";
import * as express from "express"; import * as express from "express";
import * as graphqlHTTP from "express-graphql"; import * as graphqlHTTP from "express-graphql";
import * as session from "express-session"; import * as session from "express-session";
import sharedsession = require("express-socket.io-session"); import sharedsession = require("express-socket.io-session");
import * as fsx from "fs-extra";
import {buildSchema} from "graphql"; import {buildSchema} from "graphql";
import {importSchema} from "graphql-import"; import {importSchema} from "graphql-import";
import * as http from "http"; import * as http from "http";
import * as httpStatus from "http-status";
import * as path from "path"; import * as path from "path";
import {Sequelize} from "sequelize-typescript";
import * as socketIo from "socket.io"; import * as socketIo from "socket.io";
import {resolver} from "./graphql/resolvers"; import {resolver} from "./graphql/resolvers";
import dataaccess, {queryHelper} from "./lib/dataaccess"; import dataaccess from "./lib/dataaccess";
import globals from "./lib/globals"; import globals from "./lib/globals";
import routes from "./routes"; import routes from "./routes";
const SequelizeStore = require("connect-session-sequelize")(session.Store);
const logger = globals.logger; const logger = globals.logger;
const PgSession = connectPgSimple(session);
class App { class App {
public app: express.Application; public app: express.Application;
public io: socketIo.Server; public io: socketIo.Server;
public server: http.Server; public server: http.Server;
public readonly sequelize: Sequelize;
constructor() { constructor() {
this.app = express(); this.app = express();
this.server = new http.Server(this.app); this.server = new http.Server(this.app);
this.io = socketIo(this.server); this.io = socketIo(this.server);
this.sequelize = new Sequelize(globals.config.database.connectionUri );
} }
/** /**
* initializes everything that needs to be initialized asynchronous. * initializes everything that needs to be initialized asynchronous.
*/ */
public async init() { public async init() {
await dataaccess.init(); await dataaccess.init(this.sequelize);
await routes.ioListeners(this.io);
const appSession = session({ const appSession = session({
cookie: { cookie: {
@ -45,12 +49,14 @@ class App {
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
secret: globals.config.session.secret, secret: globals.config.session.secret,
store: new PgSession({ store: new SequelizeStore({db: this.sequelize}),
pool: dataaccess.pool,
tableName: "user_sessions",
}),
}); });
const force = fsx.existsSync("sqz-force");
logger.info(`Sequelize Table force: ${force}`);
await this.sequelize.sync({force, logging: (msg) => logger.silly(msg)});
await routes.ioListeners(this.io);
this.io.use(sharedsession(appSession, {autoSave: true})); this.io.use(sharedsession(appSession, {autoSave: true}));
this.app.set("views", path.join(__dirname, "views")); this.app.set("views", path.join(__dirname, "views"));
@ -63,6 +69,9 @@ class App {
this.app.use(express.static(path.join(__dirname, "public"))); this.app.use(express.static(path.join(__dirname, "public")));
this.app.use(cookieParser()); this.app.use(cookieParser());
this.app.use(appSession); this.app.use(appSession);
if (globals.config.server.cors) {
this.app.use(cors());
}
this.app.use((req, res, next) => { this.app.use((req, res, next) => {
logger.verbose(`${req.method} ${req.url}`); logger.verbose(`${req.method} ${req.url}`);
next(); next();
@ -77,6 +86,18 @@ class App {
schema: buildSchema(importSchema(path.join(__dirname, "./graphql/schema.graphql"))), schema: buildSchema(importSchema(path.join(__dirname, "./graphql/schema.graphql"))),
}; };
})); }));
this.app.use((req: any, res: Response) => {
if (globals.config.frontend.angularIndex) {
res.sendFile(path.join(__dirname, globals.config.frontend.angularIndex));
} else {
res.status(httpStatus.NOT_FOUND);
res.render("errors/404.pug", {url: req.url});
}
});
this.app.use((err, req: Request, res: Response) => {
res.status(httpStatus.INTERNAL_SERVER_ERROR);
res.render("errors/500.pug");
});
} }
/** /**

@ -1,22 +1,25 @@
# database connection info # database connection info
database: database:
host: connectionUri: "sqlite://greenvironment.db"
port:
user:
password:
database:
# http server configuration # http server configuration
server: server:
port: 8080 port: 8080
cors: false
# configuration of sessions
session: session:
secret: REPLACE WITH SAFE RANDOM GENERATED SECRET secret: REPLACE WITH SAFE RANDOM GENERATED SECRET
cookieMaxAge: 604800000 # 7 days cookieMaxAge: 604800000 # 7 days
# configuration of the markdown parser
markdown: markdown:
plugins: plugins:
- 'markdown-it-emoji' - 'markdown-it-emoji'
# configuration for logging
logging: logging:
level: info level: info
frontend:
angularIndex:

@ -1,13 +1,10 @@
import {GraphQLError} from "graphql"; import {GraphQLError} from "graphql";
import * as status from "http-status"; import * as status from "http-status";
import dataaccess from "../lib/dataaccess"; import dataaccess from "../lib/dataaccess";
import {Chatroom} from "../lib/dataaccess/Chatroom"; import {NotLoggedInGqlError, PostNotFoundGqlError} from "../lib/errors/graphqlErrors";
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 globals from "../lib/globals";
import {InternalEvents} from "../lib/InternalEvents"; import {InternalEvents} from "../lib/InternalEvents";
import * as models from "../lib/models";
import {is} from "../lib/regex"; import {is} from "../lib/regex";
/** /**
@ -17,9 +14,9 @@ import {is} from "../lib/regex";
*/ */
export function resolver(req: any, res: any): any { export function resolver(req: any, res: any): any {
return { return {
getSelf() { async getSelf() {
if (req.session.userId) { if (req.session.userId) {
return new Profile(req.session.userId); return models.User.findByPk(req.session.userId);
} else { } else {
res.status(status.UNAUTHORIZED); res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError(); return new NotLoggedInGqlError();
@ -29,7 +26,7 @@ export function resolver(req: any, res: any): any {
if (handle) { if (handle) {
return await dataaccess.getUserByHandle(handle); return await dataaccess.getUserByHandle(handle);
} else if (userId) { } else if (userId) {
return new User(userId); return models.User.findByPk(userId);
} else { } else {
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
return new GraphQLError("No userId or handle provided."); return new GraphQLError("No userId or handle provided.");
@ -45,12 +42,28 @@ export function resolver(req: any, res: any): any {
}, },
async getChat({chatId}: { chatId: number }) { async getChat({chatId}: { chatId: number }) {
if (chatId) { if (chatId) {
return new Chatroom(chatId); return models.ChatRoom.findByPk(chatId);
} else { } else {
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
return new GraphQLError("No chatId given."); return new GraphQLError("No chatId given.");
} }
}, },
async getGroup({groupId}: { groupId: number }) {
if (groupId) {
return models.Group.findByPk(groupId);
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No group id given.");
}
},
async getRequest({requestId}: { requestId: number }) {
if (requestId) {
return models.Request.findByPk(requestId);
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No requestId given.");
}
},
acceptCookies() { acceptCookies() {
req.session.cookiesAccepted = true; req.session.cookiesAccepted = true;
return true; return true;
@ -73,8 +86,14 @@ export function resolver(req: any, res: any): any {
} }
}, },
logout() { logout() {
if (req.session.user) { if (req.session.userId) {
delete req.session.user; delete req.session.userId;
req.session.save((err: any) => {
if (err) {
globals.logger.error(err.message);
globals.logger.debug(err.stack);
}
});
return true; return true;
} else { } else {
res.status(status.UNAUTHORIZED); res.status(status.UNAUTHORIZED);
@ -105,7 +124,13 @@ export function resolver(req: any, res: any): any {
async vote({postId, type}: { postId: number, type: dataaccess.VoteType }) { async vote({postId, type}: { postId: number, type: dataaccess.VoteType }) {
if (postId && type) { if (postId && type) {
if (req.session.userId) { if (req.session.userId) {
return await (new Post(postId)).vote(req.session.userId, type); const post = await models.Post.findByPk(postId);
if (post) {
return await post.vote(req.session.userId, type);
} else {
res.status(status.BAD_REQUEST);
return new PostNotFoundGqlError(postId);
}
} else { } else {
res.status(status.UNAUTHORIZED); res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError(); return new NotLoggedInGqlError();
@ -118,9 +143,13 @@ export function resolver(req: any, res: any): any {
async createPost({content}: { content: string }) { async createPost({content}: { content: string }) {
if (content) { if (content) {
if (req.session.userId) { if (req.session.userId) {
const post = await dataaccess.createPost(content, req.session.userId); if (content.length > 2048) {
globals.internalEmitter.emit(InternalEvents.GQLPOSTCREATE, post); return new GraphQLError("Content too long.");
return post; } else {
const post = await dataaccess.createPost(content, req.session.userId);
globals.internalEmitter.emit(InternalEvents.GQLPOSTCREATE, post);
return post;
}
} else { } else {
res.status(status.UNAUTHORIZED); res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError(); return new NotLoggedInGqlError();
@ -132,8 +161,8 @@ export function resolver(req: any, res: any): any {
}, },
async deletePost({postId}: { postId: number }) { async deletePost({postId}: { postId: number }) {
if (postId) { if (postId) {
const post = new Post(postId); const post = await models.Post.findByPk(postId, {include: [models.User]});
if ((await post.author()).id === req.session.userId) { if (post.rAuthor.id === req.session.userId) {
return await dataaccess.deletePost(post.id); return await dataaccess.deletePost(post.id);
} else { } else {
res.status(status.FORBIDDEN); res.status(status.FORBIDDEN);
@ -194,8 +223,8 @@ export function resolver(req: any, res: any): any {
return new NotLoggedInGqlError(); return new NotLoggedInGqlError();
} }
if (sender && type) { if (sender && type) {
const profile = new Profile(req.session.userId); const user = await models.User.findByPk(req.session.userId);
await profile.denyRequest(sender, type); await user.denyRequest(sender, type);
return true; return true;
} else { } else {
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
@ -209,8 +238,8 @@ export function resolver(req: any, res: any): any {
} }
if (sender && type) { if (sender && type) {
try { try {
const profile = new Profile(req.session.userId); const user = await models.User.findByPk(req.session.userId);
await profile.acceptRequest(sender, type); await user.acceptRequest(sender, type);
return true; return true;
} catch (err) { } catch (err) {
globals.logger.warn(err.message); globals.logger.warn(err.message);
@ -223,5 +252,129 @@ export function resolver(req: any, res: any): any {
return new GraphQLError("No sender or type given."); return new GraphQLError("No sender or type given.");
} }
}, },
async removeFriend({friendId}: { friendId: number }) {
if (req.session.userId) {
const self = await models.User.findByPk(req.session.userId);
return await self.removeFriend(friendId);
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async getPosts({first, offset, sort}: { first: number, offset: number, sort: dataaccess.SortType }) {
return await dataaccess.getPosts(first, offset, sort);
},
async createGroup({name, members}: { name: string, members: number[] }) {
if (req.session.userId) {
return await dataaccess.createGroup(name, req.session.userId, members);
} else {
return new NotLoggedInGqlError();
}
},
async joinGroup({id}: { id: number }) {
if (req.session.userId) {
try {
return await dataaccess
.changeGroupMembership(id, req.session.userId, dataaccess.MembershipChangeAction.ADD);
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError;
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async leaveGroup({id}: { id: number }) {
if (req.session.userId) {
try {
return await dataaccess
.changeGroupMembership(id, req.session.userId, dataaccess.MembershipChangeAction.REMOVE);
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError;
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async addGroupAdmin({groupId, userId}: { groupId: number, userId: number }) {
if (req.session.userId) {
const group = await models.Group.findByPk(groupId);
const self = await models.User.findByPk(req.session.userId);
if (group && !(await group.$has("rAdmins", self)) && (await group.creator()) !== self.id) {
res.status(status.FORBIDDEN);
return new GraphQLError("You are not a group admin!");
}
try {
return await dataaccess
.changeGroupMembership(groupId, userId, dataaccess.MembershipChangeAction.OP);
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError;
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async removeGroupAdmin({groupId, userId}: { groupId: number, userId: number }) {
if (req.session.userId) {
const group = await models.Group.findByPk(groupId);
const isCreator = Number(group.creatorId) === Number(req.session.userId);
const userIsCreator = Number(group.creatorId) === Number(userId);
if (group && !isCreator && Number(userId) !== Number(req.session.userId)) {
res.status(status.FORBIDDEN);
return new GraphQLError("You are not the group creator!");
} else if (userIsCreator) {
res.status(status.FORBIDDEN);
return new GraphQLError("You are not allowed to remove a creator as an admin.");
}
try {
return await dataaccess
.changeGroupMembership(groupId, userId, dataaccess.MembershipChangeAction.DEOP);
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError;
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async createEvent({name, dueDate, groupId}: { name: string, dueDate: string, groupId: number }) {
if (req.session.userId) {
const date = new Date(dueDate);
const group = await models.Group.findByPk(groupId);
return group.$create<models.Event>("rEvent", {name, dueDate: date});
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async joinEvent({eventId}: { eventId: number }) {
if (req.session.userId) {
const event = await models.Event.findByPk(eventId);
const self = await models.User.findByPk(req.session.userId);
await event.$add("rParticipants", self);
return event;
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async leaveEvent({eventId}: { eventId: number }) {
if (req.session.userId) {
const event = await models.Event.findByPk(eventId);
const self = await models.User.findByPk(req.session.userId);
await event.$remove("rParticipants", self);
return event;
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
}; };
} }

@ -11,11 +11,20 @@ type Query {
"returns the chat object for a chat id" "returns the chat object for a chat id"
getChat(chatId: ID!): ChatRoom getChat(chatId: ID!): ChatRoom
"return shte group object for its id"
getGroup(groupId: ID!): Group
"returns the request object for its id"
getRequest(requestId: ID!): Request
"find a post by the posted date or content" "find a post by the posted date or content"
findPost(first: Int, offset: Int, text: String!, postedDate: String): [Post] findPost(first: Int, offset: Int, text: String!, postedDate: String): [Post]
"find a user by user name or handle" "find a user by user name or handle"
findUser(first: Int, offset: Int, name: String!, handle: String!): [User] findUser(first: Int, offset: Int, name: String, handle: String): [User]
"returns the post filtered by the sort type with pagination."
getPosts(first: Int=20, offset: Int=0, sort: SortType = NEW): [Post]
} }
type Mutation { type Mutation {
@ -46,17 +55,44 @@ type Mutation {
"lets you deny a request for a given request id" "lets you deny a request for a given request id"
denyRequest(requestId: ID!): Boolean denyRequest(requestId: ID!): Boolean
"removes a friend"
removeFriend(friendId: ID!): Boolean
"send a message in a Chatroom" "send a message in a Chatroom"
sendMessage(chatId: ID!, content: String!): ChatMessage sendMessage(chatId: ID!, content: String!): ChatMessage
"create the post" "create the post"
createPost(content: String!): Post createPost(content: String!): Post!
"delete the post for a given post id" "delete the post for a given post id"
deletePost(postId: ID!): Boolean deletePost(postId: ID!): Boolean!
"Creates a chat between the user (and optional an other user)" "Creates a chat between the user (and optional an other user)"
createChat(members: [ID!]): ChatRoom createChat(members: [ID!]): ChatRoom!
"Creates a new group with a given name and additional members"
createGroup(name: String!, members: [ID!]): Group!
"Joins a group with the given id"
joinGroup(id: ID!): Group
"leaves the group with the given id"
leaveGroup(id: ID!): Group
"adds an admin to the group"
addGroupAdmin(groupId: ID!, userId: ID!): Group
"removes an admin from the group"
removeGroupAdmin(groupId: ID!, userId: ID!): Group
"Creates a new event with a epoch due date on a group."
createEvent(name: String, dueDate: String, groupId: ID!): Event
"Joins a event."
joinEvent(eventId: ID!): Event
"Leaves a event."
leaveEvent(eventId: ID!): Event
} }
interface UserData { interface UserData {
@ -72,17 +108,35 @@ interface UserData {
"Id of the User" "Id of the User"
id: ID! id: ID!
"the total number of posts the user posted" "DEPRECATED! the total number of posts the user posted"
numberOfPosts: Int numberOfPosts: Int!
"the number of posts the user has created"
postCount: Int!
"returns a given number of posts of a user" "returns a given number of posts of a user"
posts(first: Int=10, offset: Int): [Post] posts(first: Int=10, offset: Int=0): [Post]
"creation date of the user account" "creation date of the user account"
joinedAt: String! joinedAt: String!
"all friends of the user" "all friends of the user"
friends: [User] friends(first: Int=10, offset: Int=0): [User]
"The number of friends the user has"
friendCount: Int!
"The groups the user has joined"
groups(first: Int=10, offset: Int=0): [Group]
"The numbef of groups the user has joined"
groupCount: Int!
"the points of the user"
points: Int!
"the levels of the user depending on the points"
level: Int!
} }
"represents a single user account" "represents a single user account"
@ -100,16 +154,34 @@ type User implements UserData{
id: ID! id: ID!
"the total number of posts the user posted" "the total number of posts the user posted"
numberOfPosts: Int numberOfPosts: Int!
"returns a given number of posts of a user" "returns a given number of posts of a user"
posts(first: Int=10, offset: Int): [Post] posts(first: Int=10, offset: Int): [Post]
"the number of posts the user has created"
postCount: Int!
"creation date of the user account" "creation date of the user account"
joinedAt: String! joinedAt: String!
"all friends of the user" "all friends of the user"
friends: [User] friends(first: Int=10, offset: Int=0): [User]
"The number of friends the user has"
friendCount: Int!
"the points of the user"
points: Int!
"the groups the user has joined"
groups(first: Int=10, offset: Int=0): [Group]
"The numbef of groups the user has joined"
groupCount: Int!
"the levels of the user depending on the points"
level: Int!
} }
type Profile implements UserData { type Profile implements UserData {
@ -119,9 +191,15 @@ type Profile implements UserData {
"name of the User" "name of the User"
name: String! name: String!
"the email of the user"
email: String!
"returns the chatrooms the user joined." "returns the chatrooms the user joined."
chats(first: Int=10, offset: Int): [ChatRoom] chats(first: Int=10, offset: Int): [ChatRoom]
"the count of the users chats"
chatCount: Int!
"unique identifier name from the User" "unique identifier name from the User"
handle: String! handle: String!
@ -129,22 +207,46 @@ type Profile implements UserData {
id: ID! id: ID!
"the total number of posts the user posted" "the total number of posts the user posted"
numberOfPosts: Int numberOfPosts: Int!
"the number of posts the user has created"
postCount: Int!
"returns a given number of posts of a user" "returns a given number of posts of a user"
posts(first: Int=10, offset: Int): [Post] posts(first: Int=10, offset: Int): [Post!]!
"creation date of the user account" "creation date of the user account"
joinedAt: String! joinedAt: String!
"all friends of the user" "all friends of the user"
friends: [User] friends(first: Int=10, offset: Int=0): [User!]!
"The number of friends the user has"
friendCount: Int!
"all sent request for groupChats/friends/events" "all sent request for groupChats/friends/events"
sentRequests: [Request] sentRequests: [Request!]!
"all received request for groupChats/friends/events" "all received request for groupChats/friends/events"
receivedRequests: [Request] receivedRequests: [Request!]!
"all groups the user is an admin of"
administratedGroups: [Group!]!
"all groups the user has created"
createdGroups: [Group!]!
"all groups the user has joined"
groups(first: Int=10, offset: Int=0): [Group!]!
"The numbef of groups the user has joined"
groupCount: Int!
"the points of the user"
points: Int!
"the levels of the user depending on the points"
level: Int!
} }
"represents a single user post" "represents a single user post"
@ -177,6 +279,10 @@ type Post {
"represents a request of any type" "represents a request of any type"
type Request { type Request {
"Id of the request."
id: ID!
"Id of the user who sended the request" "Id of the user who sended the request"
sender: User! sender: User!
@ -193,7 +299,7 @@ type ChatRoom {
namespace: String namespace: String
"the members of the chatroom" "the members of the chatroom"
members: [User!] members(first: Int=10, offset: Int=0): [User!]
"return a specfic range of messages posted in the chat" "return a specfic range of messages posted in the chat"
messages(first: Int = 10, offset: Int, containing: String): [ChatMessage]! messages(first: Int = 10, offset: Int, containing: String): [ChatMessage]!
@ -203,6 +309,9 @@ type ChatRoom {
} }
type ChatMessage { type ChatMessage {
"Id of the chat message"
id: ID!
"The author of the chat message." "The author of the chat message."
author: User! author: User!
@ -219,6 +328,46 @@ type ChatMessage {
htmlContent: String htmlContent: String
} }
type Group {
"ID of the group"
id: ID!
"name of the group"
name: String!
"the creator of the group"
creator: User
"all admins of the group"
admins(first: Int=10, offset: Int=0): [User]!
"the members of the group with pagination"
members(first: Int = 10, offset: Int = 0): [User]!
"the groups chat"
chat: ChatRoom
"the events of the group"
events(first: Int=10, offset: Int=0): [Event!]!
}
type Event {
"ID of the event"
id: ID!
"Name of the event"
name: String!
"The date of the event."
dueDate: String!
"The group the event belongs to."
group: Group!
"The participants of the event."
participants(first: Int=10, offset: Int=0): [User!]!
}
"represents the type of vote performed on a post" "represents the type of vote performed on a post"
enum VoteType { enum VoteType {
UPVOTE UPVOTE
@ -234,3 +383,8 @@ enum RequestType {
GROUPINVITE GROUPINVITE
EVENTINVITE EVENTINVITE
} }
enum SortType {
TOP
NEW
}

@ -1,3 +1,6 @@
/**
* Events that are emitted and processsed internally on the server
*/
export enum InternalEvents { export enum InternalEvents {
CHATCREATE = "chatCreate", CHATCREATE = "chatCreate",
CHATMESSAGE = "chatMessage", CHATMESSAGE = "chatMessage",

@ -1,73 +0,0 @@
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("sha1");
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];
}
}
}
}

@ -1,212 +0,0 @@
/**
* @author Trivernis
* @remarks
*
* Taken from {@link https://github.com/Trivernis/whooshy}
*/
import * as fsx from "fs-extra";
import {Pool, PoolClient, QueryConfig, QueryResult} from "pg";
import globals from "./globals";
const logger = globals.logger;
export interface IAdvancedQueryConfig extends QueryConfig {
cache?: boolean;
}
/**
* Transaction class to wrap SQL transactions.
*/
export class SqlTransaction {
/**
* Constructor.
* @param client
*/
constructor(private client: PoolClient) {
}
/**
* Begins the transaction.
*/
public async begin() {
return await this.client.query("BEGIN");
}
/**
* Commits the transaction
*/
public async commit() {
return await this.client.query("COMMIT");
}
/**
* Rolls back the transaction
*/
public async rollback() {
return await this.client.query("ROLLBACK");
}
/**
* Executes a query inside the transaction.
* @param query
*/
public async query(query: QueryConfig) {
return await this.client.query(query);
}
/**
* Releases the client back to the pool.
*/
public release() {
this.client.release();
}
}
/**
* Query helper for easyer fetching of a specific row count.
*/
export class QueryHelper {
private pool: Pool;
/**
* Constructor.
* @param pgPool
* @param [tableCreationFile]
* @param [tableUpdateFile]
*/
constructor(pgPool: Pool, private tableCreationFile?: string, private tableUpdateFile?: string) {
this.pool = pgPool;
}
/**
* Async init function
*/
public async init() {
await this.pool.connect();
await this.createTables();
await this.updateTableDefinitions();
}
/**
* creates all tables needed if a filepath was given with the constructor
*/
public async createTables() {
if (this.tableCreationFile) {
logger.info("Creating nonexistent tables...");
const tableSql = await fsx.readFile(this.tableCreationFile, "utf-8");
const trans = await this.createTransaction();
await trans.begin();
try {
await trans.query({text: tableSql});
await trans.commit();
} catch (err) {
globals.logger.error(`Error on table creation ${err.message}`);
globals.logger.debug(err.stack);
await trans.rollback();
} finally {
trans.release();
}
}
}
/**
* Updates the definition of the tables if the table update file was passed in the constructor
*/
public async updateTableDefinitions() {
if (this.tableUpdateFile) {
logger.info("Updating table definitions...");
const tableSql = await fsx.readFile(this.tableUpdateFile, "utf-8");
const trans = await this.createTransaction();
await trans.begin();
try {
await trans.query({text: tableSql});
await trans.commit();
} catch (err) {
globals.logger.error(`Error on table update ${err.message}`);
globals.logger.debug(err.stack);
await trans.rollback();
} finally {
trans.release();
}
}
}
/**
* executes the sql query with values and returns all results.
* @param query
*/
public async all(query: IAdvancedQueryConfig): Promise<any[]> {
const result = await this.query(query);
return result.rows;
}
/**
* executes the sql query with values and returns the first result.
* @param query
*/
public async first(query: IAdvancedQueryConfig): Promise<any> {
const result = await this.query(query);
if (result.rows && result.rows.length > 0) {
return result.rows[0];
}
}
/**
* Creates a new Transaction to be uses with error handling.
*/
public async createTransaction() {
const client: PoolClient = await this.pool.connect();
return new SqlTransaction(client);
}
/**
* Queries the database with error handling.
* @param query - the sql and values to execute
*/
private async query(query: IAdvancedQueryConfig): Promise<QueryResult|{rows: any}> {
try {
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 "${JSON.stringify(query)}".`);
logger.error(`Sql query failed: ${err}`);
logger.verbose(err.stack);
return {
rows: null,
};
}
}
}
/**
* Returns the parameterized value sql for inserting
* @param columnCount
* @param rowCount
* @param [offset]
*/
export function buildSqlParameters(columnCount: number, rowCount: number, offset?: number): string {
let sql = "";
for (let i = 0; i < rowCount; i++) {
sql += "(";
for (let j = 0; j < columnCount; j++) {
sql += `$${(i * columnCount) + j + 1 + offset},`;
}
sql = sql.replace(/,$/, "") + "),";
}
return sql.replace(/,$/, "");
}

@ -1,10 +1,3 @@
/**
* @author Trivernis
* @remarks
*
* Taken from {@link https://github.com/Trivernis/whooshy}
*/
import {Router} from "express"; import {Router} from "express";
import {Namespace, Server} from "socket.io"; import {Namespace, Server} from "socket.io";
@ -20,6 +13,7 @@ abstract class Route {
protected ions?: Namespace; protected ions?: Namespace;
public abstract async init(...params: any): Promise<any>; public abstract async init(...params: any): Promise<any>;
public abstract async destroy(...params: any): Promise<any>; public abstract async destroy(...params: any): Promise<any>;
} }

@ -0,0 +1,317 @@
import * as crypto from "crypto";
import * as sqz from "sequelize";
import {Sequelize} from "sequelize-typescript";
import {ChatNotFoundError} from "./errors/ChatNotFoundError";
import {EmailAlreadyRegisteredError} from "./errors/EmailAlreadyRegisteredError";
import {GroupNotFoundError} from "./errors/GroupNotFoundError";
import {InvalidLoginError} from "./errors/InvalidLoginError";
import {NoActionSpecifiedError} from "./errors/NoActionSpecifiedError";
import {UserNotFoundError} from "./errors/UserNotFoundError";
import globals from "./globals";
import {InternalEvents} from "./InternalEvents";
import * as models from "./models";
/**
* Generates a new handle from the username and a base64 string of the current time.
* @param username
*/
async function generateHandle(username: string) {
username = username.toLowerCase().replace(/\s/g, "_");
const count = await models.User.count({where: {handle: {[sqz.Op.like]: `%${username}%`}}});
if (count > 0) {
return `${username}${count}`;
} else {
return username;
}
}
/**
* Namespace with functions to fetch initial data for wrapping.
*/
namespace dataaccess {
let sequelize: Sequelize;
/**
* Initializes everything that needs to be initialized asynchronous.
*/
export async function init(seq: Sequelize) {
sequelize = seq;
try {
await sequelize.addModels([
models.ChatMember,
models.ChatMessage,
models.ChatRoom,
models.Friendship,
models.Post,
models.PostVote,
models.Request,
models.User,
models.Group,
models.GroupAdmin,
models.GroupMember,
models.EventParticipant,
models.Event,
]);
} catch (err) {
globals.logger.error(err.message);
globals.logger.debug(err.stack);
}
}
/**
* Returns the user by handle.
* @param userHandle
*/
export async function getUserByHandle(userHandle: string): Promise<models.User> {
const user = await models.User.findOne({where: {handle: userHandle}});
if (user) {
return user;
} 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<models.User> {
const hash = crypto.createHash("sha512");
hash.update(password);
password = hash.digest("hex");
const user = await models.User.findOne({where: {email}});
if (user) {
if (user.password === password) {
return user;
} else {
throw new InvalidLoginError(email);
}
} 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): Promise<models.User> {
const hash = crypto.createHash("sha512");
hash.update(password);
password = hash.digest("hex");
const existResult = !!(await models.User.findOne({where: {username, email, password}}));
const handle = await generateHandle(username);
if (!existResult) {
return models.User.create({username, email, password, handle});
} else {
throw new EmailAlreadyRegisteredError(email);
}
}
/**
* Returns a post for a given postId.s
* @param postId
*/
export async function getPost(postId: number): Promise<models.Post> {
const post = await models.Post.findByPk(postId);
if (post) {
return post;
} else {
return null;
}
}
/**
* Returns all posts sorted by new or top with pagination.
* @param first
* @param offset
* @param sort
*/
export async function getPosts(first: number, offset: number, sort: SortType) {
if (sort === SortType.NEW) {
return models.Post.findAll({
include: [{association: "rVotes"}],
limit: first,
offset,
order: [["createdAt", "DESC"]],
});
} else {
return await sequelize.query(
`SELECT * FROM (
SELECT *,
(SELECT count(*) FROM post_votes WHERE vote_type = 'UPVOTE' AND post_id = posts.id) AS upvotes ,
(SELECT count(*) FROM post_votes WHERE vote_type = 'DOWNVOTE' AND post_id = posts.id) AS downvotes
FROM posts) AS a ORDER BY (a.upvotes - a.downvotes) DESC LIMIT ? OFFSET ?`,
{replacements: [first, offset], mapToModel: true, model: models.Post}) as models.Post[];
}
}
/**
* Creates a post
* @param content
* @param authorId
* @param type
*/
export async function createPost(content: string, authorId: number, type?: string): Promise<models.Post> {
type = type || "MISC";
const post = await models.Post.create({content, authorId});
globals.internalEmitter.emit(InternalEvents.POSTCREATE, post);
return post;
}
/**
* Deletes a post
* @param postId
*/
export async function deletePost(postId: number): Promise<boolean> {
await (await models.Post.findByPk(postId)).destroy();
return true;
}
/**
* Creates a chatroom containing two users
* @param members
*/
export async function createChat(...members: number[]): Promise<models.ChatRoom> {
return sequelize.transaction(async (t) => {
const chat = await models.ChatRoom.create({}, {transaction: t, include: [models.User]});
for (const member of members) {
const user = await models.User.findByPk(member);
await chat.$add("rMember", user, {transaction: t});
}
await chat.save({transaction: t});
globals.internalEmitter.emit(InternalEvents.CHATCREATE, chat);
return chat;
});
}
/**
* Sends a message into a chat.
* @param authorId
* @param chatId
* @param content
*/
export async function sendChatMessage(authorId: number, chatId: number, content: string) {
const chat = await models.ChatRoom.findByPk(chatId);
if (chat) {
const message = await chat.$create("rMessage", {content, authorId}) as models.ChatMessage;
globals.internalEmitter.emit(InternalEvents.CHATMESSAGE, message);
return message;
} else {
throw new ChatNotFoundError(chatId);
}
}
/**
* Returns all rChats.
*/
export async function getAllChats(): Promise<models.ChatRoom[]> {
return models.ChatRoom.findAll();
}
/**
* Sends a request to a user.
* @param sender
* @param receiver
* @param requestType
*/
export async function createRequest(sender: number, receiver: number, requestType?: RequestType) {
requestType = requestType || RequestType.FRIENDREQUEST;
const request = await models.Request.create({senderId: sender, receiverId: receiver, requestType});
globals.internalEmitter.emit(InternalEvents.REQUESTCREATE, request);
return request;
}
/**
* Create a new group.
* @param name
* @param creator
* @param members
*/
export async function createGroup(name: string, creator: number, members: number[]): Promise<models.Group> {
members = members || [];
return sequelize.transaction(async (t) => {
members.push(creator);
const groupChat = await createChat(...members);
const group = await models.Group.create({name, creatorId: creator, chatId: groupChat.id}, {transaction: t});
const creatorUser = await models.User.findByPk(creator, {transaction: t});
await group.$add("rAdmins", creatorUser, {transaction: t});
for (const member of members) {
const user = await models.User.findByPk(member, {transaction: t});
await group.$add("rMembers", user, {transaction: t});
}
return group;
});
}
/**
* Changes the membership of a user
* @param groupId
* @param userId
* @param action
*/
export async function changeGroupMembership(groupId: number, userId: number, action: MembershipChangeAction):
Promise<models.Group> {
const group = await models.Group.findByPk(groupId);
if (group) {
const user = await models.User.findByPk(userId);
if (user) {
if (action === MembershipChangeAction.ADD) {
await group.$add("rMembers", user);
} else if (action === MembershipChangeAction.REMOVE) {
await group.$remove("rMembers", user);
} else if (action === MembershipChangeAction.OP) {
await group.$add("rAdmins", user);
} else if (action === MembershipChangeAction.DEOP) {
await group.$remove("rAdmins", user);
} else {
throw new NoActionSpecifiedError(MembershipChangeAction);
}
return group;
} else {
throw new UserNotFoundError(userId);
}
} else {
throw new GroupNotFoundError(groupId);
}
}
/**
* Enum representing the types of votes that can be performed on a post.
*/
export enum VoteType {
UPVOTE = "UPVOTE",
DOWNVOTE = "DOWNVOTE",
}
/**
* Enum representing the types of request that can be created.
*/
export enum RequestType {
FRIENDREQUEST = "FRIENDREQUEST",
GROUPINVITE = "GROUPINVITE",
EVENTINVITE = "EVENTINVITE",
}
/**
* Enum representing the types of sorting in the feed.
*/
export enum SortType {
TOP = "TOP",
NEW = "NEW",
}
export enum MembershipChangeAction {
ADD,
REMOVE,
OP,
DEOP,
}
}
export default dataaccess;

@ -1,31 +0,0 @@
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);
}
/**
* Returns resolved and rendered content of the chat message.
*/
public resolvedContent() {
return {
author: this.author.id,
chat: this.chat.id,
content: this.content,
createdAt: this.createdAt,
htmlContent: this.htmlContent(),
};
}
}

@ -1,76 +0,0 @@
import globals from "../globals";
import {ChatMessage} from "./ChatMessage";
import {queryHelper} from "./index";
import {User} from "./User";
export class Chatroom {
public namespace: string;
constructor(public readonly id: number) {
this.id = Number(id);
this.namespace = `/chat/${id}`;
}
/**
* Returns if the chat exists.
*/
public async exists(): Promise<boolean> {
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<User[]> {
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;
}
}
}

@ -1,40 +0,0 @@
/**
* abstact DataObject class
*/
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<void>;
/**
* Loads data from the database if data has not been loaded
*/
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());
});
}
}
}

@ -1,159 +0,0 @@
import markdown from "../markdown";
import {DataObject} from "./DataObject";
import {queryHelper} from "./index";
import dataaccess from "./index";
import {User} from "./User";
export class Post extends DataObject {
public readonly id: number;
private $createdAt: string;
private $content: string;
private $author: number;
private $type: string;
/**
* Returns the resolved data of the post.
*/
public async resolvedData() {
await this.loadDataIfNotExists();
return {
authorId: this.$author,
content: this.$content,
createdAt: this.$createdAt,
id: this.id,
type: this.$type,
};
}
/**
* Returns the upvotes of a post.
*/
public async upvotes(): Promise<number> {
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<number> {
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<string> {
await this.loadDataIfNotExists();
return this.$content;
}
/**
* the content rendered by markdown-it.
*/
public async htmlContent(): Promise<string> {
await this.loadDataIfNotExists();
return markdown.render(this.$content);
}
/**
* The date the post was created at.
*/
public async createdAt(): Promise<string> {
await this.loadDataIfNotExists();
return this.$createdAt;
}
/**
* The autor of the post.
*/
public async author(): Promise<User> {
await this.loadDataIfNotExists();
return new User(this.$author);
}
/**
* Deletes the post.
*/
public async delete(): Promise<void> {
const query = await queryHelper.first({
text: "DELETE FROM posts WHERE id = $1",
values: [this.id],
});
}
/**
* The type of vote the user performed on the post.
*/
public async userVote(userId: number): Promise<dataaccess.VoteType> {
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],
});
if (result) {
return result.vote_type;
} else {
return null;
}
}
/**
* Performs a vote on a post.
* @param userId
* @param type
*/
public async vote(userId: number, type: dataaccess.VoteType): Promise<dataaccess.VoteType> {
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.
*/
protected async loadData(): Promise<void> {
let result: any;
if (this.row) {
result = this.row;
} else {
result = await queryHelper.first({
cache: true,
text: "SELECT * FROM posts WHERE posts.id = $1",
values: [this.id],
});
}
if (result) {
this.$author = result.author;
this.$content = result.content;
this.$createdAt = result.created_at;
this.$type = result.type;
this.dataLoaded = true;
}
}
}

@ -1,163 +0,0 @@
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<Chatroom[]> {
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<number> {
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<string> {
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<string> {
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<string> {
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;
}
}

@ -1,24 +0,0 @@
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) {
}
/**
* Returns the resolved request data.
*/
public resolvedData() {
return {
receiverId: this.receiver.id,
senderId: this.sender.id,
type: this.type,
};
}
}

@ -1,128 +0,0 @@
import globals from "../globals";
import {DataObject} from "./DataObject";
import {queryHelper} from "./index";
import {Post} from "./Post";
export class User extends DataObject {
private $name: string;
private $handle: string;
private $email: string;
private $greenpoints: number;
private $joinedAt: string;
private $exists: boolean;
/**
* The name of the user
*/
public async name(): Promise<string> {
await this.loadDataIfNotExists();
return this.$name;
}
/**
* The unique handle of the user.
*/
public async handle(): Promise<string> {
await this.loadDataIfNotExists();
return this.$handle;
}
/**
* The email of the user
*/
public async email(): Promise<string> {
await this.loadDataIfNotExists();
return this.$email;
}
/**
* The number of greenpoints of the user
*/
public async greenpoints(): Promise<number> {
await this.loadDataIfNotExists();
return this.$greenpoints;
}
/**
* Returns the number of posts the user created
*/
public async numberOfPosts(): Promise<number> {
const result = await queryHelper.first({
cache: true,
text: "SELECT COUNT(*) count FROM posts WHERE author = $1",
values: [this.id],
});
return result.count;
}
/**
* The date the user joined the platform
*/
public async joinedAt(): Promise<Date> {
await this.loadDataIfNotExists();
return new Date(this.$joinedAt);
}
/**
* Returns all friends of the user.
*/
public async friends(): Promise<User[]> {
const result = await queryHelper.all({
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<Post[]> {
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) {
posts.push(new Post(row.id, row));
}
return posts;
}
/**
* Fetches the data for the user.
*/
protected async loadData(): Promise<void> {
let result: any;
if (this.row) {
result = this.row;
} else {
result = await queryHelper.first({
cache: true,
text: "SELECT * FROM users WHERE users.id = $1",
values: [this.id],
});
}
if (result) {
this.$name = result.name;
this.$handle = result.handle;
this.$email = result.email;
this.$greenpoints = result.greenpoints;
this.$joinedAt = result.joined_at;
this.dataLoaded = true;
}
}
}

@ -1,258 +0,0 @@
import {Pool} from "pg";
import {ChatNotFoundError} from "../errors/ChatNotFoundError";
import {EmailAlreadyRegisteredError} from "../errors/EmailAlreadyRegisteredError";
import {UserNotFoundError} from "../errors/UserNotFoundError";
import globals from "../globals";
import {InternalEvents} from "../InternalEvents";
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;
const tableCreationFile = __dirname + "/../../sql/create-tables.sql";
const tableUpdateFile = __dirname + "/../../sql/update-tables.sql";
const dbClient: Pool = new Pool({
database: config.database.database,
host: config.database.host,
password: config.database.password,
port: config.database.port,
user: config.database.user,
});
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() {
try {
await queryHelper.init();
} catch (err) {
globals.logger.error(err.message);
globals.logger.debug(err.stack);
}
}
/**
* Returns the user by handle.
* @param userHandle
*/
export async function getUserByHandle(userHandle: string): Promise<User> {
const result = await queryHelper.first({
text: "SELECT * FROM users WHERE users.handle = $1",
values: [userHandle],
});
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<Profile> {
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 existResult = await queryHelper.first({
text: "SELECT email FROM users WHERE email = $1;",
values: [email],
});
if (!existResult || !existResult.email) {
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);
} else {
throw new EmailAlreadyRegisteredError(email);
}
}
/**
* Returns a post for a given postId.s
* @param postId
*/
export async function getPost(postId: number): Promise<Post> {
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<Post> {
type = type || "MISC";
const result = await queryHelper.first({
text: "INSERT INTO posts (content, author, type) VALUES ($1, $2, $3) RETURNING *",
values: [content, authorId, type],
});
const post = new Post(result.id, result);
globals.internalEmitter.emit(InternalEvents.POSTCREATE, post);
return post;
}
/**
* Deletes a post
* @param postId
*/
export async function deletePost(postId: number): Promise<boolean> {
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<Chatroom> {
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();
}
const chat = new Chatroom(id);
globals.internalEmitter.emit(InternalEvents.CHATCREATE, chat);
return chat;
}
/**
* 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],
});
const message = new ChatMessage(new User(result.author), chat, result.created_at, result.content);
globals.internalEmitter.emit(InternalEvents.CHATMESSAGE, message);
return message;
} else {
throw new ChatNotFoundError(chatId);
}
}
/**
* Returns all chats.
*/
export async function getAllChats(): Promise<Chatroom[]> {
const result = await queryHelper.all({
text: "SELECT id FROM chats;",
});
const chats = [];
for (const row of result) {
chats.push(new Chatroom(row.id));
}
return chats;
}
/**
* 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],
});
const request = new Request(new User(result.sender), new User(result.receiver), result.type);
globals.internalEmitter.emit(InternalEvents.REQUESTCREATE, Request);
return request;
}
/**
* Enum representing the types of votes that can be performed on a post.
*/
export enum VoteType {
UPVOTE = "UPVOTE",
DOWNVOTE = "DOWNVOTE",
}
/**
* Enum representing the types of request that can be created.
*/
export enum RequestType {
FRIENDREQUEST = "FRIENDREQUEST",
GROUPINVITE = "GROUPINVITE",
EVENTINVITE = "EVENTINVITE",
}
}
export default dataaccess;

@ -0,0 +1,8 @@
import {BaseError} from "./BaseError";
export class GroupNotFoundError extends BaseError {
constructor(groupId: number) {
super(`Group ${groupId} not found!`);
}
}

@ -0,0 +1,7 @@
import {BaseError} from "./BaseError";
export class InvalidLoginError extends BaseError {
constructor(email: (string)) {
super(`Invalid login data for ${email}.`);
}
}

@ -0,0 +1,11 @@
import {BaseError} from "./BaseError";
export class NoActionSpecifiedError extends BaseError {
constructor(actions?: any) {
if (actions) {
super(`No action of '${Object.keys(actions).join(", ")}'`);
} else {
super("No action specified!");
}
}
}

@ -1,7 +1,7 @@
import {BaseError} from "./BaseError"; import {BaseError} from "./BaseError";
export class UserNotFoundError extends BaseError { export class UserNotFoundError extends BaseError {
constructor(username: string) { constructor(username: (string|number)) {
super(`User ${username} not found!`); super(`User ${username} not found!`);
} }
} }

@ -1,9 +1,19 @@
import {GraphQLError} from "graphql"; import {GraphQLError} from "graphql";
import {BaseError} from "./BaseError";
export class NotLoggedInGqlError extends GraphQLError { export class NotLoggedInGqlError extends GraphQLError {
constructor() { constructor() {
super("Not logged in"); super("Not logged in");
} }
} }
export class PostNotFoundGqlError extends GraphQLError {
constructor(postId: number) {
super(`Post '${postId}' not found!`);
}
}
export class GroupNotFoundGqlError extends GraphQLError {
constructor(groupId: number) {
super(`Group '${groupId}' not found!`);
}
}

@ -1,15 +1,9 @@
/**
* @author Trivernis
* @remarks
*
* Partly taken from {@link https://github.com/Trivernis/whooshy}
*/
import {EventEmitter} from "events"; import {EventEmitter} from "events";
import * as fsx from "fs-extra"; import * as fsx from "fs-extra";
import * as yaml from "js-yaml"; import * as yaml from "js-yaml";
import * as winston from "winston"; import * as winston from "winston";
import {MemoryCache} from "./MemoryCache";
require("winston-daily-rotate-file");
const configPath = "config.yaml"; const configPath = "config.yaml";
const defaultConfig = __dirname + "/../default-config.yaml"; const defaultConfig = __dirname + "/../default-config.yaml";
@ -28,25 +22,37 @@ if (!(fsx.pathExistsSync(configPath))) {
*/ */
namespace globals { namespace globals {
export const config = yaml.safeLoad(fsx.readFileSync("config.yaml", "utf-8")); export const config = yaml.safeLoad(fsx.readFileSync("config.yaml", "utf-8"));
export const cache = new MemoryCache(1200); // @ts-ignore
export const logger = winston.createLogger({ export const logger = winston.createLogger({
transports: [ transports: [
new winston.transports.Console({ new winston.transports.Console({
format: winston.format.combine( format: winston.format.combine(
winston.format.timestamp(), winston.format.timestamp(),
winston.format.colorize(), winston.format.colorize(),
winston.format.printf(({ level, message, timestamp }) => { winston.format.printf(({level, message, timestamp}) => {
return `${timestamp} ${level}: ${message}`;
}),
),
level: config.logging.level,
}),
// @ts-ignore
new (winston.transports.DailyRotateFile)({
dirname: "logs",
filename: "gv-%DATE%.log",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.printf(({level, message, timestamp}) => {
return `${timestamp} ${level}: ${message}`; return `${timestamp} ${level}: ${message}`;
}), }),
), ),
json: false,
level: config.logging.level, level: config.logging.level,
maxFiles: "7d",
zippedArchive: true,
}), }),
], ],
}); });
export const internalEmitter = new EventEmitter(); export const internalEmitter = new EventEmitter();
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; export default globals;

@ -0,0 +1,16 @@
import {Column, ForeignKey, Model, NotNull, Table,} from "sequelize-typescript";
import {ChatRoom} from "./ChatRoom";
import {User} from "./User";
@Table({underscored: true})
export class ChatMember extends Model<ChatMember> {
@ForeignKey(() => User)
@NotNull
@Column({allowNull: false})
public userId: number;
@ForeignKey(() => ChatRoom)
@NotNull
@Column({allowNull: false})
public chatId: number;
}

@ -0,0 +1,44 @@
import * as sqz from "sequelize";
import {BelongsTo, Column, CreatedAt, ForeignKey, Model, NotNull, Table,} from "sequelize-typescript";
import markdown from "../markdown";
import {ChatRoom} from "./ChatRoom";
import {User} from "./User";
@Table({underscored: true})
export class ChatMessage extends Model<ChatMessage> {
@NotNull
@Column({type: sqz.STRING(512), allowNull: false})
public content: string;
@ForeignKey(() => ChatRoom)
@NotNull
@Column({allowNull: false})
public chatId: number;
@ForeignKey(() => User)
@NotNull
@Column({allowNull: false})
public authorId: number;
@BelongsTo(() => ChatRoom, "chatId")
public rChat: ChatRoom;
@BelongsTo(() => User, "authorId")
public rAuthor: User;
@CreatedAt
public createdAt: Date;
public async chat(): Promise<ChatRoom> {
return await this.$get("rChat") as ChatRoom;
}
public async author(): Promise<User> {
return await this.$get("rAuthor") as User;
}
public get htmlContent(): string {
return markdown.renderInline(this.getDataValue("content"));
}
}

@ -0,0 +1,28 @@
import {BelongsToMany, CreatedAt, HasMany, Model, Table,} from "sequelize-typescript";
import {ChatMember} from "./ChatMember";
import {ChatMessage} from "./ChatMessage";
import {User} from "./User";
@Table({underscored: true})
export class ChatRoom extends Model<ChatRoom> {
@BelongsToMany(() => User, () => ChatMember)
public rMembers: User[];
@HasMany(() => ChatMessage, "chatId")
public rMessages: ChatMessage[];
@CreatedAt
public readonly createdAt!: Date;
public async members(): Promise<User[]> {
return await this.$get("rMembers") as User[];
}
public async messages(): Promise<ChatMessage[]> {
return await this.$get("rMessages") as ChatMessage[];
}
public get namespace(): string {
return "/chats/" + this.getDataValue("id");
}
}

@ -0,0 +1,36 @@
import {BelongsTo, BelongsToMany, Column, ForeignKey, Model, NotNull, Table} from "sequelize-typescript";
import {EventParticipant} from "./EventParticipant";
import {Group} from "./Group";
import {User} from "./User";
@Table({underscored: true})
export class Event extends Model<Event> {
@NotNull
@Column({allowNull: false})
public name: string;
@NotNull
@Column({allowNull: false})
public dueDate: Date;
@NotNull
@ForeignKey(() => Group)
@Column({allowNull: false})
public groupId: number;
@BelongsTo(() => Group, "groupId")
public rGroup: Group;
@BelongsToMany(() => User, () => EventParticipant)
public rParticipants: User[];
public async group(): Promise<Group> {
return await this.$get("rGroup") as Group;
}
public async participants({first, offset}: {first: number, offset: number}): Promise<User[]> {
const limit = first || 10;
offset = offset || 0;
return await this.$get("rParticipants", {limit, offset}) as User[];
}
}

@ -0,0 +1,16 @@
import {BelongsTo, BelongsToMany, Column, ForeignKey, Model, NotNull, Table} from "sequelize-typescript";
import {Event} from "./Event";
import {User} from "./User";
@Table({underscored: true})
export class EventParticipant extends Model<EventParticipant> {
@NotNull
@ForeignKey(() => User)
@Column({allowNull: false})
public userId: number;
@NotNull
@ForeignKey(() => Event)
@Column({allowNull: false})
public eventId: number;
}

@ -0,0 +1,18 @@
import {Column, ForeignKey, Model, NotNull, PrimaryKey, Table} from "sequelize-typescript";
import {User} from "./User";
@Table({underscored: true})
export class Friendship extends Model<Friendship> {
@ForeignKey(() => User)
@PrimaryKey
@NotNull
@Column({allowNull: false})
public userId: number;
@ForeignKey(() => User)
@PrimaryKey
@NotNull
@Column({allowNull: false})
public friendId: number;
}

@ -0,0 +1,64 @@
import {BelongsTo, BelongsToMany, Column, ForeignKey, HasMany, Model, NotNull, Table} from "sequelize-typescript";
import {ChatRoom} from "./ChatRoom";
import {Event} from "./Event";
import {GroupAdmin} from "./GroupAdmin";
import {GroupMember} from "./GroupMember";
import {User} from "./User";
@Table({underscored: true})
export class Group extends Model<Group> {
@NotNull
@Column({allowNull: false})
public name: string;
@NotNull
@ForeignKey(() => User)
@Column({allowNull: false})
public creatorId: number;
@NotNull
@ForeignKey(() => ChatRoom)
@Column({allowNull: false})
public chatId: number;
@BelongsTo(() => User, "creatorId")
public rCreator: User;
@BelongsToMany(() => User, () => GroupAdmin)
public rAdmins: User[];
@BelongsToMany(() => User, () => GroupMember)
public rMembers: User[];
@BelongsTo(() => ChatRoom)
public rChat: ChatRoom;
@HasMany(() => Event, "groupId")
public rEvents: Event[];
public async creator(): Promise<User> {
return await this.$get("rCreator") as User;
}
public async admins({first, offset}: { first: number, offset: number }): Promise<User[]> {
const limit = first || 10;
offset = offset || 0;
return await this.$get("rAdmins", {limit, offset}) as User[];
}
public async members({first, offset}: { first: number, offset: number }): Promise<User[]> {
const limit = first || 10;
offset = offset || 0;
return await this.$get("rMembers", {limit, offset}) as User[];
}
public async chat(): Promise<ChatRoom> {
return await this.$get("rChat") as ChatRoom;
}
public async events({first, offset}: { first: number, offset: number }): Promise<Event[]> {
const limit = first || 10;
offset = offset || 0;
return await this.$get("rEvents", {limit, offset}) as Event[];
}
}

@ -0,0 +1,16 @@
import {Column, ForeignKey, Model, NotNull, Table,} from "sequelize-typescript";
import {Group} from "./Group";
import {User} from "./User";
@Table({underscored: true})
export class GroupAdmin extends Model<GroupAdmin> {
@NotNull
@ForeignKey(() => User)
@Column({allowNull: false})
public userId: number;
@NotNull
@ForeignKey(() => Group)
@Column({allowNull: false})
public groupId: number;
}

@ -0,0 +1,16 @@
import {Column, ForeignKey, Model, NotNull, Table,} from "sequelize-typescript";
import {Group} from "./Group";
import {User} from "./User";
@Table({underscored: true})
export class GroupMember extends Model<GroupMember> {
@NotNull
@ForeignKey(() => User)
@Column({allowNull: false})
public userId: number;
@NotNull
@ForeignKey(() => Group)
@Column({allowNull: false})
public groupId: number;
}

@ -0,0 +1,70 @@
import * as sqz from "sequelize";
import {BelongsTo, BelongsToMany, Column, CreatedAt, ForeignKey, Model, NotNull, Table,} from "sequelize-typescript";
import markdown from "../markdown";
import {PostVote, VoteType} from "./PostVote";
import {User} from "./User";
@Table({underscored: true})
export class Post extends Model<Post> {
@NotNull
@Column({type: sqz.STRING(2048), allowNull: false})
public content: string;
@ForeignKey(() => User)
@NotNull
@Column({allowNull: false})
public authorId: number;
@BelongsTo(() => User, "authorId")
public rAuthor: User;
@BelongsToMany(() => User, () => PostVote)
public rVotes: Array<User & {PostVote: PostVote}>;
@CreatedAt
public readonly createdAt!: Date;
public async author(): Promise<User> {
return await this.$get("rAuthor") as User;
}
public async votes(): Promise<Array<User & {PostVote: PostVote}>> {
return await this.$get("rVotes") as Array<User & {PostVote: PostVote}>;
}
public get htmlContent() {
return markdown.render(this.getDataValue("content"));
}
public async upvotes() {
return (await this.votes()).filter((v) => v.PostVote.voteType === VoteType.UPVOTE).length;
}
public async downvotes() {
return (await this.votes()).filter((v) => v.PostVote.voteType === VoteType.DOWNVOTE).length;
}
public async vote(userId: number, type: VoteType): Promise<VoteType> {
type = type || VoteType.UPVOTE;
let votes = await this.$get("rVotes", {where: {id: userId}}) as Array<User & {PostVote: PostVote}>;
let vote = votes[0] || null;
let created = false;
if (!vote) {
await this.$add("rVote", userId);
votes = await this.$get("rVotes", {where: {id: userId}}) as Array<User & {PostVote: PostVote}>;
vote = votes[0] || null;
created = true;
}
if (vote) {
if (vote.PostVote.voteType === type && !created) {
await vote.PostVote.destroy();
return null;
} else {
vote.PostVote.voteType = type;
await vote.PostVote.save();
}
}
return vote.PostVote.voteType;
}
}

@ -0,0 +1,26 @@
import * as sqz from "sequelize";
import {Column, ForeignKey, Model, NotNull, Table,} from "sequelize-typescript";
import {Post} from "./Post";
import {User} from "./User";
export enum VoteType {
UPVOTE = "UPVOTE",
DOWNVOTE = "DOWNVOTE",
}
@Table({underscored: true})
export class PostVote extends Model<PostVote> {
@NotNull
@Column({type: sqz.ENUM, values: ["UPVOTE", "DOWNVOTE"], defaultValue: "UPVOTE", allowNull: false})
public voteType: VoteType;
@ForeignKey(() => User)
@NotNull
@Column({allowNull: false})
public userId: number;
@ForeignKey(() => Post)
@NotNull
@Column({allowNull: false})
public postId: number;
}

@ -0,0 +1,45 @@
import * as sqz from "sequelize";
import {BelongsTo, Column, ForeignKey, Model, NotNull, Table,} from "sequelize-typescript";
import {User} from "./User";
export enum RequestType {
FRIENDREQUEST = "FRIENDREQUEST",
GROUPINVITE = "GROUPINVITE",
EVENTINVITE = "EVENTINVITE",
}
@Table({underscored: true})
export class Request extends Model<Request> {
@NotNull
@Column({
allowNull: false,
defaultValue: "FRIENDREQUEST",
type: sqz.ENUM,
values: ["FRIENDREQUEST", "GROUPINVITE", "EVENTINVITE"],
})
public requestType: RequestType;
@ForeignKey(() => User)
@NotNull
@Column({allowNull: false})
public senderId: number;
@BelongsTo(() => User, "senderId")
public rSender: User;
@ForeignKey(() => User)
@NotNull
@Column({allowNull: false})
public receiverId: number;
@BelongsTo(() => User, "receiverId")
public rReceiver: User;
public async receiver(): Promise<User> {
return await this.$get("rReceiver") as User;
}
public async sender(): Promise<User> {
return await this.$get("rSender") as User;
}
}

@ -0,0 +1,288 @@
import * as sqz from "sequelize";
import {
BelongsToMany,
Column,
CreatedAt,
HasMany,
Model,
NotNull,
Table,
Unique,
UpdatedAt,
} from "sequelize-typescript";
import {RequestNotFoundError} from "../errors/RequestNotFoundError";
import {UserNotFoundError} from "../errors/UserNotFoundError";
import {ChatMember} from "./ChatMember";
import {ChatMessage} from "./ChatMessage";
import {ChatRoom} from "./ChatRoom";
import {Event} from "./Event";
import {EventParticipant} from "./EventParticipant";
import {Friendship} from "./Friendship";
import {Group} from "./Group";
import {GroupAdmin} from "./GroupAdmin";
import {GroupMember} from "./GroupMember";
import {Post} from "./Post";
import {PostVote} from "./PostVote";
import {Request, RequestType} from "./Request";
@Table({underscored: true})
export class User extends Model<User> {
@NotNull
@Column({type: sqz.STRING(128), allowNull: false})
public username: string;
@NotNull
@Unique
@Column({type: sqz.STRING(128), allowNull: false, unique: true})
public handle: string;
@Unique
@NotNull
@Column({type: sqz.STRING(128), allowNull: false, unique: true})
public email: string;
@NotNull
@Column({type: sqz.STRING(128), allowNull: false})
public password: string;
@NotNull
@Column({defaultValue: 0, allowNull: false})
public rankpoints: number;
@BelongsToMany(() => User, () => Friendship, "userId")
public rFriends: User[];
@BelongsToMany(() => User, () => Friendship, "friendId")
public rFriendOf: User[];
@BelongsToMany(() => Post, () => PostVote)
public votes: Array<Post & { PostVote: PostVote }>;
@BelongsToMany(() => ChatRoom, () => ChatMember)
public rChats: ChatRoom[];
@BelongsToMany(() => Group, () => GroupAdmin)
public rAdministratedGroups: Group[];
@BelongsToMany(() => Event, () => EventParticipant)
public rEvents: Event[];
@BelongsToMany(() => Group, () => GroupMember)
public rGroups: Group[];
@HasMany(() => Post, "authorId")
public rPosts: Post[];
@HasMany(() => Request, "senderId")
public rSentRequests: Request[];
@HasMany(() => Request, "receiverId")
public rReceivedRequests: Request[];
@HasMany(() => ChatMessage, "authorId")
public messages: ChatMessage[];
@HasMany(() => Group, "creatorId")
public rCreatedGroups: Group[];
@CreatedAt
public readonly createdAt!: Date;
@UpdatedAt
public readonly updatedAt!: Date;
/**
* The name of the user
*/
public get name(): string {
return this.getDataValue("username");
}
/**
* The date the user joined the network
*/
public get joinedAt(): Date {
return this.getDataValue("createdAt");
}
/**
* The points of the user
*/
public get points(): number {
return this.rankpoints;
}
/**
* The level of the user which is the points divided by 100
*/
public get level(): number {
return Math.ceil(this.rankpoints / 100);
}
/**
* All friends of the user
* @param first
* @param offset
*/
public async friends({first, offset}: { first: number, offset: number }): Promise<User[]> {
const limit = first || 10;
offset = offset || 0;
return await this.$get("rFriendOf", {limit, offset}) as User[];
}
/**
* The total number of the users friends.
*/
public async friendCount(): Promise<number> {
return this.$count("rFriends");
}
/**
* The chats the user has joined
* @param first
* @param offset
*/
public async chats({first, offset}: { first: number, offset: number }): Promise<ChatRoom[]> {
const limit = first || 10;
offset = offset || 0;
return await this.$get("rChats", {limit, offset}) as ChatRoom[];
}
/**
* the number of chats the user has
*/
public async chatCount(): Promise<number> {
return this.$count("rChats");
}
/**
* All active requests the user ha ssent
*/
public async sentRequests(): Promise<Request[]> {
return await this.$get("rSentRequests") as Request[];
}
/**
* All requests the user has received
*/
public async receivedRequests(): Promise<Request[]> {
return await this.$get("rReceivedRequests") as Request[];
}
public async posts({first, offset}: { first: number, offset: number }): Promise<Post[]> {
const limit = first || 10;
offset = offset || 0;
return await this.$get("rPosts", {limit, offset}) as Post[];
}
/**
* @deprecated
* use {@link postCount} instead
*/
public async numberOfPosts(): Promise<number> {
return this.postCount();
}
/**
* number of posts the user created
*/
public async postCount(): Promise<number> {
return this.$count("rPosts");
}
/**
* Groups the user is the admin of
*/
public async administratedGroups(): Promise<Group[]> {
return await this.$get("rAdministratedGroups") as Group[];
}
/**
* Groups the user has created
*/
public async createdGroups(): Promise<Group[]> {
return await this.$get("rCreatedGroups") as Group[];
}
/**
* Groups the user is a member of
* @param first
* @param offset
*/
public async groups({first, offset}: { first: number, offset: number }): Promise<Group[]> {
const limit = first || 10;
offset = offset || 0;
return await this.$get("rGroups", {limit, offset}) as Group[];
}
/**
* The number of groups the user has joined
*/
public async groupCount(): Promise<number> {
return this.$count("rGroups");
}
/**
* Events the user has joined
*/
public async events(): Promise<Event[]> {
return await this.$get("rEvents") as Event[];
}
/**
* The number of events the user is participating in.
*/
public async eventCount(): Promise<number> {
return this.$count("rEvents");
}
/**
* Denys a request the user has received
* @param sender
* @param type
*/
public async denyRequest(sender: number, type: RequestType) {
const request = await this.$get("rReceivedRequests",
{where: {senderId: sender, requestType: type}}) as Request[];
if (request[0]) {
await request[0].destroy();
}
}
/**
* Accepts a request the user has received
* @param sender
* @param type
*/
public async acceptRequest(sender: number, type: RequestType) {
const requests = await this.$get("rReceivedRequests",
{where: {senderId: sender, requestType: type}}) as Request[];
if (requests.length > 0) {
const request = requests[0];
if (request.requestType === RequestType.FRIENDREQUEST) {
await Friendship.bulkCreate([
{userId: this.id, friendId: sender},
{userId: sender, friendId: this.id},
], {ignoreDuplicates: true});
await request.destroy();
}
} else {
throw new RequestNotFoundError(sender, this.id, type);
}
}
/**
* Removes a user from the users friends
* @param friendId
*/
public async removeFriend(friendId: number) {
const friend = await User.findByPk(friendId);
if (friend) {
await this.$remove("rFriends", friend);
await this.$remove("rFriendOf", friend);
return true;
} else {
throw new UserNotFoundError(friendId);
}
}
}

@ -0,0 +1,13 @@
export {ChatMember} from "./ChatMember";
export {ChatMessage} from "./ChatMessage";
export {ChatRoom} from "./ChatRoom";
export {Friendship} from "./Friendship";
export {Post} from "./Post";
export {PostVote} from "./PostVote";
export {Request} from "./Request";
export {User} from "./User";
export {Group} from "./Group";
export {GroupAdmin} from "./GroupAdmin";
export {GroupMember} from "./GroupMember";
export {Event} from "./Event";
export {EventParticipant} from "./EventParticipant";

@ -1,5 +0,0 @@
@mixin gridPosition($rowStart, $rowEnd, $columnStart, $columnEnd)
grid-row-start: $rowStart
grid-row-end: $rowEnd
grid-column-start: $columnStart
grid-column-end: $columnEnd

@ -1,108 +1,10 @@
@import "vars"
@import "mixins"
body body
font-family: Arial, serif font-family: Arial, serif
button #server-error
border: 2px solid $cPrimary *
margin-top: 0.125em margin-left: auto
padding: 0.125em margin-right: auto
background-color: $cPrimary
color: $cPrimarySurface
font-weight: bold
transition-duration: 0.25s
button:hover
background-color: lighten($cPrimary, 10%)
cursor: pointer
button:active
background-color: darken($cPrimary, 5%)
box-shadow: inset 0.25em 0.25em 0.1em rgba(0, 0, 0, 0.25)
.stylebar
@include gridPosition(1, 2, 1, 4)
display: grid
grid-template: 100% /25% 50% 25%
background-color: $cPrimary
color: $cPrimarySurface
h1
@include gridPosition(1, 2, 1, 2)
text-align: center text-align: center
margin: auto code
font-size: 2em
#content
grid-template: 7.5% 92.5% / 25% 50% 25%
display: grid
width: 100%
height: 100%
#friendscontainer
@include gridPosition(2, 3, 1, 2)
background-color: $cPrimaryBackground
#input-login
margin-top: 1em
@include gridPosition(2,3,2,3)
grid-template: 7.5% 7.5% 7.5% 7.5% 72%/ 100%
display: grid
background-color: $cPrimaryBackground
input
margin: 0.25em
.loginButton
margin: 0.25em
#input-register
margin-top: 1em
@include gridPosition(2,3,2,3)
grid-template: 7.5% 7.5% 7.5% 7.5% 7.5% 7.5% 58%/ 100%
display: grid
background-color: $cPrimaryBackground
input
margin: 0.25em
.registerButton
margin: 0.25em
#feedcontainer
@include gridPosition(2, 3, 2, 3)
background-color: $cSecondaryBackground
.postinput
margin: 0.5em
input
width: 100%
border-radius: 0.25em
border: 1px solid $cPrimary
padding: 0.125em
height: 2em
button.submitbutton
border-radius: 0.25em
height: 2em
.feeditem
background-color: $cPrimaryBackground
min-height: 2em
margin: 0.5em
padding: 0.25em
border-radius: 0.25em
.itemhead
align-items: flex-start
.title, .handle, .date
margin: 0.125em
.title
font-weight: bold
.handle, .date
color: $cInactiveText
.handle a
text-decoration: none
color: $cInactiveText
font-style: normal
.handle a:hover
text-decoration: underline

@ -1,5 +0,0 @@
$cPrimaryBackground: #fff
$cSecondaryBackground: #ddd
$cInactiveText: #555
$cPrimary: #0d6b14
$cPrimarySurface: #fff

@ -1,12 +1,9 @@
import {Router} from "express"; import {Router} from "express";
import {Namespace, Server} from "socket.io"; import {Namespace, Server} from "socket.io";
import dataaccess from "../lib/dataaccess"; import dataaccess from "../lib/dataaccess";
import {ChatMessage} from "../lib/dataaccess/ChatMessage";
import {Chatroom} from "../lib/dataaccess/Chatroom";
import {Post} from "../lib/dataaccess/Post";
import {Request} from "../lib/dataaccess/Request";
import globals from "../lib/globals"; import globals from "../lib/globals";
import {InternalEvents} from "../lib/InternalEvents"; import {InternalEvents} from "../lib/InternalEvents";
import {ChatMessage, ChatRoom, Post, Request, User} from "../lib/models";
import Route from "../lib/Route"; import Route from "../lib/Route";
/** /**
@ -37,18 +34,18 @@ class HomeRoute extends Route {
socket.on("postCreate", async (content) => { socket.on("postCreate", async (content) => {
if (socket.handshake.session.userId) { if (socket.handshake.session.userId) {
const post = await dataaccess.createPost(content, socket.handshake.session.userId); const post = await dataaccess.createPost(content, socket.handshake.session.userId);
io.emit("post", await post.resolvedData()); io.emit("post", Object.assign(post, {htmlContent: post.htmlContent}));
} else { } else {
socket.emit("error", "Not logged in!"); socket.emit("error", "Not logged in!");
} }
}); });
globals.internalEmitter.on(InternalEvents.REQUESTCREATE, (request: Request) => { globals.internalEmitter.on(InternalEvents.REQUESTCREATE, async (request: Request) => {
if (request.receiver.id === socket.handshake.session.userId) { if ((await request.$get("sender") as User).id === socket.handshake.session.userId) {
socket.emit("request", request.resolvedData()); socket.emit("request", request);
} }
}); });
globals.internalEmitter.on(InternalEvents.GQLPOSTCREATE, async (post: Post) => { globals.internalEmitter.on(InternalEvents.GQLPOSTCREATE, async (post: Post) => {
socket.emit("post", await post.resolvedData()); socket.emit("post", Object.assign(post, {htmlContent: post.htmlContent}));
}); });
}); });
@ -56,7 +53,7 @@ class HomeRoute extends Route {
for (const chat of chats) { for (const chat of chats) {
chatRooms[chat.id] = this.getChatSocketNamespace(chat.id); chatRooms[chat.id] = this.getChatSocketNamespace(chat.id);
} }
globals.internalEmitter.on(InternalEvents.CHATCREATE, (chat: Chatroom) => { globals.internalEmitter.on(InternalEvents.CHATCREATE, (chat: ChatRoom) => {
chatRooms[chat.id] = this.getChatSocketNamespace(chat.id); chatRooms[chat.id] = this.getChatSocketNamespace(chat.id);
}); });
} }
@ -82,15 +79,15 @@ class HomeRoute extends Route {
if (socket.handshake.session.userId) { if (socket.handshake.session.userId) {
const userId = socket.handshake.session.userId; const userId = socket.handshake.session.userId;
const message = await dataaccess.sendChatMessage(userId, chatId, content); const message = await dataaccess.sendChatMessage(userId, chatId, content);
socket.broadcast.emit("chatMessage", message.resolvedContent()); socket.broadcast.emit("chatMessage", Object.assign(message, {htmlContent: message.htmlContent}));
socket.emit("chatMessageSent", message.resolvedContent()); socket.emit("chatMessageSent", Object.assign(message, {htmlContent: message.htmlContent}));
} else { } else {
socket.emit("error", "Not logged in!"); socket.emit("error", "Not logged in!");
} }
}); });
globals.internalEmitter.on(InternalEvents.GQLCHATMESSAGE, (message: ChatMessage) => { globals.internalEmitter.on(InternalEvents.GQLCHATMESSAGE, async (message: ChatMessage) => {
if (message.chat.id === chatId) { if ((await message.$get("chat") as ChatRoom).id === chatId) {
socket.emit("chatMessage", message.resolvedContent()); socket.emit("chatMessage", Object.assign(message, {htmlContent: message.htmlContent}));
} }
}); });
}); });

@ -1,137 +0,0 @@
--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;
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 functions relying on types
DO $$ BEGIN
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 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 $$;

@ -1,19 +0,0 @@
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 $$;

@ -0,0 +1,12 @@
html
head
link(href="/stylesheets/style.css" rel="stylesheet" type="text/css")
body
div#server-error
div
h1 Page not found!
div
code 404
div
h1 The page "#{url}" was not found.

@ -0,0 +1,13 @@
html
head
link(href="/stylesheets/style.css" rel="stylesheet" type="text/css")
body
div#server-error
div
h1 Internal server error!
div
code 500
div
h2 Oops the server couldn't handle that.
div
p You might want to report this.

@ -4,12 +4,15 @@
"noImplicitAny": true, "noImplicitAny": true,
"removeComments": true, "removeComments": true,
"preserveConstEnums": true, "preserveConstEnums": true,
"allowSyntheticDefaultImports": true,
"outDir": "./dist", "outDir": "./dist",
"sourceMap": true, "sourceMap": true,
"target": "es2018", "target": "es2018",
"allowJs": true, "allowJs": true,
"moduleResolution": "node", "moduleResolution": "node",
"module": "commonjs" "module": "commonjs",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}, },
"include": [ "include": [
"src/**/*" "src/**/*"

@ -21,7 +21,8 @@
}, },
"no-namespace": false, "no-namespace": false,
"no-internal-module": false, "no-internal-module": false,
"max-classes-per-file": false "max-classes-per-file": false,
"no-var-requires": false
}, },
"jsRules": { "jsRules": {
"max-line-length": { "max-line-length": {

Loading…
Cancel
Save