/* * This file is part of ARSnova Backend. * Copyright (C) 2012-2017 The ARSnova Team * * ARSnova Backend is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * ARSnova Backend is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package de.thm.arsnova.socket; import com.codahale.metrics.annotation.Timed; import com.corundumstudio.socketio.AckRequest; import com.corundumstudio.socketio.Configuration; import com.corundumstudio.socketio.SocketConfig; import com.corundumstudio.socketio.SocketIOClient; import com.corundumstudio.socketio.SocketIOServer; import com.corundumstudio.socketio.listener.ConnectListener; import com.corundumstudio.socketio.listener.DataListener; import com.corundumstudio.socketio.listener.DisconnectListener; import com.corundumstudio.socketio.protocol.Packet; import com.corundumstudio.socketio.protocol.PacketType; import de.thm.arsnova.entities.InterposedQuestion; import de.thm.arsnova.entities.User; import de.thm.arsnova.entities.transport.LearningProgressOptions; import de.thm.arsnova.events.*; import de.thm.arsnova.exceptions.NoContentException; import de.thm.arsnova.exceptions.NotFoundException; import de.thm.arsnova.exceptions.UnauthorizedException; import de.thm.arsnova.services.IFeedbackService; import de.thm.arsnova.services.IQuestionService; import de.thm.arsnova.services.ISessionService; import de.thm.arsnova.services.IUserService; import de.thm.arsnova.socket.message.Feedback; import de.thm.arsnova.socket.message.Question; import de.thm.arsnova.socket.message.Session; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Required; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import javax.annotation.PreDestroy; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map.Entry; import java.util.Set; import java.util.UUID; /** * Web socket implementation based on Socket.io. */ @Component public class ARSnovaSocketIOServer implements ARSnovaSocket, NovaEventVisitor { @Autowired private IFeedbackService feedbackService; @Autowired private IUserService userService; @Autowired private ISessionService sessionService; @Autowired private IQuestionService questionService; private static final Logger logger = LoggerFactory.getLogger(ARSnovaSocketIOServer.class); private int portNumber; private String hostIp; private boolean useSSL = false; private String keystore; private String storepass; private final Configuration config; private SocketIOServer server; public ARSnovaSocketIOServer() { config = new Configuration(); } @PreDestroy public void closeAllSessions() { logger.info("Close all websockets due to @PreDestroy"); for (final SocketIOClient c : server.getAllClients()) { c.disconnect(); } int clientCount = 0; for (final SocketIOClient c : server.getAllClients()) { c.send(new Packet(PacketType.DISCONNECT)); clientCount++; } logger.info("Pending websockets at @PreDestroy: {}", clientCount); server.stop(); } public void startServer() { /* hack: listen to ipv4 adresses */ System.setProperty("java.net.preferIPv4Stack", "true"); SocketConfig soConfig = new SocketConfig(); soConfig.setReuseAddress(true); config.setSocketConfig(soConfig); config.setPort(portNumber); config.setHostname(hostIp); if (useSSL) { try { final InputStream stream = new FileInputStream(keystore); config.setKeyStore(stream); config.setKeyStorePassword(storepass); } catch (final FileNotFoundException e) { logger.error("Keystore {} not found on filesystem", keystore); } } server = new SocketIOServer(config); server.addEventListener("setFeedback", Feedback.class, new DataListener<Feedback>() { @Override @Timed(name = "setFeedbackEvent.onData") public void onData(final SocketIOClient client, final Feedback data, final AckRequest ackSender) { final User u = userService.getUser2SocketId(client.getSessionId()); if (u == null) { logger.info("Client {} tried to send feedback but is not mapped to a user", client.getSessionId()); return; } final String sessionKey = userService.getSessionForUser(u.getUsername()); final de.thm.arsnova.entities.Session session = sessionService.getSessionInternal(sessionKey, u); if (session.getFeedbackLock()) { logger.debug("Feedback save blocked: {}", u, sessionKey, data.getValue()); } else { logger.debug("Feedback recieved: {}", u, sessionKey, data.getValue()); if (null != sessionKey) { feedbackService.saveFeedback(sessionKey, data.getValue(), u); } } } }); server.addEventListener("setSession", Session.class, new DataListener<Session>() { @Override @Timed(name = "setSessionEvent.onData") public void onData(final SocketIOClient client, final Session session, final AckRequest ackSender) { final User u = userService.getUser2SocketId(client.getSessionId()); if (null == u) { logger.info("Client {} requested to join session but is not mapped to a user", client.getSessionId()); return; } final String oldSessionKey = userService.getSessionForUser(u.getUsername()); if (null != session.getKeyword() && session.getKeyword().equals(oldSessionKey)) { return; } if (null != sessionService.joinSession(session.getKeyword(), client.getSessionId())) { /* active user count has to be sent to the client since the broadcast is * not always sent as long as the polling solution is active simultaneously */ reportActiveUserCountForSession(session.getKeyword()); reportSessionDataToClient(session.getKeyword(), u, client); } if (null != oldSessionKey) { reportActiveUserCountForSession(oldSessionKey); } } }); server.addEventListener( "readInterposedQuestion", de.thm.arsnova.entities.transport.InterposedQuestion.class, new DataListener<de.thm.arsnova.entities.transport.InterposedQuestion>() { @Override @Timed(name = "readInterposedQuestionEvent.onData") public void onData( SocketIOClient client, de.thm.arsnova.entities.transport.InterposedQuestion question, AckRequest ackRequest) { final User user = userService.getUser2SocketId(client.getSessionId()); try { questionService.readInterposedQuestionInternal(question.getId(), user); } catch (NotFoundException | UnauthorizedException e) { logger.error("Loading of question {} failed for user {} with exception {}", question.getId(), user, e.getMessage()); } } }); server.addEventListener("readFreetextAnswer", String.class, new DataListener<String>() { @Override public void onData(SocketIOClient client, String answerId, AckRequest ackRequest) { final User user = userService.getUser2SocketId(client.getSessionId()); try { questionService.readFreetextAnswer(answerId, user); } catch (NotFoundException | UnauthorizedException e) { logger.error("Marking answer {} as read failed for user {} with exception {}", answerId, user, e.getMessage()); } } }); server.addEventListener( "setLearningProgressOptions", LearningProgressOptions.class, new DataListener<LearningProgressOptions>() { @Override @Timed(name = "setLearningProgressOptionsEvent.onData") public void onData(SocketIOClient client, LearningProgressOptions progressOptions, AckRequest ack) { final User user = userService.getUser2SocketId(client.getSessionId()); final de.thm.arsnova.entities.Session session = sessionService.getSessionInternal(progressOptions.getSessionKeyword(), user); if (session.isCreator(user)) { session.setLearningProgressOptions(progressOptions.toEntity()); sessionService.updateSessionInternal(session, user); broadcastInSession(session.getKeyword(), "learningProgressOptions", progressOptions.toEntity()); } } }); server.addConnectListener(new ConnectListener() { @Override @Timed public void onConnect(final SocketIOClient client) { /* No implementation - only used for monitoring */ } }); server.addDisconnectListener(new DisconnectListener() { @Override @Timed public void onDisconnect(final SocketIOClient client) { if ( userService == null || client.getSessionId() == null || userService.getUser2SocketId(client.getSessionId()) == null ) { return; } final String username = userService.getUser2SocketId(client.getSessionId()).getUsername(); final String sessionKey = userService.getSessionForUser(username); userService.removeUserFromSessionBySocketId(client.getSessionId()); userService.removeUser2SocketId(client.getSessionId()); if (null != sessionKey) { /* user disconnected before joining a session */ reportActiveUserCountForSession(sessionKey); } } }); server.start(); } public void stopServer() { logger.trace("In stopServer method of class: {}", getClass().getName()); try { for (final SocketIOClient client : server.getAllClients()) { client.disconnect(); } } catch (final Exception e) { /* If exceptions are not caught they could prevent the Socket.IO server from shutting down. */ logger.error("Exception caught on Socket.IO shutdown: {}", e.getMessage()); } server.stop(); } @Override public int getPortNumber() { return portNumber; } @Required public void setPortNumber(final int portNumber) { this.portNumber = portNumber; } public String getHostIp() { return hostIp; } public void setHostIp(final String hostIp) { this.hostIp = hostIp; } public String getStorepass() { return storepass; } @Required public void setStorepass(final String storepass) { this.storepass = storepass; } public String getKeystore() { return keystore; } @Required public void setKeystore(final String keystore) { this.keystore = keystore; } @Override public boolean isUseSSL() { return useSSL; } @Required public void setUseSSL(final boolean useSSL) { this.useSSL = useSSL; } public void reportDeletedFeedback(final User user, final Set<de.thm.arsnova.entities.Session> arsSessions) { final List<String> keywords = new ArrayList<>(); for (final de.thm.arsnova.entities.Session session : arsSessions) { keywords.add(session.getKeyword()); } this.sendToUser(user, "feedbackReset", keywords); } private List<UUID> findConnectionIdForUser(final User user) { final List<UUID> result = new ArrayList<>(); for (final Entry<UUID, User> e : userService.socketId2User()) { final UUID someUsersConnectionId = e.getKey(); final User someUser = e.getValue(); if (someUser.equals(user)) { result.add(someUsersConnectionId); } } return result; } private void sendToUser(final User user, final String event, Object data) { final List<UUID> connectionIds = findConnectionIdForUser(user); if (connectionIds.isEmpty()) { return; } for (final SocketIOClient client : server.getAllClients()) { if (connectionIds.contains(client.getSessionId())) { client.sendEvent(event, data); } } } /** * Currently only sends the feedback data to the client. Should be used for all * relevant Socket.IO data, the client needs to know after joining a session. */ public void reportSessionDataToClient(final String sessionKey, final User user, final SocketIOClient client) { final de.thm.arsnova.entities.Session session = sessionService.getSessionInternal(sessionKey, user); final de.thm.arsnova.entities.SessionFeature features = sessionService.getSessionFeatures(sessionKey); client.sendEvent("unansweredLecturerQuestions", questionService.getUnAnsweredLectureQuestionIds(sessionKey, user)); client.sendEvent("unansweredPreparationQuestions", questionService.getUnAnsweredPreparationQuestionIds(sessionKey, user)); client.sendEvent("countLectureQuestionAnswers", questionService.countLectureQuestionAnswersInternal(sessionKey)); client.sendEvent("countPreparationQuestionAnswers", questionService.countPreparationQuestionAnswersInternal(sessionKey)); client.sendEvent("activeUserCountData", sessionService.activeUsers(sessionKey)); client.sendEvent("learningProgressOptions", session.getLearningProgressOptions()); final de.thm.arsnova.entities.Feedback fb = feedbackService.getFeedback(sessionKey); client.sendEvent("feedbackData", fb.getValues()); if (features.isFlashcard() || features.isFlashcardFeature()) { client.sendEvent("countFlashcards", questionService.countFlashcardsForUserInternal(sessionKey)); client.sendEvent("flipFlashcards", session.getFlipFlashcards()); } try { final long averageFeedback = feedbackService.getAverageFeedbackRounded(sessionKey); client.sendEvent("feedbackDataRoundedAverage", averageFeedback); } catch (final NoContentException e) { final Object object = null; // can't directly use "null". client.sendEvent("feedbackDataRoundedAverage", object); } } public void reportUpdatedFeedbackForSession(final de.thm.arsnova.entities.Session session) { final de.thm.arsnova.entities.Feedback fb = feedbackService.getFeedback(session.getKeyword()); broadcastInSession(session.getKeyword(), "feedbackData", fb.getValues()); try { final long averageFeedback = feedbackService.getAverageFeedbackRounded(session.getKeyword()); broadcastInSession(session.getKeyword(), "feedbackDataRoundedAverage", averageFeedback); } catch (final NoContentException e) { broadcastInSession(session.getKeyword(), "feedbackDataRoundedAverage", null); } } public void reportFeedbackForUserInSession(final de.thm.arsnova.entities.Session session, final User user) { final de.thm.arsnova.entities.Feedback fb = feedbackService.getFeedback(session.getKeyword()); Long averageFeedback; try { averageFeedback = feedbackService.getAverageFeedbackRounded(session.getKeyword()); } catch (final NoContentException e) { averageFeedback = null; } final List<UUID> connectionIds = findConnectionIdForUser(user); if (connectionIds.isEmpty()) { return; } for (final SocketIOClient client : server.getAllClients()) { if (connectionIds.contains(client.getSessionId())) { client.sendEvent("feedbackData", fb.getValues()); client.sendEvent("feedbackDataRoundedAverage", averageFeedback); } } } public void reportActiveUserCountForSession(final String sessionKey) { final int count = userService.getUsersInSession(sessionKey).size(); broadcastInSession(sessionKey, "activeUserCountData", count); } public void reportAnswersToLecturerQuestionAvailable(final de.thm.arsnova.entities.Session session, final Question lecturerQuestion) { broadcastInSession(session.getKeyword(), "answersToLecQuestionAvail", lecturerQuestion.get_id()); } public void reportAudienceQuestionAvailable(final de.thm.arsnova.entities.Session session, final InterposedQuestion audienceQuestion) { /* TODO role handling implementation, send this only to users with role lecturer */ broadcastInSession(session.getKeyword(), "audQuestionAvail", audienceQuestion.get_id()); } public void reportLecturerQuestionAvailable(final de.thm.arsnova.entities.Session session, final List<de.thm.arsnova.entities.Question> qs) { List<Question> questions = new ArrayList<>(); for (de.thm.arsnova.entities.Question q : qs) { questions.add(new Question(q)); } /* TODO role handling implementation, send this only to users with role audience */ if (!qs.isEmpty()) { broadcastInSession(session.getKeyword(), "lecQuestionAvail", questions.get(0).get_id()); // deprecated! } broadcastInSession(session.getKeyword(), "lecturerQuestionAvailable", questions); } public void reportLecturerQuestionsLocked(final de.thm.arsnova.entities.Session session, final List<de.thm.arsnova.entities.Question> qs) { List<Question> questions = new ArrayList<>(); for (de.thm.arsnova.entities.Question q : qs) { questions.add(new Question(q)); } broadcastInSession(session.getKeyword(), "lecturerQuestionLocked", questions); } public void reportSessionStatus(final String sessionKey, final boolean active) { broadcastInSession(sessionKey, "setSessionActive", active); } public void broadcastInSession(final String sessionKey, final String eventName, final Object data) { /* collect a list of users which are in the current session iterate over * all connected clients and if send feedback, if user is in current * session */ final Set<User> users = userService.getUsersInSession(sessionKey); for (final SocketIOClient c : server.getAllClients()) { final User u = userService.getUser2SocketId(c.getSessionId()); if (u != null && users.contains(u)) { c.sendEvent(eventName, data); } } } @Override public void visit(NewQuestionEvent event) { this.reportLecturerQuestionAvailable(event.getSession(), Collections.singletonList(event.getQuestion())); } @Override public void visit(UnlockQuestionEvent event) { this.reportLecturerQuestionAvailable(event.getSession(), Collections.singletonList(event.getQuestion())); } @Override public void visit(LockQuestionEvent event) { this.reportLecturerQuestionsLocked(event.getSession(), Collections.singletonList(event.getQuestion())); } @Override public void visit(UnlockQuestionsEvent event) { this.reportLecturerQuestionAvailable(event.getSession(), event.getQuestions()); } @Override public void visit(LockQuestionsEvent event) { this.reportLecturerQuestionsLocked(event.getSession(), event.getQuestions()); } @Override public void visit(NewInterposedQuestionEvent event) { this.reportAudienceQuestionAvailable(event.getSession(), event.getQuestion()); } @Async @Override @Timed(name = "visit.NewAnswerEvent") public void visit(NewAnswerEvent event) { final String sessionKey = event.getSession().getKeyword(); this.reportAnswersToLecturerQuestionAvailable(event.getSession(), new Question(event.getQuestion())); broadcastInSession(sessionKey, "countQuestionAnswersByQuestionId", questionService.getAnswerAndAbstentionCountInternal(event.getQuestion().get_id())); broadcastInSession(sessionKey, "countLectureQuestionAnswers", questionService.countLectureQuestionAnswersInternal(sessionKey)); broadcastInSession(sessionKey, "countPreparationQuestionAnswers", questionService.countPreparationQuestionAnswersInternal(sessionKey)); // Update the unanswered count for the question variant that was answered. final de.thm.arsnova.entities.Question question = event.getQuestion(); if ("lecture".equals(question.getQuestionVariant())) { sendToUser(event.getUser(), "unansweredLecturerQuestions", questionService.getUnAnsweredLectureQuestionIds(sessionKey, event.getUser())); } else if ("preparation".equals(question.getQuestionVariant())) { sendToUser(event.getUser(), "unansweredPreparationQuestions", questionService.getUnAnsweredPreparationQuestionIds(sessionKey, event.getUser())); } } @Async @Override @Timed(name = "visit.DeleteAnswerEvent") public void visit(DeleteAnswerEvent event) { final String sessionKey = event.getSession().getKeyword(); this.reportAnswersToLecturerQuestionAvailable(event.getSession(), new Question(event.getQuestion())); // We do not know which user's answer was deleted, so we can't update his 'unanswered' list of questions... broadcastInSession(sessionKey, "countLectureQuestionAnswers", questionService.countLectureQuestionAnswersInternal(sessionKey)); broadcastInSession(sessionKey, "countPreparationQuestionAnswers", questionService.countPreparationQuestionAnswersInternal(sessionKey)); } @Async @Override @Timed(name = "visit.PiRoundDelayedStartEvent") public void visit(PiRoundDelayedStartEvent event) { final String sessionKey = event.getSession().getKeyword(); broadcastInSession(sessionKey, "startDelayedPiRound", event.getPiRoundInformations()); } @Async @Override @Timed(name = "visit.PiRoundEndEvent") public void visit(PiRoundEndEvent event) { final String sessionKey = event.getSession().getKeyword(); broadcastInSession(sessionKey, "endPiRound", event.getPiRoundEndInformations()); } @Async @Override @Timed(name = "visit.PiRoundCancelEvent") public void visit(PiRoundCancelEvent event) { final String sessionKey = event.getSession().getKeyword(); broadcastInSession(sessionKey, "cancelPiRound", event.getQuestionId()); } @Override public void visit(PiRoundResetEvent event) { final String sessionKey = event.getSession().getKeyword(); broadcastInSession(sessionKey, "resetPiRound", event.getPiRoundResetInformations()); } @Override public void visit(LockVoteEvent event) { final String sessionKey = event.getSession().getKeyword(); broadcastInSession(sessionKey, "lockVote", event.getVotingAdmission()); } @Override public void visit(UnlockVoteEvent event) { final String sessionKey = event.getSession().getKeyword(); broadcastInSession(sessionKey, "unlockVote", event.getVotingAdmission()); } @Override public void visit(LockVotesEvent event) { List<Question> questions = new ArrayList<>(); for (de.thm.arsnova.entities.Question q : event.getQuestions()) { questions.add(new Question(q)); } broadcastInSession(event.getSession().getKeyword(), "lockVotes", questions); } @Override public void visit(UnlockVotesEvent event) { List<Question> questions = new ArrayList<>(); for (de.thm.arsnova.entities.Question q : event.getQuestions()) { questions.add(new Question(q)); } broadcastInSession(event.getSession().getKeyword(), "unlockVotes", questions); } @Override public void visit(FeatureChangeEvent event) { final String sessionKey = event.getSession().getKeyword(); final de.thm.arsnova.entities.SessionFeature features = event.getSession().getFeatures(); broadcastInSession(sessionKey, "featureChange", features); if (features.isFlashcard() || features.isFlashcardFeature()) { broadcastInSession(sessionKey, "countFlashcards", questionService.countFlashcardsForUserInternal(sessionKey)); broadcastInSession(sessionKey, "flipFlashcards", event.getSession().getFlipFlashcards()); } } @Override public void visit(LockFeedbackEvent event) { broadcastInSession(event.getSession().getKeyword(), "lockFeedback", event.getSession().getFeedbackLock()); } @Override public void visit(FlipFlashcardsEvent event) { broadcastInSession(event.getSession().getKeyword(), "flipFlashcards", event.getSession().getFlipFlashcards()); } @Override public void visit(DeleteQuestionEvent deleteQuestionEvent) { // TODO Auto-generated method stub } @Override public void visit(DeleteAllQuestionsEvent event) { // TODO Auto-generated method stub } @Override public void visit(DeleteAllQuestionsAnswersEvent deleteAllAnswersEvent) { // TODO Auto-generated method stub } @Override public void visit(DeleteAllPreparationAnswersEvent deleteAllPreparationAnswersEvent) { // TODO Auto-generated method stub } @Override public void visit(DeleteAllLectureAnswersEvent deleteAllLectureAnswersEvent) { // TODO Auto-generated method stub } @Override public void visit(DeleteInterposedQuestionEvent deleteInterposedQuestionEvent) { // TODO Auto-generated method stub } @Override public void visit(NewFeedbackEvent event) { this.reportUpdatedFeedbackForSession(event.getSession()); } @Override public void visit(DeleteFeedbackForSessionsEvent event) { this.reportDeletedFeedback(event.getUser(), event.getSessions()); } @Override public void visit(StatusSessionEvent event) { this.reportSessionStatus(event.getSession().getKeyword(), event.getSession().isActive()); } @Override public void visit(ChangeLearningProgressEvent event) { broadcastInSession(event.getSession().getKeyword(), "learningProgressChange", null); } @Override public void visit(NewSessionEvent event) { } @Override public void visit(DeleteSessionEvent event) { } }