GitLab wurde aktualisiert. Dank regelmäßiger Updates bleibt das THM GitLab sicher und Sie profitieren von den neuesten Funktionen. Vielen Dank für Ihre Geduld.

Commit 9180ecd1 authored by Christopher Mark Fullarton's avatar Christopher Mark Fullarton
Browse files

Adds rabbitmq as message broker for frontend and backend

parent 828cb5b0
......@@ -16,6 +16,15 @@ To send E-Mails it is required to provide additional variables:
- `ARSNOVA_CLICK_BACKEND_MAIL_FROM [string]`: The `from` header of the E-Mails
- `ARSNOVA_CLICK_BACKEND_MAIL_TO [string]`: The `to` header of the E-Mails
###### RabbitMQ
The server uses RabbitMQ to send messages to the frontend. These variables can be adjusted to connect to the RabbitMQ server:
- `AMQP_PROTOCOL [string]`: Protocol for the connection (defaults to amqp)
- `AMQP_HOSTNAME [string]`: Hostname of the RabbitMQ Server (defaults to localhost)
- `AMQP_USER [string]`: The username to use for the connection (defaults to guest)
- `AMQP_PASSWORD [string]`: The username to use for the connection (defaults to guest)
As mentioned in the RabbitMQ installation guideline, the user should not be an management user!
###### Dumps
The server will generate dumps if an Error is thrown.
The dump will contain the serialized error and the state of the DAOs.
......
import { Channel, connect, Connection } from 'amqplib';
import { settings } from '../statistics';
class AMQPConnector {
private static _instance: AMQPConnector;
private _channel: Channel;
get channel(): Channel {
return this._channel;
}
private _connection: Connection;
constructor() {
}
public static getInstance(): AMQPConnector {
if (!this._instance) {
this._instance = new AMQPConnector();
}
return this._instance;
}
public async initConnection(): Promise<void> {
this._connection = await connect({
protocol: settings.amqp.protocol,
hostname: settings.amqp.hostname,
username: settings.amqp.user,
password: settings.amqp.password,
});
this._channel = await this._connection.createChannel();
}
}
export default AMQPConnector.getInstance();
......@@ -2,7 +2,6 @@ import { ObjectId } from 'bson';
import { MemberEntity } from '../entities/member/MemberEntity';
import { QuizEntity } from '../entities/quiz/QuizEntity';
import { DbCollection, DbEvent } from '../enums/DbOperation';
import { IMemberEntity } from '../interfaces/entities/Member/IMemberEntity';
import { IMemberSerialized } from '../interfaces/entities/Member/IMemberSerialized';
import { IQuizEntity } from '../interfaces/quizzes/IQuizEntity';
import { AbstractDAO } from './AbstractDAO';
......@@ -83,7 +82,7 @@ class MemberDAO extends AbstractDAO<Array<MemberEntity>> {
}
}
public getMembersOfQuiz(quizName: string): Array<IMemberEntity> {
public getMembersOfQuiz(quizName: string): Array<MemberEntity> {
return this.storage.filter(val => !!val.currentQuizName.match(new RegExp(`^${RegExp.escape(quizName)}$`, 'i')));
}
......
......@@ -2,6 +2,7 @@ import * as mongoose from 'mongoose';
import { Connection } from 'mongoose';
import { Database } from '../enums/DbOperation';
import LoggerService from '../services/LoggerService';
import AMQPConnector from './AMQPConnector';
class MongoDbConnector {
get dbName(): string {
......@@ -31,6 +32,10 @@ class MongoDbConnector {
resolve(db);
});
AMQPConnector.initConnection().then(() => {
AMQPConnector.channel.assertExchange('global', 'fanout');
});
await mongoose.connect(this._mongoURL, {
useCreateIndex: true,
autoIndex: true,
......
import { ObjectId } from 'bson';
import WebSocket from 'ws';
import { MemberGroupEntity } from '../../entities/member/MemberGroupEntity';
import { getQuestionForType } from '../../entities/question/QuizValidator';
import { QuizEntity } from '../../entities/quiz/QuizEntity';
......@@ -11,6 +10,7 @@ import { IQuizEntity, IQuizSerialized } from '../../interfaces/quizzes/IQuizEnti
import { generateToken } from '../../lib/generateToken';
import { setPath } from '../../lib/resolveNestedObjectProperty';
import { AbstractDAO } from '../AbstractDAO';
import AMQPConnector from '../AMQPConnector';
import DbDAO from '../DbDAO';
import MemberDAO from '../MemberDAO';
......@@ -55,7 +55,7 @@ class QuizDAO extends AbstractDAO<Array<IQuizEntity>> {
public removeQuiz(id: ObjectId): void {
const removedQuiz = this.storage.splice(this.storage.findIndex(val => val.id.equals(id)), 1);
removedQuiz[0].onRemove();
removedQuiz[0].state = 0;
removedQuiz[0].state = QuizState.Inactive;
MemberDAO.removeMembersOfQuiz(removedQuiz[0]);
}
......@@ -151,16 +151,13 @@ class QuizDAO extends AbstractDAO<Array<IQuizEntity>> {
}
}
public joinableQuizzesUpdated(): void {
this.updateEmitter.emit(DbEvent.Change, this.getJoinableQuizzes());
}
public async addQuiz(quizDoc: IQuizSerialized): Promise<IQuizEntity> {
if (this.getQuizByName(quizDoc.name)) {
throw new Error(`Duplicate quiz insertion: ${quizDoc.name}`);
}
const entity = new QuizEntity(quizDoc);
await AMQPConnector.channel.assertExchange(`quiz_${encodeURI(entity.name)}`, 'fanout');
this.storage.push(entity);
return entity;
}
......@@ -233,10 +230,6 @@ class QuizDAO extends AbstractDAO<Array<IQuizEntity>> {
return this.getActiveQuizzes().find(val => !!val.name.match(new RegExp(`^${RegExp.escape(quizName)}$`, 'i')));
}
public getQuizBySocket(ws: WebSocket): IQuizEntity {
return this.storage.find(quiz => quiz.containsSocket(ws));
}
public getQuizByToken(token: string): IQuizEntity {
return this.storage.find(quiz => quiz.privateKey === token);
}
......
import { ObjectId } from 'bson';
import { DeleteWriteOpResultObject } from 'mongodb';
import * as WebSocket from 'ws';
import AMQPConnector from '../../db/AMQPConnector';
import DbDAO from '../../db/DbDAO';
import MemberDAO from '../../db/MemberDAO';
import { DbCollection } from '../../enums/DbOperation';
......@@ -10,7 +10,6 @@ import { QuizState } from '../../enums/QuizState';
import { QuizVisibility } from '../../enums/QuizVisibility';
import { IQuizEntity, IQuizSerialized } from '../../interfaces/quizzes/IQuizEntity';
import { ISessionConfigurationEntity } from '../../interfaces/session_configuration/ISessionConfigurationEntity';
import { SendSocketMessageService } from '../../services/SendSocketMessageService';
import { AbstractEntity } from '../AbstractEntity';
import { MemberEntity } from '../member/MemberEntity';
import { MemberGroupEntity } from '../member/MemberGroupEntity';
......@@ -142,7 +141,8 @@ export class QuizEntity extends AbstractEntity implements IQuizEntity {
private _dropEmptyQuizTimeout: any;
private _quizTimerInterval: any;
private _quizTimer: number;
private _socketChannel: Array<WebSocket> = [];
private readonly _exchangeName: string;
constructor(quiz: IQuizSerialized) {
super();
......@@ -159,87 +159,52 @@ export class QuizEntity extends AbstractEntity implements IQuizEntity {
this._readingConfirmationRequested = !!quiz.readingConfirmationRequested;
this._visibility = quiz.visibility;
this._description = quiz.description;
this._exchangeName = encodeURI(`quiz_${quiz.name}`);
}
public onMemberAdded(member: MemberEntity): void {
this._socketChannel.forEach(socket => SendSocketMessageService.sendMessage(socket, {
public async onMemberAdded(member: MemberEntity): Promise<void> {
AMQPConnector.channel.publish(this._exchangeName, '.*', Buffer.from(JSON.stringify({
status: StatusProtocol.Success,
step: MessageProtocol.Added,
payload: { member: member.serialize() },
}));
})));
}
public onMemberRemoved(member: MemberEntity): void {
this._socketChannel.forEach(socket => SendSocketMessageService.sendMessage(socket, {
public async onMemberRemoved(member: MemberEntity): Promise<void> {
AMQPConnector.channel.publish(this._exchangeName, '.*', Buffer.from(JSON.stringify({
status: StatusProtocol.Success,
step: MessageProtocol.Removed,
payload: { name: member.name },
}));
})));
}
public onRemove(): void {
this._socketChannel.forEach(socket => SendSocketMessageService.sendMessage(socket, {
MemberDAO.getMembersOfQuiz(this.name).forEach(member => {
AMQPConnector.channel.deleteQueue(encodeURI(`${member.currentQuizName}_${member.name}`));
});
AMQPConnector.channel.publish(this._exchangeName, '.*', Buffer.from(JSON.stringify({
status: StatusProtocol.Success,
step: MessageProtocol.Closed,
}));
})));
}
public reset(): void {
this._socketChannel.forEach(socket => SendSocketMessageService.sendMessage(socket, {
AMQPConnector.channel.publish(this._exchangeName, '.*', Buffer.from(JSON.stringify({
status: StatusProtocol.Success,
step: MessageProtocol.Reset,
}));
})));
clearTimeout(this._quizTimerInterval);
}
public stop(): void {
this._socketChannel.forEach(socket => SendSocketMessageService.sendMessage(socket, {
AMQPConnector.channel.publish(this._exchangeName, '.*', Buffer.from(JSON.stringify({
status: StatusProtocol.Success,
step: MessageProtocol.Stop,
}));
})));
this.currentStartTimestamp = -1;
clearTimeout(this._quizTimerInterval);
}
public addSocketToChannel(socket: WebSocket): void {
if (this._socketChannel.find(value => value === socket)) {
console.error(`Cannot add socket to quiz channel ${this.name} since it is already added`);
return;
}
console.log(`Adding socket to quiz channel ${this.name}`);
this._socketChannel.push(socket);
clearTimeout(this._dropEmptyQuizTimeout);
this._dropEmptyQuizTimeout = null;
}
public removeSocketFromChannel(socket: WebSocket): void {
const index = this._socketChannel.findIndex(value => value === socket);
if (index === -1) {
console.log(`Cannot remove socket from quiz channel ${this.name} since it is not found`);
return;
}
console.log(`Removing socket from quiz channel ${this.name}`);
this._socketChannel.splice(index, 1);
if (!this._socketChannel.length) {
if (this._dropEmptyQuizTimeout !== null) {
clearTimeout(this._dropEmptyQuizTimeout);
}
this._dropEmptyQuizTimeout = setTimeout(() => {
if (!this._socketChannel.length) {
DbDAO.updateOne(DbCollection.Quizzes, { _id: this.id }, { state: QuizState.Inactive });
DbDAO.deleteMany(DbCollection.Members, { currentQuizName: this.name });
}
}, 300000); // 5 minutes
}
}
public containsSocket(socket: WebSocket): boolean {
return !!this._socketChannel.find(value => value === socket);
}
public addQuestion(question: AbstractQuestionEntity, index: number = -1): void {
if (index === -1 || index >= this.questionList.length) {
this.questionList.push(question);
......@@ -306,13 +271,13 @@ export class QuizEntity extends AbstractEntity implements IQuizEntity {
DbDAO.updateOne(DbCollection.Quizzes, { _id: this.id }, { currentQuestionIndex: nextIndex });
this._socketChannel.forEach(socket => SendSocketMessageService.sendMessage(socket, {
AMQPConnector.channel.publish(this._exchangeName, '.*', Buffer.from(JSON.stringify({
status: StatusProtocol.Success,
step: MessageProtocol.NextQuestion,
payload: {
nextQuestionIndex: nextIndex,
},
}));
})));
return nextIndex;
}
......@@ -322,11 +287,11 @@ export class QuizEntity extends AbstractEntity implements IQuizEntity {
}
public startNextQuestion(): void {
this._socketChannel.forEach(socket => SendSocketMessageService.sendMessage(socket, {
AMQPConnector.channel.publish(this._exchangeName, '.*', Buffer.from(JSON.stringify({
status: StatusProtocol.Success,
step: MessageProtocol.Start,
payload: {},
}));
})));
this._quizTimer = this._questionList[this._currentQuestionIndex].timer;
if (this._quizTimer <= 0) {
......@@ -338,13 +303,13 @@ export class QuizEntity extends AbstractEntity implements IQuizEntity {
}
this._quizTimerInterval = setInterval(() => {
this._quizTimer--;
this._socketChannel.forEach(socket => SendSocketMessageService.sendMessage(socket, {
AMQPConnector.channel.publish(this._exchangeName, '.*', Buffer.from(JSON.stringify({
status: StatusProtocol.Success,
step: MessageProtocol.Countdown,
payload: {
value: this._quizTimer,
},
}));
})));
if (this._quizTimer <= 0) {
clearInterval(this._quizTimerInterval);
......@@ -356,19 +321,20 @@ export class QuizEntity extends AbstractEntity implements IQuizEntity {
public requestReadingConfirmation(): void {
this._readingConfirmationRequested = true;
this._socketChannel.forEach(socket => SendSocketMessageService.sendMessage(socket, {
AMQPConnector.channel.publish(this._exchangeName, '.*', Buffer.from(JSON.stringify({
status: StatusProtocol.Success,
step: MessageProtocol.ReadingConfirmationRequested,
payload: {},
}));
})));
}
public updatedMemberResponse(payload: object): void {
this._socketChannel.forEach(socket => SendSocketMessageService.sendMessage(socket, {
AMQPConnector.channel.publish(this._exchangeName, '.*', Buffer.from(JSON.stringify({
status: StatusProtocol.Success,
step: MessageProtocol.UpdatedResponse,
payload,
}));
})));
if (MemberDAO.getMembersOfQuiz(this.name).every(nick => {
const val = nick.responses[this.currentQuestionIndex].value;
return typeof val === 'number' ? val > -1 : val.length > 0;
......
export enum QuizState {
Inactive, //
Active, //
Running, //
Finished, //
Inactive = 'Inactive', //
Active = 'Active', //
Running = 'Running', //
Finished = 'Finished', //
}
import { ObjectId } from 'bson';
import { DeleteWriteOpResultObject } from 'mongodb';
import WebSocket from 'ws';
import { MemberEntity } from '../../entities/member/MemberEntity';
import { AbstractQuestionEntity } from '../../entities/question/AbstractQuestionEntity';
import { QuizState } from '../../enums/QuizState';
......@@ -30,12 +29,6 @@ export interface IQuizEntity extends IQuizBase {
addQuestion(question: AbstractQuestionEntity, index: number): void;
addSocketToChannel(socket: WebSocket): void;
removeSocketFromChannel(socket: WebSocket): void;
containsSocket(socket: WebSocket): boolean;
updatedMemberResponse(payload: object): void;
startNextQuestion(): void;
......@@ -46,9 +39,9 @@ export interface IQuizEntity extends IQuizBase {
onRemove(): void;
onMemberAdded(member: MemberEntity): void;
onMemberAdded(member: MemberEntity): Promise<void>;
onMemberRemoved(memberEntity: MemberEntity): void;
onMemberRemoved(memberEntity: MemberEntity): Promise<void>;
}
export interface IQuizSerialized extends IQuizBase {
......
......@@ -7,7 +7,6 @@ import * as Minimist from 'minimist';
import * as path from 'path';
import * as process from 'process';
import 'reflect-metadata';
import * as WebSocket from 'ws';
import App from './App';
import AssetDAO from './db/AssetDAO';
import CasDAO from './db/CasDAO';
......@@ -19,7 +18,6 @@ import QuizDAO from './db/quiz/QuizDAO';
import UserDAO from './db/UserDAO';
import { jsonCensor } from './lib/jsonCensor';
import { rejectionToCreateDump } from './lib/rejectionToCreateDump';
import { WebSocketRouter } from './routers/websocket/WebSocketRouter';
import LoggerService from './services/LoggerService';
import { staticStatistics } from './statistics';
import { LoadTester } from './tests/LoadTester';
......@@ -174,8 +172,6 @@ function onListening(): void {
const bind: string = (typeof addr === 'string') ? `pipe ${addr}` : `port ${addr.port}`;
LoggerService.info(`Listening on ${bind}`);
WebSocketRouter.wss = new WebSocket.Server({ server });
I18nDAO.reloadCache().catch(reason => {
console.error('Could not reload i18n dao cache', reason);
});
......@@ -193,6 +189,4 @@ function runTest(): void {
});
}
function onClose(): void {
WebSocketRouter.wss.close();
}
function onClose(): void {}
import { index, prop, Typegoose } from 'typegoose';
import { arrayProp, index, prop, Typegoose } from 'typegoose';
import DbDAO from '../../db/DbDAO';
import UserDAO from '../../db/UserDAO';
import { DbCollection, DbEvent, DbWatchStreamOperation } from '../../enums/DbOperation';
......@@ -10,7 +10,10 @@ export class UserModelItem extends Typegoose implements IUserSerialized {
@prop({ required: true }) public name: string;
@prop({ required: false }) public passwordHash: string;
@prop({ required: false }) public tokenHash: string;
@prop({ required: true }) public userAuthorizations: Array<string>;
@arrayProp({
required: true,
items: String,
}) public userAuthorizations: Array<string>;
@prop({ required: true }) public privateKey: string;
@prop() public gitlabToken?: string;
@prop() public token?: string;
......
import { index, prop, Typegoose } from 'typegoose';
import { arrayProp, index, prop, Typegoose } from 'typegoose';
import DbDAO from '../../db/DbDAO';
import MemberDAO from '../../db/MemberDAO';
import { DbCollection, DbEvent, DbWatchStreamOperation } from '../../enums/DbOperation';
......@@ -14,7 +14,7 @@ export class MemberModelItem extends Typegoose implements IMemberSerialized {
@prop() public colorCode: string;
@prop({ required: false }) public groupName: string;
@prop() public name: string;
@prop() public responses: Array<IQuizResponse>;
@arrayProp({ items: Object }) public responses: Array<IQuizResponse>;
@prop({ required: false }) public ticket: string;
@prop() public token: string;
@prop() public currentQuizName: string;
......
......@@ -7,6 +7,7 @@ import * as path from 'path';
import { Get, getMetadataArgsStorage, JsonController, NotFoundError, Param, Res } from 'routing-controllers';
import { OpenAPI, routingControllersToSpec } from 'routing-controllers-openapi';
import { routingControllerOptions } from '../../App';
import QuizDAO from '../../db/quiz/QuizDAO';
import { settings, staticStatistics } from '../../statistics';
import { AbstractRouter } from './AbstractRouter';
......@@ -47,6 +48,7 @@ export class ApiRouter extends AbstractRouter {
private getAll(): object {
return {
serverConfig: settings.public,
activeQuizzes: QuizDAO.getJoinableQuizzes().map(quiz => quiz.name),
};
}
......
import { BodyParam, Delete, Get, JsonController, Param, Put } from 'routing-controllers';
import AMQPConnector from '../../db/AMQPConnector';
import DbDAO from '../../db/DbDAO';
import QuizDAO from '../../db/quiz/QuizDAO';
import { DbCollection } from '../../enums/DbOperation';
......@@ -6,7 +7,6 @@ import { MessageProtocol, StatusProtocol } from '../../enums/Message';
import { QuizState } from '../../enums/QuizState';
import { IQuizSerialized } from '../../interfaces/quizzes/IQuizEntity';
import { QuizModel } from '../../models/quiz/QuizModelItem';
import { WebSocketRouter } from '../websocket/WebSocketRouter';
import { AbstractRouter } from './AbstractRouter';
@JsonController('/api/v1/lobby')
......@@ -18,14 +18,13 @@ export class LobbyRouter extends AbstractRouter {
@BodyParam('privateKey') privateKey: string, //
): Promise<object> {
const messageToWSSClients = JSON.stringify({
AMQPConnector.channel.publish('global', '.*', Buffer.from(JSON.stringify({
status: StatusProtocol.Success,
step: MessageProtocol.SetActive,
payload: {
quizName: quiz.name,
},
});
WebSocketRouter.wss.clients.forEach(client => client.send(messageToWSSClients));
})));
quiz.state = QuizState.Active;
quiz.currentQuestionIndex = -1;
......@@ -73,6 +72,14 @@ export class LobbyRouter extends AbstractRouter {
DbDAO.updateOne(DbCollection.Quizzes, { _id: addedQuiz.id }, { state: QuizState.Inactive });
}
AMQPConnector.channel.publish('global', '.*', Buffer.from(JSON.stringify({
status: StatusProtocol.Success,
step: MessageProtocol.SetInactive,
payload: {
quizName,
},
})));
return {
status: StatusProtocol.Success,
step: MessageProtocol.Closed,
......
......@@ -20,6 +20,7 @@ import {
UnauthorizedError,
UploadedFiles,
} from 'routing-controllers';
import AMQPConnector from '../../db/AMQPConnector';
import { default as DbDAO } from '../../db/DbDAO';
import MemberDAO from '../../db/MemberDAO';
import QuizDAO from '../../db/quiz/QuizDAO';
......@@ -471,6 +472,14 @@ export class QuizRouter extends AbstractRouter {
throw result;
}
AMQPConnector.channel.publish('global', '.*', Buffer.from(JSON.stringify({
status: StatusProtocol.Success,
step: quiz.state === QuizState.Active ? MessageProtocol.SetActive : MessageProtocol.SetInactive,
payload: {
quizName: quiz.name,
},
})));
const existingQuiz = QuizDAO.getQuizByName(quiz.name);
if (existingQuiz) {
if (existingQuiz.privateKey !== privateKey) {
......@@ -564,6 +573,14 @@ export class QuizRouter extends AbstractRouter {
DbDAO.updateOne(DbCollection.Quizzes, { _id: quiz.id }, { state: QuizState.Inactive });
DbDAO.deleteMany(DbCollection.Members, { currentQuizName: quiz.name });
AMQPConnector.channel.publish('global', '.*', Buffer.from(JSON.stringify({
status: StatusProtocol.Success,
step: MessageProtocol.SetInactive,
payload: {
quizName,
},
})));
return {
status: StatusProtocol.Success,
step: MessageProtocol.Closed,
......
import * as WebSocket from 'ws';
import MemberDAO from '../../db/MemberDAO';
import QuizDAO from '../../db/quiz/QuizDAO';
import { DbEvent } from '../../enums/DbOperation';