Merge branch 'master' into develop

pull/4/head
Trivernis 5 years ago
commit 162aed30dc

@ -4,6 +4,47 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- settings field to own user data to store frontend settings
- Jenkinsfile
- Mocha Tests
- worker initialization error handling
- bearer token authentication for testing purposes
- Added `deletable' field on post
- Admin field that for admin users
- ability for admins to delete posts
- ability to upload file at `/upload` with the name profilePicture
- publicPath to config file to configure the directory for public files
- profilePicture property to User model which is an url to the users profile picture
- activities to posts
- getActivities field to receive all activities
- createActivity mutation
- activities table
### Removed
- special worker logging
### Changed
- changed the running behaviour to run in cluster threads via node.js cluster api
- gql field userVote requires a userId
- default findUser param limit to 20
- only group admins can create group events
### Fixed
- sequelize initialization being logged without winston
- userVote is always null (#47)
- findUser not being implemented
- style issues
- graphql schema for denyRequest using the wrong parameters
- sendRequest allowing duplicates
## [0.9] - 2019-10-29 ## [0.9] - 2019-10-29
### Added ### Added

@ -27,6 +27,7 @@
"@types/cookie-parser": "^1.4.2", "@types/cookie-parser": "^1.4.2",
"@types/cors": "^2.8.6", "@types/cors": "^2.8.6",
"@types/express": "^4.17.1", "@types/express": "^4.17.1",
"@types/express-fileupload": "^1.1.0",
"@types/express-graphql": "^0.8.0", "@types/express-graphql": "^0.8.0",
"@types/express-session": "^1.15.14", "@types/express-session": "^1.15.14",
"@types/express-socket.io-session": "^1.3.2", "@types/express-socket.io-session": "^1.3.2",
@ -38,8 +39,10 @@
"@types/node": "^12.7.12", "@types/node": "^12.7.12",
"@types/pg": "^7.11.0", "@types/pg": "^7.11.0",
"@types/sequelize": "^4.28.5", "@types/sequelize": "^4.28.5",
"@types/sharp": "^0.23.1",
"@types/socket.io": "^2.1.2", "@types/socket.io": "^2.1.2",
"@types/socket.io-redis": "^1.0.25", "@types/socket.io-redis": "^1.0.25",
"@types/uuid": "^3.4.6",
"@types/validator": "^10.11.3", "@types/validator": "^10.11.3",
"chai": "^4.2.0", "chai": "^4.2.0",
"delete": "^1.1.0", "delete": "^1.1.0",
@ -54,12 +57,12 @@
"typescript": "^3.7.2" "typescript": "^3.7.2"
}, },
"dependencies": { "dependencies": {
"@types/uuid": "^3.4.6",
"compression": "^1.7.4", "compression": "^1.7.4",
"connect-session-sequelize": "^6.0.0", "connect-session-sequelize": "^6.0.0",
"cookie-parser": "^1.4.4", "cookie-parser": "^1.4.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.17.1", "express": "^4.17.1",
"express-fileupload": "^1.1.6",
"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",
@ -75,6 +78,7 @@
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"sequelize": "^5.19.6", "sequelize": "^5.19.6",
"sequelize-typescript": "^1.0.0", "sequelize-typescript": "^1.0.0",
"sharp": "^0.23.4",
"socket.io": "^2.2.0", "socket.io": "^2.2.0",
"socket.io-redis": "^5.2.0", "socket.io-redis": "^5.2.0",
"sqlite3": "^4.1.0", "sqlite3": "^4.1.0",

@ -3,6 +3,8 @@ import * as cookieParser from "cookie-parser";
import * as cors from "cors"; import * as cors from "cors";
import {Request, Response} from "express"; import {Request, Response} from "express";
import * as express from "express"; import * as express from "express";
import {UploadedFile} from "express-fileupload";
import * as fileUpload from "express-fileupload";
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");
@ -13,11 +15,13 @@ import * as http from "http";
import * as httpStatus from "http-status"; import * as httpStatus from "http-status";
import * as path from "path"; import * as path from "path";
import {Sequelize} from "sequelize-typescript"; import {Sequelize} from "sequelize-typescript";
import * as sharp from "sharp";
import * as socketIo from "socket.io"; import * as socketIo from "socket.io";
import * as socketIoRedis from "socket.io-redis"; import * as socketIoRedis from "socket.io-redis";
import {resolver} from "./graphql/resolvers"; import {resolver} from "./graphql/resolvers";
import dataaccess from "./lib/dataAccess"; import dataaccess from "./lib/dataAccess";
import globals from "./lib/globals"; import globals from "./lib/globals";
import {User} from "./lib/models";
import routes from "./routes"; import routes from "./routes";
const SequelizeStore = require("connect-session-sequelize")(session.Store); const SequelizeStore = require("connect-session-sequelize")(session.Store);
@ -27,6 +31,7 @@ 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 publicPath: string;
public readonly id?: number; public readonly id?: number;
public readonly sequelize: Sequelize; public readonly sequelize: Sequelize;
@ -35,7 +40,11 @@ class App {
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 ); this.sequelize = new Sequelize(globals.config.database.connectionUri);
this.publicPath = globals.config.frontend.publicPath;
if (!path.isAbsolute(this.publicPath)) {
this.publicPath = path.normalize(path.join(__dirname, this.publicPath));
}
} }
/** /**
@ -62,7 +71,12 @@ class App {
this.sequelize.options.logging = (msg) => logger.silly(msg); this.sequelize.options.logging = (msg) => logger.silly(msg);
logger.info("Setting up socket.io"); logger.info("Setting up socket.io");
await routes.ioListeners(this.io); await routes.ioListeners(this.io);
this.io.adapter(socketIoRedis()); try {
this.io.adapter(socketIoRedis());
} catch (err) {
logger.error(err.message);
logger.debug(err.stack);
}
this.io.use(sharedsession(appSession, {autoSave: true})); this.io.use(sharedsession(appSession, {autoSave: true}));
logger.info("Configuring express app."); logger.info("Configuring express app.");
@ -73,32 +87,36 @@ class App {
this.app.use(compression()); this.app.use(compression());
this.app.use(express.json()); this.app.use(express.json());
this.app.use(express.urlencoded({extended: false})); this.app.use(express.urlencoded({extended: false}));
this.app.use(express.static(path.join(__dirname, "public"))); this.app.use(express.static(this.publicPath));
this.app.use(cookieParser()); this.app.use(cookieParser());
this.app.use(appSession); this.app.use(appSession);
// enable cross origin requests if enabled in the config // enable cross origin requests if enabled in the config
if (globals.config.server?.cors) { if (globals.config.server?.cors) {
this.app.use(cors()); this.app.use(cors());
} }
// handle authentification via bearer in the Authorization header // handle authentication via bearer in the Authorization header
this.app.use(async (req, res, next) => { this.app.use(async (req, res, next) => {
if (!req.session.userId && req.headers.authorization) { try {
const bearer = req.headers.authorization.split("Bearer ")[1]; if (!req.session.userId && req.headers.authorization) {
if (bearer) { const bearer = req.headers.authorization.split("Bearer ")[1];
const user = await dataaccess.getUserByToken(bearer); if (bearer) {
// @ts-ignore const user = await dataaccess.getUserByToken(bearer);
req.session.userId = user.id; // @ts-ignore
req.session.userId = user.id;
}
} }
} catch (err) {
logger.error(err.message);
logger.debug(err.stack);
} }
next(); next();
}); });
this.app.use((req, res, next) => { this.app.use((req, res, next) => {
logger.verbose(`${req.method} ${req.url}`); logger.verbose(`${req.method} ${req.url}`);
process.send({cmd: "notifyRequest"});
next(); next();
}); });
this.app.use(routes.router); this.app.use(routes.router);
// listen for graphql requrest // listen for graphql requests
this.app.use("/graphql", graphqlHTTP((request, response) => { this.app.use("/graphql", graphqlHTTP((request, response) => {
return { return {
// @ts-ignore all // @ts-ignore all
@ -108,6 +126,32 @@ class App {
schema: buildSchema(importSchema(path.join(__dirname, "./graphql/schema.graphql"))), schema: buildSchema(importSchema(path.join(__dirname, "./graphql/schema.graphql"))),
}; };
})); }));
this.app.use("/upload", fileUpload());
this.app.use("/upload", async (req, res) => {
const profilePic = req.files.profilePicture as UploadedFile;
let success = false;
let fileName;
if (profilePic && req.session.userId) {
const dir = path.join(this.publicPath, "data/profilePictures");
await fsx.ensureDir(dir);
await sharp(profilePic.data)
.resize(512, 512)
.normalise()
.png()
.toFile(path.join(dir, req.session.userId + ".png"));
success = true;
fileName = `/data/profilePictures/${req.session.userId}.png`;
const user = await User.findByPk(req.session.userId);
user.profilePicture = fileName;
await user.save();
} else {
res.status(400);
}
res.json({
fileName,
success,
});
});
// allow access to cluster information // allow access to cluster information
this.app.use("/cluster-info", (req: Request, res: Response) => { this.app.use("/cluster-info", (req: Request, res: Response) => {
res.json({ res.json({
@ -117,7 +161,7 @@ class App {
// redirect all request to the angular file // redirect all request to the angular file
this.app.use((req: any, res: Response) => { this.app.use((req: any, res: Response) => {
if (globals.config.frontend.angularIndex) { if (globals.config.frontend.angularIndex) {
const angularIndex = path.join(__dirname, globals.config.frontend.angularIndex); const angularIndex = path.join(this.publicPath, globals.config.frontend.angularIndex);
if (fsx.existsSync(path.join(angularIndex))) { if (fsx.existsSync(path.join(angularIndex))) {
res.sendFile(angularIndex); res.sendFile(angularIndex);
} else { } else {

@ -22,4 +22,5 @@ logging:
level: info level: info
frontend: frontend:
angularIndex: angularIndex: index.html
publicPath: ./public

@ -1,13 +1,19 @@
import {GraphQLError} from "graphql"; import {GraphQLError} from "graphql";
import * as status from "http-status"; import * as status from "http-status";
import * as yaml from "js-yaml"; import * as yaml from "js-yaml";
import {Op} from "sequelize";
import dataaccess from "../lib/dataAccess"; import dataaccess from "../lib/dataAccess";
import {NotLoggedInGqlError, PostNotFoundGqlError} from "../lib/errors/graphqlErrors"; import {NotLoggedInGqlError, PostNotFoundGqlError} from "../lib/errors/graphqlErrors";
import {InvalidLoginError} from "../lib/errors/InvalidLoginError";
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 * as models from "../lib/models";
import {is} from "../lib/regex"; import {is} from "../lib/regex";
class Resolver {
}
/** /**
* Returns the resolvers for the graphql api. * Returns the resolvers for the graphql api.
* @param req - the request object * @param req - the request object
@ -15,6 +21,47 @@ import {is} from "../lib/regex";
*/ */
export function resolver(req: any, res: any): any { export function resolver(req: any, res: any): any {
return { return {
async search({first, offset, query}: { first: number, offset: number, query: string }) {
const limit = first;
const users = await models.User.findAll({
limit,
offset,
where: {
[Op.or]: [
{handle: {[Op.iRegexp]: query}},
{username: {[Op.iRegexp]: query}},
],
},
});
const groups = await models.Group.findAll({
limit,
offset,
where: {name: {[Op.iRegexp]: query}},
});
const posts = await models.Post.findAll({
limit,
offset,
where: {content: {[Op.iRegexp]: query}},
});
const events = await models.Event.findAll({
limit,
offset,
where: {name: {[Op.iRegexp]: query}},
});
return {users, posts, groups, events};
},
async findUser({first, offset, name, handle}:
{ first: number, offset: number, name: string, handle: string }) {
res.status(status.MOVED_PERMANENTLY);
if (name) {
return models.User.findAll({where: {username: {[Op.like]: `%${name}%`}}, offset, limit: first});
} else if (handle) {
return models.User.findAll({where: {handle: {[Op.like]: `%${handle}%`}}, offset, limit: first});
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No search parameters provided.");
}
},
async getSelf() { async getSelf() {
if (req.session.userId) { if (req.session.userId) {
return models.User.findByPk(req.session.userId); return models.User.findByPk(req.session.userId);
@ -79,7 +126,7 @@ export function resolver(req: any, res: any): any {
globals.logger.warn(err.message); globals.logger.warn(err.message);
globals.logger.debug(err.stack); globals.logger.debug(err.stack);
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
return err.graphqlError || err.message; return err.graphqlError ?? new GraphQLError(err.message);
} }
} else { } else {
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
@ -101,20 +148,25 @@ export function resolver(req: any, res: any): any {
return new NotLoggedInGqlError(); return new NotLoggedInGqlError();
} }
}, },
async getToken({email, passwordHash}: {email: string, passwordHash: string}) { async getToken({email, passwordHash}: { email: string, passwordHash: string }) {
if (email && passwordHash) { if (email && passwordHash) {
try { try {
const user = await dataaccess.getUserByLogin(email, passwordHash); const user = await dataaccess.getUserByLogin(email, passwordHash);
return { if (!user) {
expires: Number(user.authExpire), res.status(status.BAD_REQUEST);
value: user.token(), return new InvalidLoginError(email);
}; } else {
return {
expires: Number(user.authExpire),
value: user.token(),
};
}
} catch (err) { } catch (err) {
res.status(400); res.status(status.BAD_REQUEST);
return err.graphqlError; return err.graphqlError ?? new GraphQLError(err.message);
} }
} else { } else {
res.status(400); res.status(status.BAD_REQUEST);
return new GraphQLError("No email or password specified."); return new GraphQLError("No email or password specified.");
} }
}, },
@ -132,14 +184,14 @@ export function resolver(req: any, res: any): any {
globals.logger.warn(err.message); globals.logger.warn(err.message);
globals.logger.debug(err.stack); globals.logger.debug(err.stack);
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
return err.graphqlError || err.message; return err.graphqlError ?? new GraphQLError(err.message);
} }
} else { } else {
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
return new GraphQLError("No username, email or password given."); return new GraphQLError("No username, email or password given.");
} }
}, },
async setUserSettings({settings}: {settings: string}) { async setUserSettings({settings}: { settings: string }) {
if (req.session.userId) { if (req.session.userId) {
const user = await models.User.findByPk(req.session.userId); const user = await models.User.findByPk(req.session.userId);
try { try {
@ -147,7 +199,7 @@ export function resolver(req: any, res: any): any {
await user.save(); await user.save();
return user.settings; return user.settings;
} catch (err) { } catch (err) {
res.status(400); res.status(status.BAD_REQUEST);
return new GraphQLError("Invalid settings json."); return new GraphQLError("Invalid settings json.");
} }
} else { } else {
@ -174,15 +226,20 @@ export function resolver(req: any, res: any): any {
return new GraphQLError("No postId or type given."); return new GraphQLError("No postId or type given.");
} }
}, },
async createPost({content}: { content: string }) { async createPost({content, activityId}: { content: string, activityId: number }) {
if (content) { if (content) {
if (req.session.userId) { if (req.session.userId) {
if (content.length > 2048) { if (content.length > 2048) {
return new GraphQLError("Content too long."); return new GraphQLError("Content too long.");
} else { } else {
const post = await dataaccess.createPost(content, req.session.userId); try {
globals.internalEmitter.emit(InternalEvents.GQLPOSTCREATE, post); const post = await dataaccess.createPost(content, req.session.userId, activityId);
return post; globals.internalEmitter.emit(InternalEvents.GQLPOSTCREATE, post);
return post;
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
} }
} else { } else {
res.status(status.UNAUTHORIZED); res.status(status.UNAUTHORIZED);
@ -195,8 +252,12 @@ export function resolver(req: any, res: any): any {
}, },
async deletePost({postId}: { postId: number }) { async deletePost({postId}: { postId: number }) {
if (postId) { if (postId) {
const post = await models.Post.findByPk(postId, {include: [models.User]}); const post = await models.Post.findByPk(postId, {include: [{
if (post.rAuthor.id === req.session.userId) { as: "rAuthor",
model: models.User,
}]});
const isAdmin = (await models.User.findOne({where: {id: req.session.userId}})).isAdmin;
if (post.rAuthor.id === req.session.userId || isAdmin) {
return await dataaccess.deletePost(post.id); return await dataaccess.deletePost(post.id);
} else { } else {
res.status(status.FORBIDDEN); res.status(status.FORBIDDEN);
@ -232,7 +293,7 @@ export function resolver(req: any, res: any): any {
globals.logger.warn(err.message); globals.logger.warn(err.message);
globals.logger.debug(err.stack); globals.logger.debug(err.stack);
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
return err.graphqlError || err.message; return err.graphqlError ?? new GraphQLError(err.message);
} }
} else { } else {
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
@ -245,7 +306,12 @@ export function resolver(req: any, res: any): any {
return new NotLoggedInGqlError(); return new NotLoggedInGqlError();
} }
if (receiver && type) { if (receiver && type) {
return await dataaccess.createRequest(req.session.userId, receiver, type); try {
return await dataaccess.createRequest(req.session.userId, receiver, type);
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
} else { } else {
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
return new GraphQLError("No receiver or type given."); return new GraphQLError("No receiver or type given.");
@ -279,7 +345,7 @@ export function resolver(req: any, res: any): any {
globals.logger.warn(err.message); globals.logger.warn(err.message);
globals.logger.debug(err.stack); globals.logger.debug(err.stack);
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
return err.graphqlError || err.message; return err.graphqlError ?? new GraphQLError(err.message);
} }
} else { } else {
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
@ -300,7 +366,12 @@ export function resolver(req: any, res: any): any {
}, },
async createGroup({name, members}: { name: string, members: number[] }) { async createGroup({name, members}: { name: string, members: number[] }) {
if (req.session.userId) { if (req.session.userId) {
return await dataaccess.createGroup(name, req.session.userId, members); try {
return await dataaccess.createGroup(name, req.session.userId, members);
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
} else { } else {
return new NotLoggedInGqlError(); return new NotLoggedInGqlError();
} }
@ -312,7 +383,7 @@ export function resolver(req: any, res: any): any {
.changeGroupMembership(id, req.session.userId, dataaccess.MembershipChangeAction.ADD); .changeGroupMembership(id, req.session.userId, dataaccess.MembershipChangeAction.ADD);
} catch (err) { } catch (err) {
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
return err.graphqlError; return err.graphqlError ?? new GraphQLError(err.message);
} }
} else { } else {
res.status(status.UNAUTHORIZED); res.status(status.UNAUTHORIZED);
@ -326,7 +397,7 @@ export function resolver(req: any, res: any): any {
.changeGroupMembership(id, req.session.userId, dataaccess.MembershipChangeAction.REMOVE); .changeGroupMembership(id, req.session.userId, dataaccess.MembershipChangeAction.REMOVE);
} catch (err) { } catch (err) {
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
return err.graphqlError; return err.graphqlError ?? new GraphQLError(err.message);
} }
} else { } else {
res.status(status.UNAUTHORIZED); res.status(status.UNAUTHORIZED);
@ -346,7 +417,7 @@ export function resolver(req: any, res: any): any {
.changeGroupMembership(groupId, userId, dataaccess.MembershipChangeAction.OP); .changeGroupMembership(groupId, userId, dataaccess.MembershipChangeAction.OP);
} catch (err) { } catch (err) {
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
return err.graphqlError; return err.graphqlError ?? new GraphQLError(err.message);
} }
} else { } else {
@ -371,7 +442,7 @@ export function resolver(req: any, res: any): any {
.changeGroupMembership(groupId, userId, dataaccess.MembershipChangeAction.DEOP); .changeGroupMembership(groupId, userId, dataaccess.MembershipChangeAction.DEOP);
} catch (err) { } catch (err) {
res.status(status.BAD_REQUEST); res.status(status.BAD_REQUEST);
return err.graphqlError; return err.graphqlError ?? new GraphQLError(err.message);
} }
} else { } else {
res.status(status.UNAUTHORIZED); res.status(status.UNAUTHORIZED);
@ -380,9 +451,14 @@ export function resolver(req: any, res: any): any {
}, },
async createEvent({name, dueDate, groupId}: { name: string, dueDate: string, groupId: number }) { async createEvent({name, dueDate, groupId}: { name: string, dueDate: string, groupId: number }) {
if (req.session.userId) { if (req.session.userId) {
const date = new Date(dueDate); const date = new Date(Number(dueDate));
const group = await models.Group.findByPk(groupId); const group = await models.Group.findByPk(groupId, {include: [{association: "rAdmins"}]});
return group.$create<models.Event>("rEvent", {name, dueDate: date}); if (group.rAdmins.find((x) => x.id === req.session.userId)) {
return group.$create<models.Event>("rEvent", {name, dueDate: date});
} else {
res.status(status.FORBIDDEN);
return new GraphQLError("You are not a group admin!");
}
} else { } else {
res.status(status.UNAUTHORIZED); res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError(); return new NotLoggedInGqlError();
@ -410,5 +486,28 @@ export function resolver(req: any, res: any): any {
return new NotLoggedInGqlError(); return new NotLoggedInGqlError();
} }
}, },
async getActivities() {
return models.Activity.findAll();
},
async createActivity({name, description, points}:
{name: string, description: string, points: number}) {
if (req.session.userId) {
const user = await models.User.findByPk(req.session.userId);
if (user.isAdmin) {
const nameExists = await models.Activity.findOne({where: {name}});
if (!nameExists) {
return models.Activity.create({name, description, points});
} else {
return new GraphQLError(`An activity with the name '${name}'`);
}
} else {
res.status(status.FORBIDDEN);
return new GraphQLError("You are not an admin.");
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
}; };
} }

@ -17,15 +17,18 @@ type Query {
"returns the request object for its id" "returns the request object for its id"
getRequest(requestId: ID!): Request getRequest(requestId: ID!): Request
"find a post by the posted date or content" "DEPRECATED! Find a user by user name or handle"
findPost(first: Int, offset: Int, text: String!, postedDate: String): [Post] findUser(first: Int = 20, offset: Int = 0, name: String, handle: String): [User]
"find a user by user name or handle" "searches for users, groups, events, posts and returns a search result"
findUser(first: Int, offset: Int, name: String, handle: String): [User] search(query: String!, first: Int = 20, offset: Int = 0): SearchResult!
"returns the post filtered by the sort type with pagination." "returns the post filtered by the sort type with pagination."
getPosts(first: Int=20, offset: Int=0, sort: SortType = NEW): [Post] getPosts(first: Int=20, offset: Int=0, sort: SortType = NEW): [Post]
"returns all activities"
getActivities: [Activity]
"Returns an access token for the user that can be used in requests. To user the token in requests, it has to be set in the HTTP header 'Authorization' with the format Bearer <token>." "Returns an access token for the user that can be used in requests. To user the token in requests, it has to be set in the HTTP header 'Authorization' with the format Bearer <token>."
getToken(email: String!, passwordHash: String!): Token! getToken(email: String!, passwordHash: String!): Token!
} }
@ -59,7 +62,7 @@ type Mutation {
acceptRequest(sender: ID!, type: RequestType): Boolean acceptRequest(sender: ID!, type: RequestType): Boolean
"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(sender: ID!, type: RequestType): Boolean
"removes a friend" "removes a friend"
removeFriend(friendId: ID!): Boolean removeFriend(friendId: ID!): Boolean
@ -67,8 +70,8 @@ type Mutation {
"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 a post that can belong to an activity"
createPost(content: String!): Post! createPost(content: String!, activityId: ID): 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!
@ -99,6 +102,9 @@ type Mutation {
"Leaves a event." "Leaves a event."
leaveEvent(eventId: ID!): Event leaveEvent(eventId: ID!): Event
"Creates an activity. Can only be used by admins."
createActivity(name: String!, description: String!, points: Int!): Activity
} }
interface UserData { interface UserData {
@ -114,9 +120,6 @@ interface UserData {
"Id of the User" "Id of the User"
id: ID! id: ID!
"DEPRECATED! the total number of posts the user posted"
numberOfPosts: Int!
"the number of posts the user has created" "the number of posts the user has created"
postCount: Int! postCount: Int!
@ -256,6 +259,9 @@ type Profile implements UserData {
"the custom settings for the frontend" "the custom settings for the frontend"
settings: String! settings: String!
"if the user is an admin"
isAdmin: Boolean
} }
"represents a single user post" "represents a single user post"
@ -284,6 +290,12 @@ type Post {
"the type of vote the user performed on the post" "the type of vote the user performed on the post"
userVote(userId: ID!): VoteType userVote(userId: ID!): VoteType
"if the post can be deleted by the specified user"
deletable(userId: ID!): Boolean
"the activity that belongs to the post"
activity: Activity
} }
"represents a request of any type" "represents a request of any type"
@ -379,10 +391,44 @@ type Event {
"respresents an access token entry with the value as the acutal token and expires as the date the token expires." "respresents an access token entry with the value as the acutal token and expires as the date the token expires."
type Token { type Token {
"The token itself."
value: String! value: String!
"The timestamp when the token expires."
expires: String! expires: String!
} }
"The result of a search."
type SearchResult {
"The users that were found in the search."
users: [User!]!
"The posts that were found in the search."
posts: [Post!]!
"The groups that were found in the search."
groups: [Group!]!
"The events that were found in the search."
events: [Event!]!
}
"An activity that grants points"
type Activity {
"the id of the activity"
id: ID!
"the name of the activity"
name: String!
"the description of the activity"
description: String!
"the number of points the activity grants"
points: Int!
}
"represents the type of vote performed on a post" "represents the type of vote performed on a post"
enum VoteType { enum VoteType {
UPVOTE UPVOTE

@ -16,61 +16,19 @@ interface IClusterData {
if (cluster.isMaster) { if (cluster.isMaster) {
console.log(`[CLUSTER-M] Master ${process.pid} is running`); console.log(`[CLUSTER-M] Master ${process.pid} is running`);
const clusterData: IClusterData = {
reqCount: 0,
workerCount: () => Object.keys(cluster.workers).length,
// @ts-ignore
workerRes: {},
};
setInterval(() => {
clusterData.workerRes.M = {
cpu: process.cpuUsage(),
mem: process.memoryUsage(),
};
}, 1000);
const log = (msg: string) => {
process.stdout.write(" ".padEnd(100) + "\r");
process.stdout.write(msg);
process.stdout.write(
`W: ${clusterData.workerCount()},R: ${clusterData.reqCount},M: ${(() => {
let usageString = "";
for (const [key, value] of Object.entries(clusterData.workerRes)) {
usageString += `${
Math.round((value as IResourceUsage).mem.heapUsed / 100000) / 10}MB,`.padEnd(8);
}
return usageString;
})()}`.padEnd(99) + "\r");
};
cluster.settings.silent = true; cluster.settings.silent = true;
cluster.on("exit", (worker, code, signal) => { cluster.on("exit", (worker, code) => {
log(`[CLUSTER-M] Worker ${worker.process.pid} died!\n`); console.error(`[CLUSTER-M] Worker ${worker.id} died! (code: ${code})`);
delete clusterData.workerRes[worker.id]; console.log("[CLUSTER-M] Starting new worker");
log("[CLUSTER-M] Starting new worker\n");
cluster.fork(); cluster.fork();
}); });
cluster.on("online", (worker) => { cluster.on("online", (worker) => {
worker.process.stdout.on("data", (data) => { worker.process.stdout.on("data", (data) => {
log(`[CLUSTER-${worker.id}] ${data}`); process.stdout.write(`[CLUSTER-${worker.id}] ${data}`);
}); });
}); });
cluster.on("message", (worker, message) => {
switch (message.cmd) {
case "notifyRequest":
clusterData.reqCount++;
log("");
break;
case "notifyResources":
// @ts-ignore
clusterData.workerRes[worker.id] = message.data;
log("");
break;
default:
break;
}
});
for (let i = 0; i < numCPUs; i++) { for (let i = 0; i < numCPUs; i++) {
cluster.fork(); cluster.fork();
@ -81,15 +39,15 @@ if (cluster.isMaster) {
* async main function wrapper. * async main function wrapper.
*/ */
(async () => { (async () => {
setInterval(() => { try {
process.send({cmd: "notifyResources", data: { const app = new App(cluster.worker.id);
cpu: process.cpuUsage(), await app.init();
mem: process.memoryUsage(), app.start();
}}); } catch (err) {
}, 1000); console.error(err.message);
const app = new App(cluster.worker.id); console.error(err.stack);
await app.init(); process.exit(1);
app.start(); }
})(); })();
console.log(`[CLUSTER] Worker ${process.pid} started`); console.log(`[CLUSTER] Worker ${process.pid} started`);

@ -1,14 +1,20 @@
import * as crypto from "crypto"; import * as crypto from "crypto";
import {GraphQLError} from "graphql";
import * as sqz from "sequelize"; import * as sqz from "sequelize";
import {Sequelize} from "sequelize-typescript"; import {Sequelize} from "sequelize-typescript";
import {ActivityNotFoundError} from "./errors/ActivityNotFoundError";
import {ChatNotFoundError} from "./errors/ChatNotFoundError"; import {ChatNotFoundError} from "./errors/ChatNotFoundError";
import {DuplicatedRequestError} from "./errors/DuplicatedRequestError";
import {EmailAlreadyRegisteredError} from "./errors/EmailAlreadyRegisteredError"; import {EmailAlreadyRegisteredError} from "./errors/EmailAlreadyRegisteredError";
import {PostNotFoundGqlError} from "./errors/graphqlErrors";
import {GroupAlreadyExistsError} from "./errors/GroupAlreadyExistsError";
import {GroupNotFoundError} from "./errors/GroupNotFoundError"; import {GroupNotFoundError} from "./errors/GroupNotFoundError";
import {InvalidLoginError} from "./errors/InvalidLoginError"; import {InvalidLoginError} from "./errors/InvalidLoginError";
import {NoActionSpecifiedError} from "./errors/NoActionSpecifiedError"; import {NoActionSpecifiedError} from "./errors/NoActionSpecifiedError";
import {UserNotFoundError} from "./errors/UserNotFoundError"; import {UserNotFoundError} from "./errors/UserNotFoundError";
import globals from "./globals"; import globals from "./globals";
import {InternalEvents} from "./InternalEvents"; import {InternalEvents} from "./InternalEvents";
import {Activity} from "./models";
import * as models from "./models"; import * as models from "./models";
/** /**
@ -52,6 +58,7 @@ namespace dataaccess {
models.GroupMember, models.GroupMember,
models.EventParticipant, models.EventParticipant,
models.Event, models.Event,
models.Activity,
]); ]);
} catch (err) { } catch (err) {
globals.logger.error(err.message); globals.logger.error(err.message);
@ -163,21 +170,38 @@ namespace dataaccess {
* Creates a post * Creates a post
* @param content * @param content
* @param authorId * @param authorId
* @param type * @param activityId
*/ */
export async function createPost(content: string, authorId: number, type?: string): Promise<models.Post> { export async function createPost(content: string, authorId: number, activityId?: number): Promise<models.Post> {
type = type || "MISC"; const activity = await models.Activity.findByPk(activityId);
const post = await models.Post.create({content, authorId}); if (!activityId || activity) {
globals.internalEmitter.emit(InternalEvents.POSTCREATE, post); const post = await models.Post.create({content, authorId, activityId});
return post; globals.internalEmitter.emit(InternalEvents.POSTCREATE, post);
if (activity) {
const user = await models.User.findByPk(authorId);
user.rankpoints += activity.points;
await user.save();
}
return post;
} else {
throw new ActivityNotFoundError(activityId);
}
} }
/** /**
* Deletes a post * Deletes a post
* @param postId * @param postId
*/ */
export async function deletePost(postId: number): Promise<boolean> { export async function deletePost(postId: number): Promise<boolean|GraphQLError> {
await (await models.Post.findByPk(postId)).destroy(); try {
const post = await models.Post.findByPk(postId, {include: [{model: Activity}, {association: "rAuthor"}]});
const activity = await post.activity();
const author = await post.author();
author.rankpoints -= activity.points;
await author.save();
} catch (err) {
return new PostNotFoundGqlError(postId);
}
return true; return true;
} }
@ -231,9 +255,16 @@ namespace dataaccess {
export async function createRequest(sender: number, receiver: number, requestType?: RequestType) { export async function createRequest(sender: number, receiver: number, requestType?: RequestType) {
requestType = requestType || RequestType.FRIENDREQUEST; requestType = requestType || RequestType.FRIENDREQUEST;
const request = await models.Request.create({senderId: sender, receiverId: receiver, requestType}); const requestExists = !!await models.Request.findOne({where:
globals.internalEmitter.emit(InternalEvents.REQUESTCREATE, request); {senderId: sender, receiverId: receiver, requestType}});
return request;
if (!requestExists) {
const request = await models.Request.create({senderId: sender, receiverId: receiver, requestType});
globals.internalEmitter.emit(InternalEvents.REQUESTCREATE, request);
return request;
} else {
throw new DuplicatedRequestError();
}
} }
/** /**
@ -243,19 +274,25 @@ namespace dataaccess {
* @param members * @param members
*/ */
export async function createGroup(name: string, creator: number, members: number[]): Promise<models.Group> { export async function createGroup(name: string, creator: number, members: number[]): Promise<models.Group> {
members = members || []; const groupNameExists = !!await models.Group.findOne({where: {name}});
return sequelize.transaction(async (t) => { if (!groupNameExists) {
members.push(creator); members = members || [];
const groupChat = await createChat(...members); return sequelize.transaction(async (t) => {
const group = await models.Group.create({name, creatorId: creator, chatId: groupChat.id}, {transaction: t}); members.push(creator);
const creatorUser = await models.User.findByPk(creator, {transaction: t}); const groupChat = await createChat(...members);
await group.$add("rAdmins", creatorUser, {transaction: t}); const group = await models.Group
for (const member of members) { .create({name, creatorId: creator, chatId: groupChat.id}, {transaction: t});
const user = await models.User.findByPk(member, {transaction: t}); const creatorUser = await models.User.findByPk(creator, {transaction: t});
await group.$add("rMembers", user, {transaction: t}); await group.$add("rAdmins", creatorUser, {transaction: t});
} for (const member of members) {
return group; const user = await models.User.findByPk(member, {transaction: t});
}); await group.$add("rMembers", user, {transaction: t});
}
return group;
});
} else {
throw new GroupAlreadyExistsError(name);
}
} }
/** /**

@ -0,0 +1,7 @@
import {BaseError} from "./BaseError";
export class ActivityNotFoundError extends BaseError {
constructor(id: number) {
super(`The activity with the id ${id} could not be found.`);
}
}

@ -0,0 +1,7 @@
import {BaseError} from "./BaseError";
export class DuplicatedRequestError extends BaseError {
constructor() {
super(`Request already exists.`);
}
}

@ -0,0 +1,7 @@
import {BaseError} from "./BaseError";
export class GroupAlreadyExistsError extends BaseError {
constructor(name: string) {
super(`A group with the name "${name}" already exists.`);
}
}

@ -63,5 +63,10 @@ interface IConfig {
* Points to the index.html which is loaded as a fallback for angular to work * Points to the index.html which is loaded as a fallback for angular to work
*/ */
angularIndex?: string; angularIndex?: string;
/**
* The path of the public folder
*/
publicPath?: string;
}; };
} }

@ -0,0 +1,18 @@
import * as sqz from "sequelize";
import {Column, ForeignKey, Model, NotNull, Table, Unique} from "sequelize-typescript";
@Table({underscored: true})
export class Activity extends Model {
@Unique
@NotNull
@Column({type: sqz.STRING(128), allowNull: false, unique: true})
public name: string;
@NotNull
@Column({type: sqz.TEXT, allowNull: false})
public description: string;
@Column
public points: number;
}

@ -1,4 +1,14 @@
import {BelongsTo, BelongsToMany, Column, ForeignKey, HasMany, Model, NotNull, Table} from "sequelize-typescript"; import {
BelongsTo,
BelongsToMany,
Column,
ForeignKey,
HasMany,
Model,
NotNull,
Table,
Unique,
} from "sequelize-typescript";
import {ChatRoom} from "./ChatRoom"; import {ChatRoom} from "./ChatRoom";
import {Event} from "./Event"; import {Event} from "./Event";
import {GroupAdmin} from "./GroupAdmin"; import {GroupAdmin} from "./GroupAdmin";
@ -8,7 +18,8 @@ import {User} from "./User";
@Table({underscored: true}) @Table({underscored: true})
export class Group extends Model<Group> { export class Group extends Model<Group> {
@NotNull @NotNull
@Column({allowNull: false}) @Unique
@Column({allowNull: false, unique: true})
public name: string; public name: string;
@NotNull @NotNull

@ -1,6 +1,7 @@
import * as sqz from "sequelize"; import * as sqz from "sequelize";
import {BelongsTo, BelongsToMany, Column, CreatedAt, ForeignKey, Model, NotNull, Table} from "sequelize-typescript"; import {BelongsTo, BelongsToMany, Column, CreatedAt, ForeignKey, Model, NotNull, Table} from "sequelize-typescript";
import markdown from "../markdown"; import markdown from "../markdown";
import {Activity} from "./Activity";
import {PostVote, VoteType} from "./PostVote"; import {PostVote, VoteType} from "./PostVote";
import {User} from "./User"; import {User} from "./User";
@ -15,9 +16,16 @@ export class Post extends Model<Post> {
@Column({allowNull: false}) @Column({allowNull: false})
public authorId: number; public authorId: number;
@ForeignKey(() => Activity)
@Column({allowNull: true})
public activityId: number;
@BelongsTo(() => User, "authorId") @BelongsTo(() => User, "authorId")
public rAuthor: User; public rAuthor: User;
@BelongsTo(() => Activity, "activityId")
public rActivity?: Activity;
@BelongsToMany(() => User, () => PostVote) @BelongsToMany(() => User, () => PostVote)
public rVotes: Array<User & {PostVote: PostVote}>; public rVotes: Array<User & {PostVote: PostVote}>;
@ -31,6 +39,13 @@ export class Post extends Model<Post> {
return await this.$get("rAuthor") as User; return await this.$get("rAuthor") as User;
} }
/**
* Returns the activity of the post.
*/
public async activity(): Promise<Activity|undefined> {
return await this.$get("rActivity") as Activity;
}
/** /**
* Returns the votes on a post * Returns the votes on a post
*/ */
@ -89,11 +104,24 @@ export class Post extends Model<Post> {
} }
/** /**
* Returns the type of vote that was performend on the post by the user specified by the user id. * Returns the type of vote that was performed on the post by the user specified by the user id.
* @param userId * @param userId
*/ */
public async userVote({userId}: {userId: number}): Promise<VoteType> { public async userVote({userId}: {userId: number}): Promise<VoteType> {
const votes = await this.$get("rVotes", {where: {id: userId}}) as Array<User & {PostVote: PostVote}>; const votes = await this.$get("rVotes", {where: {id: userId}}) as Array<User & {PostVote: PostVote}>;
return votes[0]?.PostVote?.voteType; return votes[0]?.PostVote?.voteType;
} }
/**
* Returns if the post can be deleted by the user with the given id.
* @param userId
*/
public async deletable({userId}: {userId: number}): Promise<boolean> {
const isAuthor = Number(userId) === Number(this.authorId);
if (!isAuthor) {
return (await User.findOne({where: {id: userId}})).isAdmin;
}
return isAuthor;
}
} }

@ -35,10 +35,23 @@ export class Request extends Model<Request> {
@BelongsTo(() => User, "receiverId") @BelongsTo(() => User, "receiverId")
public rReceiver: User; public rReceiver: User;
/**
* Wrapper to return the request type for the request
*/
public get type(): RequestType {
return this.requestType;
}
/**
* The receiver of the request
*/
public async receiver(): Promise<User> { public async receiver(): Promise<User> {
return await this.$get("rReceiver") as User; return await this.$get("rReceiver") as User;
} }
/**
* The sender of the request.
*/
public async sender(): Promise<User> { public async sender(): Promise<User> {
return await this.$get("rSender") as User; return await this.$get("rSender") as User;
} }

@ -61,6 +61,13 @@ export class User extends Model<User> {
@Column({defaultValue: () => Date.now() + 7200000}) @Column({defaultValue: () => Date.now() + 7200000})
public authExpire: Date; public authExpire: Date;
@NotNull
@Column({defaultValue: false, allowNull: false})
public isAdmin: boolean;
@Column({type: sqz.STRING(512)})
public profilePicture: string;
@BelongsToMany(() => User, () => Friendship, "userId") @BelongsToMany(() => User, () => Friendship, "userId")
public rFriends: User[]; public rFriends: User[];
@ -200,20 +207,17 @@ export class User extends Model<User> {
return await this.$get("rReceivedRequests") as Request[]; return await this.$get("rReceivedRequests") as Request[];
} }
/**
* a list of posts the user has created
* @param first
* @param offset
*/
public async posts({first, offset}: { first: number, offset: number }): Promise<Post[]> { public async posts({first, offset}: { first: number, offset: number }): Promise<Post[]> {
const limit = first ?? 10; const limit = first ?? 10;
offset = offset ?? 0; offset = offset ?? 0;
return await this.$get("rPosts", {limit, offset}) as Post[]; 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 * number of posts the user created
*/ */

@ -11,3 +11,4 @@ export {GroupAdmin} from "./GroupAdmin";
export {GroupMember} from "./GroupMember"; export {GroupMember} from "./GroupMember";
export {Event} from "./Event"; export {Event} from "./Event";
export {EventParticipant} from "./EventParticipant"; export {EventParticipant} from "./EventParticipant";
export {Activity} from "./Activity";

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save