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

pull/5/head
Trivernis 5 years ago committed by Gitea
commit a4ce781e41

@ -1,20 +1,20 @@
# Changelog
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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9] - 2019-10-29
### Added
- Graphql Schema
- default-config file and generation of config file on startup
- DTOs
- Home Route
- session management
- Sequelize models and integration
- Sequelize-typescript integration
- error pages
- pagination for most list types
- angular integration by redirecting to `index.html` on not found
# Changelog
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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9] - 2019-10-29
### Added
- Graphql Schema
- default-config file and generation of config file on startup
- DTOs
- Home Route
- session management
- Sequelize models and integration
- Sequelize-typescript integration
- error pages
- pagination for most list types
- angular integration by redirecting to `index.html` on not found

@ -9,3 +9,4 @@ Then you need to install all requirements. To do so, open a terminal in the
greenvironment project folder and execute "npm i". You can build the project by
executing "gulp" in the terminal. To run the server you need
to execute "node ./dist".
Additionally the server needs a working redis server to connect to.

7900
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -30,7 +30,6 @@
"@types/express-session": "^1.15.14",
"@types/express-socket.io-session": "^1.3.2",
"@types/fs-extra": "^8.0.0",
"@types/graphql": "^14.2.3",
"@types/http-status": "^0.2.30",
"@types/js-yaml": "^3.12.1",
"@types/markdown-it": "0.0.9",
@ -38,8 +37,8 @@
"@types/pg": "^7.11.0",
"@types/sequelize": "^4.28.5",
"@types/socket.io": "^2.1.2",
"@types/socket.io-redis": "^1.0.25",
"@types/validator": "^10.11.3",
"@types/winston": "^2.4.4",
"delete": "^1.1.0",
"gulp": "^4.0.2",
"gulp-minify": "^3.1.0",
@ -48,7 +47,7 @@
"ts-lint": "^4.5.1",
"tsc": "^1.20150623.0",
"tslint": "^5.19.0",
"typescript": "^3.5.3"
"typescript": "^3.7.2"
},
"dependencies": {
"compression": "^1.7.4",
@ -72,6 +71,7 @@
"sequelize": "^5.19.6",
"sequelize-typescript": "^1.0.0",
"socket.io": "^2.2.0",
"socket.io-redis": "^5.2.0",
"sqlite3": "^4.1.0",
"winston": "^3.2.1",
"winston-daily-rotate-file": "^4.2.1"

@ -14,8 +14,9 @@ import * as httpStatus from "http-status";
import * as path from "path";
import {Sequelize} from "sequelize-typescript";
import * as socketIo from "socket.io";
import * as socketIoRedis from "socket.io-redis";
import {resolver} from "./graphql/resolvers";
import dataaccess from "./lib/dataaccess";
import dataaccess from "./lib/dataAccess";
import globals from "./lib/globals";
import routes from "./routes";
@ -26,9 +27,11 @@ class App {
public app: express.Application;
public io: socketIo.Server;
public server: http.Server;
public readonly id?: number;
public readonly sequelize: Sequelize;
constructor() {
constructor(id?: number) {
this.id = id;
this.app = express();
this.server = new http.Server(this.app);
this.io = socketIo(this.server);
@ -38,12 +41,13 @@ class App {
/**
* initializes everything that needs to be initialized asynchronous.
*/
public async init() {
public async init(): Promise<void> {
await dataaccess.init(this.sequelize);
const appSession = session({
cookie: {
maxAge: Number(globals.config.session.cookieMaxAge) || 604800000,
// @ts-ignore
secure: "auto",
},
resave: false,
@ -53,12 +57,15 @@ class App {
});
const force = fsx.existsSync("sqz-force");
logger.info(`Sequelize Table force: ${force}`);
logger.info(`Syncinc database. Sequelize Table force: ${force}.`);
await this.sequelize.sync({force, logging: (msg) => logger.silly(msg)});
this.sequelize.options.logging = (msg) => logger.silly(msg);
logger.info("Setting up socket.io");
await routes.ioListeners(this.io);
this.io.adapter(socketIoRedis());
this.io.use(sharedsession(appSession, {autoSave: true}));
logger.info("Configuring express app.");
this.app.set("views", path.join(__dirname, "views"));
this.app.set("view engine", "pug");
this.app.set("trust proxy", 1);
@ -69,14 +76,17 @@ class App {
this.app.use(express.static(path.join(__dirname, "public")));
this.app.use(cookieParser());
this.app.use(appSession);
if (globals.config.server.cors) {
// enable cross origin requests if enabled in the config
if (globals.config.server?.cors) {
this.app.use(cors());
}
this.app.use((req, res, next) => {
logger.verbose(`${req.method} ${req.url}`);
process.send({cmd: "notifyRequest"});
next();
});
this.app.use(routes.router);
// listen for graphql requrest
this.app.use("/graphql", graphqlHTTP((request, response) => {
return {
// @ts-ignore all
@ -86,25 +96,40 @@ class App {
schema: buildSchema(importSchema(path.join(__dirname, "./graphql/schema.graphql"))),
};
}));
// allow access to cluster information
this.app.use("/cluster-info", (req: Request, res: Response) => {
res.json({
id: this.id,
});
});
// redirect all request to the angular file
this.app.use((req: any, res: Response) => {
if (globals.config.frontend.angularIndex) {
res.sendFile(path.join(__dirname, globals.config.frontend.angularIndex));
const angularIndex = path.join(__dirname, globals.config.frontend.angularIndex);
if (fsx.existsSync(path.join(angularIndex))) {
res.sendFile(angularIndex);
} else {
res.status(httpStatus.NOT_FOUND);
res.render("errors/404.pug", {url: req.url});
}
} else {
res.status(httpStatus.NOT_FOUND);
res.render("errors/404.pug", {url: req.url});
}
});
// show an error page for internal errors
this.app.use((err, req: Request, res: Response) => {
res.status(httpStatus.INTERNAL_SERVER_ERROR);
res.render("errors/500.pug");
});
logger.info("Server configured.");
}
/**
* Starts the web server.
*/
public start() {
if (globals.config.server.port) {
public start(): void {
if (globals.config.server?.port) {
logger.info(`Starting server...`);
this.app.listen(globals.config.server.port);
logger.info(`Server running on port ${globals.config.server.port}`);

@ -1,11 +1,12 @@
import {GraphQLError} from "graphql";
import * as status from "http-status";
import dataaccess from "../lib/dataaccess";
import dataaccess from "../lib/dataAccess";
import {NotLoggedInGqlError, PostNotFoundGqlError} from "../lib/errors/graphqlErrors";
import globals from "../lib/globals";
import {InternalEvents} from "../lib/InternalEvents";
import * as models from "../lib/models";
import {is} from "../lib/regex";
import * as yaml from "js-yaml";
/**
* Returns the resolvers for the graphql api.
@ -121,6 +122,22 @@ export function resolver(req: any, res: any): any {
return new GraphQLError("No username, email or password given.");
}
},
async setUserSettings({settings}: {settings: string}) {
if (req.session.userId) {
const user = await models.User.findByPk(req.session.userId);
try {
user.frontendSettings = yaml.safeLoad(settings);
await user.save();
return user.settings;
} catch (err) {
res.status(400);
return new GraphQLError("Invalid settings json.");
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async vote({postId, type}: { postId: number, type: dataaccess.VoteType }) {
if (postId && type) {
if (req.session.userId) {

@ -37,6 +37,9 @@ type Mutation {
"Registers the user."
register(username: String, email: String, passwordHash: String): Profile
"Sets the user settings to the specified settings string. The settings parameter should be a valid yaml."
setUserSettings(settings: String!): String!
"Logout of the user."
logout: Boolean
@ -247,6 +250,9 @@ type Profile implements UserData {
"the levels of the user depending on the points"
level: Int!
"the custom settings for the frontend"
settings: String!
}
"represents a single user post"
@ -274,7 +280,7 @@ type Post {
createdAt: String!
"the type of vote the user performed on the post"
userVote: VoteType
userVote(userId: ID!): VoteType
}
"represents a request of any type"

@ -1,11 +1,96 @@
// tslint:disable:no-console
import * as cluster from "cluster";
import App from "./app";
const numCPUs = require("os").cpus().length;
/**
* async main function wrapper.
*/
(async () => {
const app = new App();
await app.init();
app.start();
})();
interface IResourceUsage {
mem: {rss: number, heapTotal: number, heapUsed: number, external: number};
cpu: {user: number, system: number};
}
interface IClusterData {
reqCount: number;
workerCount: () => number;
workerRes: {[key: string]: IResourceUsage};
}
if (cluster.isMaster) {
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.on("exit", (worker, code, signal) => {
log(`[CLUSTER-M] Worker ${worker.process.pid} died!\n`);
delete clusterData.workerRes[worker.id];
log("[CLUSTER-M] Starting new worker\n");
cluster.fork();
});
cluster.on("online", (worker) => {
worker.process.stdout.on("data", (data) => {
log(`[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++) {
cluster.fork();
}
} else {
/**
* async main function wrapper.
*/
(async () => {
setInterval(() => {
process.send({cmd: "notifyResources", data: {
cpu: process.cpuUsage(),
mem: process.memoryUsage(),
}});
}, 1000);
const app = new App(cluster.worker.id);
await app.init();
app.start();
})();
console.log(`[CLUSTER] Worker ${process.pid} started`);
}

@ -35,7 +35,7 @@ namespace dataaccess {
/**
* Initializes everything that needs to be initialized asynchronous.
*/
export async function init(seq: Sequelize) {
export async function init(seq: Sequelize): Promise<void> {
sequelize = seq;
try {
await sequelize.addModels([
@ -131,7 +131,7 @@ namespace dataaccess {
* @param offset
* @param sort
*/
export async function getPosts(first: number, offset: number, sort: SortType) {
export async function getPosts(first: number, offset: number, sort: SortType): Promise<models.Post[]> {
if (sort === SortType.NEW) {
return models.Post.findAll({
include: [{association: "rVotes"}],
@ -140,6 +140,7 @@ namespace dataaccess {
order: [["createdAt", "DESC"]],
});
} else {
// more performant way to get the votes with plain sql
return await sequelize.query(
`SELECT * FROM (
SELECT *,

@ -1,4 +1,4 @@
import dataaccess from "../dataaccess";
import dataaccess from "../dataAccess";
import {BaseError} from "./BaseError";
export class RequestNotFoundError extends BaseError {

@ -1,12 +1,13 @@
import {EventEmitter} from "events";
import * as fsx from "fs-extra";
import * as yaml from "js-yaml";
import * as path from "path";
import * as winston from "winston";
require("winston-daily-rotate-file");
const configPath = "config.yaml";
const defaultConfig = __dirname + "/../default-config.yaml";
const defaultConfig = path.join(__dirname, "/../default-config.yaml");
// ensure that the config exists by copying the default config.
if (!(fsx.pathExistsSync(configPath))) {
@ -21,9 +22,9 @@ if (!(fsx.pathExistsSync(configPath))) {
* Defines global variables to be used.
*/
namespace globals {
export const config = yaml.safeLoad(fsx.readFileSync("config.yaml", "utf-8"));
export const config: IConfig = yaml.safeLoad(fsx.readFileSync("config.yaml", "utf-8"));
// @ts-ignore
export const logger = winston.createLogger({
export const logger: winston.Logger = winston.createLogger({
transports: [
new winston.transports.Console({
format: winston.format.combine(
@ -33,7 +34,7 @@ namespace globals {
return `${timestamp} ${level}: ${message}`;
}),
),
level: config.logging.level,
level: config.logging?.level ?? "info",
}),
// @ts-ignore
new (winston.transports.DailyRotateFile)({
@ -46,13 +47,13 @@ namespace globals {
}),
),
json: false,
level: config.logging.level,
level: config.logging?.level ?? "info",
maxFiles: "7d",
zippedArchive: true,
}),
],
});
export const internalEmitter = new EventEmitter();
export const internalEmitter: EventEmitter = new EventEmitter();
}
export default globals;

@ -0,0 +1,67 @@
/**
* An interface for the configuration file
*/
interface IConfig {
/**
* Database connection info
*/
database: {
/**
* A connection uri for the database. <type>://<user>:<password>@<ip/domain>/<database>
*/
connectionUri: string;
};
/**
* Configuration for the http server
*/
server?: {
/**
* The port to listen on
*/
port?: number;
/**
* If cross origin requests should be enabled
*/
cors?: false;
};
/**
* The session configuration
*/
session: {
/**
* A secure secret to be used for sessions
*/
secret: string;
/**
* The maximum cookie age before the session gets deleted
*/
cookieMaxAge: number;
};
/**
* Configuration for markdown parsing
*/
markdown?: {
/**
* The plugins to use for parsing
*/
plugins: string[];
};
/**
* Logging configuration
*/
logging?: {
/**
* The loglevel that is used for the console and logfiles
*/
level?: ("silly" | "debug" | "verbose" | "info" | "warn" | "error");
};
/**
* The frontend configuration
*/
frontend?: {
/**
* Points to the index.html which is loaded as a fallback for angular to work
*/
angularIndex?: string;
};
}

@ -20,7 +20,7 @@ namespace markdown {
* Renders the markdown string inline (without blocks).
* @param markdownString
*/
export function renderInline(markdownString: string) {
export function renderInline(markdownString: string): string {
return md.renderInline(markdownString);
}
@ -28,7 +28,7 @@ namespace markdown {
* Renders the markdown string.
* @param markdownString
*/
export function render(markdownString: string) {
export function render(markdownString: string): string {
return md.render(markdownString);
}
}

@ -29,8 +29,8 @@ export class Event extends Model<Event> {
}
public async participants({first, offset}: {first: number, offset: number}): Promise<User[]> {
const limit = first || 10;
offset = offset || 0;
const limit = first ?? 10;
offset = offset ?? 0;
return await this.$get("rParticipants", {limit, offset}) as User[];
}
}

@ -41,14 +41,14 @@ export class Group extends Model<Group> {
}
public async admins({first, offset}: { first: number, offset: number }): Promise<User[]> {
const limit = first || 10;
offset = offset || 0;
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;
const limit = first ?? 10;
offset = offset ?? 0;
return await this.$get("rMembers", {limit, offset}) as User[];
}
@ -57,8 +57,8 @@ export class Group extends Model<Group> {
}
public async events({first, offset}: { first: number, offset: number }): Promise<Event[]> {
const limit = first || 10;
offset = offset || 0;
const limit = first ?? 10;
offset = offset ?? 0;
return await this.$get("rEvents", {limit, offset}) as Event[];
}
}

@ -1,5 +1,5 @@
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 {PostVote, VoteType} from "./PostVote";
import {User} from "./User";
@ -44,15 +44,20 @@ export class Post extends Model<Post> {
return (await this.votes()).filter((v) => v.PostVote.voteType === VoteType.DOWNVOTE).length;
}
/**
* Toggles the vote of the user.
* @param userId
* @param type
*/
public async vote(userId: number, type: VoteType): Promise<VoteType> {
type = type || VoteType.UPVOTE;
type = type ?? VoteType.UPVOTE;
let votes = await this.$get("rVotes", {where: {id: userId}}) as Array<User & {PostVote: PostVote}>;
let vote = votes[0] || null;
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;
vote = votes[0] ?? null;
created = true;
}
if (vote) {
@ -67,4 +72,13 @@ export class Post extends Model<Post> {
return vote.PostVote.voteType;
}
/**
* Returns the type of vote that was performend on the post by the user specified by the user id.
* @param userId
*/
public async userVote({userId}: {userId: number}): Promise<VoteType> {
const votes = await this.$get("rVotes", {where: {id: userId}}) as Array<User & {PostVote: PostVote}>;
return votes[0]?.PostVote?.voteType;
}
}

@ -49,6 +49,10 @@ export class User extends Model<User> {
@Column({defaultValue: 0, allowNull: false})
public rankpoints: number;
@NotNull
@Column({defaultValue: {}, allowNull: false, type: sqz.JSON})
public frontendSettings: any;
@BelongsToMany(() => User, () => Friendship, "userId")
public rFriends: User[];
@ -119,14 +123,21 @@ export class User extends Model<User> {
return Math.ceil(this.rankpoints / 100);
}
/**
* returns the settings of the user as a jston string
*/
public get settings(): string {
return JSON.stringify(this.getDataValue("frontendSettings"));
}
/**
* 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;
const limit = first ?? 10;
offset = offset ?? 0;
return await this.$get("rFriendOf", {limit, offset}) as User[];
}
@ -143,8 +154,8 @@ export class User extends Model<User> {
* @param offset
*/
public async chats({first, offset}: { first: number, offset: number }): Promise<ChatRoom[]> {
const limit = first || 10;
offset = offset || 0;
const limit = first ?? 10;
offset = offset ?? 0;
return await this.$get("rChats", {limit, offset}) as ChatRoom[];
}
@ -170,8 +181,8 @@ export class User extends Model<User> {
}
public async posts({first, offset}: { first: number, offset: number }): Promise<Post[]> {
const limit = first || 10;
offset = offset || 0;
const limit = first ?? 10;
offset = offset ?? 0;
return await this.$get("rPosts", {limit, offset}) as Post[];
}
@ -210,8 +221,8 @@ export class User extends Model<User> {
* @param offset
*/
public async groups({first, offset}: { first: number, offset: number }): Promise<Group[]> {
const limit = first || 10;
offset = offset || 0;
const limit = first ?? 10;
offset = offset ?? 0;
return await this.$get("rGroups", {limit, offset}) as Group[];
}

@ -1,6 +1,6 @@
import {Router} from "express";
import {Namespace, Server} from "socket.io";
import dataaccess from "../lib/dataaccess";
import dataaccess from "../lib/dataAccess";
import globals from "../lib/globals";
import {InternalEvents} from "../lib/InternalEvents";
import {ChatMessage, ChatRoom, Post, Request, User} from "../lib/models";

@ -2,13 +2,16 @@
"compileOnSave": true,
"compilerOptions": {
"noImplicitAny": true,
"noImplicitThis": true,
"removeComments": true,
"preserveConstEnums": true,
"allowSyntheticDefaultImports": true,
"outDir": "./dist",
"sourceMap": true,
"target": "es2018",
"allowJs": true,
"allowJs": false,
"forceConsistentCasingInFileNames": true,
"strictFunctionTypes": true,
"moduleResolution": "node",
"module": "commonjs",
"experimentalDecorators": true,

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