/* CodeQ: an online programming tutor. Copyright (C) 2015 UL FRI This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program 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 Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ (function () { "use strict"; // ================================================================================ // Errors generated in this module // ================================================================================ var BinaryMessageError = function (message) { var e = new Error(message); this.message = message; if (e.stack) { this.stack = e.stack; } }; BinaryMessageError.prototype = new Error(); BinaryMessageError.prototype.name = 'BinaryMessageError'; var MessageParseError = function (message) { var e; if (message instanceof Error) { e = message; this.message = 'Message parse error: ' + e.toString(); } else { e = new Error(message); this.message = message; } if (e.stack) { this.stack = e.stack; } }; MessageParseError.prototype = new Error(); MessageParseError.prototype.name = 'MessageParseError'; // ================================================================================ // engine.io communication support functions (long polling / websocket communication) // ================================================================================ var currentTID = 0, // transaction ID of the next message sid = null, // session ID, changes after each successful initial login socket = null, reconnectTimer = null, login_username = null, login_password = null, waiters = {}, // keyed by TID: {packet, created, promise} connected = false, connectPromise = null, requestHandlers = {}, // handlers for requests (events) from the server, keyed by event ID, value is an array of handlers handleIncomingMessageError = function (error, data) { if (error instanceof BinaryMessageError) { codeq.log.error('Incoming message is not a string, attempting reconnect'); connected = false; socket.close(); // connection will be reestablished in the close event handler } else if (error instanceof MessageParseError) { codeq.log.error('Incoming message is not a JSON message, ignoring: ' + data); } else { codeq.log.error('Unknown error, attempting reconnect: ' + error); connected = false; socket.close(); // connection will be reestablished in the close event handler } }, parseData = function (data) { if (typeof data !== 'string') { throw new BinaryMessageError('Message is not a string'); } try { return JSON.parse(data); } catch (e) { codeq.log.error('Incoming message is not a JSON message, ignoring: ' + data); throw new MessageParseError(e); } }, onMessage = function (data) { var m, waiter, event, handlers, i, N, tid, sortedWaiters = [], s; try { m = parseData(data); } catch (e) { handleIncomingMessageError(e, data); return; } if ('type' in m) { // system (meta) protocol switch (m.type) { case 'sid': // the peer created or changed our session ID sid = m.sid; break; case 'reset': // the server forced us to reset to the login page // * could not re-register the connection with an existing session ID, username, and password // * the user logged in elsewhere s = 'The server forced us to disconnect'; if (typeof m.message === 'string') s += ': ' + m.message; sid = null; codeq.reset(s); return; default: codeq.log.warn('Unknown system message type: ' + m.type); } if (!connected) { if (sid !== null) { // our connection registration was successful connected = true; if (connectPromise) { // manual connect connectPromise.resolve(); connectPromise = null; } else { // auto re-connect } // resend anything not already sent for (tid in waiters) { if (waiters.hasOwnProperty(tid)) { sortedWaiters.push(waiters[tid]); } } // sort by ascending transaction ID before sending sortedWaiters.sort(function (a, b) {return a.tid - b.tid;}); for (i = 0; i < sortedWaiters.length; i++) { (function (waiter) { if (!waiter.sent) { socket.send(JSON.stringify(waiter.packet), function () { waiter.sent = true; }); } })(sortedWaiters[i]); } } else { codeq.log.error('Operating outside a session!'); codeq.reset('Protocol error: server sent a bogus message instead of acknowledging our connection'); } } return; } if (!connected) { codeq.log.warn('Not in the connected state, but receiving a user message anyway'); } if ('tid' in m) { waiter = waiters[m.tid]; if (waiter) { delete waiters[m.tid]; waiter.promise.resolve(m); codeq.log.debug('Delivered an incoming message, TID=' + m.tid); return; } else { codeq.log.debug('There is noone waiting for the incoming message, TID=' + m.tid); } } event = m.event; if (typeof event !== 'string') { codeq.log.info('Incoming message without a TID and with no event name, dropping it on the floor: ' + data); return; } handlers = requestHandlers[event]; if (!handlers) { codeq.log.info('Incoming event message cannot be handled: no handler registered for ' + event); return; } N = handlers.length; for (i = 0; i < N; i++) { setTimeout((function (handler) {return function () { handler(m); }})(handlers[i]), 0); } }, onConnect = function () { // register our connection if (sid === null) socket.send(JSON.stringify({'type': 'create_session'})); else socket.send(JSON.stringify({'type': 'connect_session', 'sid': sid, 'username': login_username, 'password': login_password})); }, onClose = function () { connected = false; if (connectPromise) { // resulted from manual connect, don't attempt reconnect connectPromise.reject(new Error('Connection failed')); connectPromise = null; socket.off('open', onConnect); socket.off('close', onClose); socket.off('message', onMessage); socket = null; } else { // spurious disconnect, attempt reconnect reconnectTimer = setTimeout(function () { reconnectTimer = null; socket.open(); }, 250); // reconnect in quarter of a second } }; // ================================================================================ // AJAX communication support functions (loading of static web resources) // ================================================================================ var languageCache = {}, // language defs, keyed by language identifier ajaxGet = function (url) { return Q.Promise(function (resolve, reject, notify) { $.ajax({ dataType: 'json', type: 'GET', url: url, error: function (jqXHR, textStatus, errorThrown) { codeq.log.error('AJAX to ' + url + ' failed with status ' + textStatus, errorThrown); reject(errorThrown || new Error(textStatus)); }, success: function (data, textStatus, jqXHR) { var s; if ((typeof data === 'object') && (data !== null)) { // got a JSON resolve(data); } else { s = 'AJAX to ' + url + ' did not result in a JSON object'; if (typeof data === 'string') { s += ', the response was:\n' + data; } else if (typeof textStatus === 'string') { s += ', status was: ' + textStatus; } codeq.log.error(s); reject(new Error(s)); } } }); }); }, ajaxPrefix, ajaxDataPrefix, ajaxResPrefix; ajaxPrefix = codeq.ajaxPrefix; if (!ajaxPrefix) ajaxPrefix = '/'; else if (ajaxPrefix[ajaxPrefix.length - 1] !== '/') { ajaxPrefix = ajaxPrefix.split('/'); ajaxPrefix[ajaxPrefix.length - 1] = ''; ajaxPrefix = ajaxPrefix.join('/'); } ajaxDataPrefix = ajaxPrefix + 'data/'; ajaxResPrefix = ajaxPrefix + 'res/'; // ================================================================================ // Try to be polite and logout on page close/reload // ================================================================================ window.onbeforeunload = function () { if (sid) { // we need to perform an action inside the current iteration of the event loop // so no fancy promises etc., just the raw AJAX request $.ajax({ type: 'GET', url: '/ws/logout?sid=' + sid + '&t=' + Date.now() }); } }; // ================================================================================ // This module's API methods // ================================================================================ codeq.comms = { 'getSid':function(){ return sid; }, 'connect': function () { var deferred = Q.defer(); if (connectPromise) { // connection already taking place connectPromise.then(deferred.resolve, deferred.reject, deferred.notify); } else if (socket) { // already connected if (connected) { deferred.resolve(); } else { // auto re-connecting connectPromise = deferred; } } else { // create a new connection connectPromise = deferred; socket = eio(codeq.eioHost, { 'upgrade': true, 'path': '/ws', 'transports': ['polling', 'websocket'] }); socket.on('open', onConnect); socket.on('close', onClose); socket.on('message', onMessage); } return deferred.promise; }, 'disconnect': function (reason) { // reason is optional var tid; if (socket) { socket.off('open', onConnect); socket.off('close', onClose); socket.off('message', onMessage); socket.close(); socket = null; } if (reconnectTimer !== null) { clearTimeout(reconnectTimer); reconnectTimer = null; } connected = false; if (connectPromise) { connectPromise.reject(new Error('Forced disconnect')); connectPromise = null; } login_password = null; // manual disconnect will prevent auto-login sid = null; // reject any outstanding promises for (tid in waiters) { if (waiters.hasOwnProperty(tid)) { waiters[tid].promise.reject(new Error(reason || 'Forced disconnect')); delete waiters[tid]; } } }, 'send': function (packet) { return Q.Promise(function (resolve, reject, notify) { var waiter = { 'packet': packet, 'created': Date.now(), 'promise': {'resolve': resolve, 'reject': reject, 'notify': notify}, 'sent': false }; packet.tid = currentTID; if (sid !== null) packet.sid = sid; waiters[currentTID] = waiter; currentTID++; if (connected) { socket.send(JSON.stringify(packet), function () { waiter.sent = true; }); } }); }, 'login': function (username, password) { var packet = {'action': 'login', 'username': username, 'password': password}, myself = this, performLogin = function () { return myself.send(packet).then(function (response) { if (response.code === 0) { // login successful, remember the login credentials for auto-reconnects login_username = username; login_password = password; sid = response.new_sid || response.sid; } return response; }); }; if (socket) return performLogin(); // this is the 1st method where we do connect() first, if we're not already connected return this.connect().then(performLogin); }, 'logout': function () { var performLogout = function() { var promise = codeq.comms.send({'action': 'logout'}); sid = null; return promise; }; if (socket) return performLogout(); return this.connect().then(performLogout); }, 'signup': function (username, name, email, password) { var myself = this, performSignUp = function () { return myself.send({'action': 'signup', 'username': username, 'name': name, 'email': email, 'password': password}); }; if (socket) return performSignUp(); // this is the 2nd method where we do connect() first, if we're not already connected return this.connect().then(performSignUp); }, 'changePassword': function (newpassword) { return this.send({'action': 'change_password', 'password': newpassword}); }, 'getUserStat': function () { return this.send({'action': 'user_stat'}); }, 'updateSettings': function (new_settings){ return this.send({'action': 'update_settings', 'settings': new_settings}); }, sendActivity: function commsSendActivity (trace, solution, problem_id) { return this.send({'action': 'activity', 'trace': trace, 'solution': solution, 'problem_id': problem_id}); }, sendQuery: function commsSendQuery (query, problem_id) { query['action'] = 'query'; return this.send(query); }, sendPythonExec: function commsSendPythonExec (json) { json['action'] = 'python_exec'; return this.send(json); }, sendPythonPush: function commsSendPythonPush (json) { json['action'] = 'python_push'; return this.send(json); }, sendPythonStop: function commsSendPythonStop (json) { json['action'] = 'python_stop'; return this.send(json); }, sendHint: function commsSendHint (json) { json['action'] = 'hint'; return this.send(json); }, sendTest: function commsSendTest (json) { json['action'] = 'test'; return this.send(json); }, getCurrentSolution: function commsGetCurrentSolution (problem_id) { return this.send({ 'action': 'get_current_solution', 'problem_id': problem_id }); }, getAttempts: function commsGetAttempts (language) { return this.send({ 'action': 'get_attempts', 'language': language }); }, getSolutions: function commsGetSolutions (problem_ids) { return this.send({ 'action': 'get_solutions', 'problem_ids': problem_ids }); }, loadProblem: function commsLoadProblem (problem_id) { return this.send({ 'action': 'load_problem', 'problem_id': problem_id }); }, endProblem: function commsLoadProblem (problem_id) { return this.send({ 'action': 'end_problem' }); }, 'on': function (event, handler) { var handlers = requestHandlers[event], N, i; if (!handlers) { handlers = [handler]; requestHandlers[event] = handlers; return; } N = handlers.length; for (i = 0; i < N; i++) { if (handlers[i] === handler) return; // already registered } handlers.push(handler); }, 'off': function (event, handler) { var handlers = requestHandlers[event], N, i; if (!handlers) return; // none registered if (arguments.length == 1) { // no handler specified -> remove all handlers for this event delete requestHandlers[event]; handlers.length = 0; } else { N = handlers.length; for (i = 0; i < N; i++) { if (handlers[i] === handler) { handlers.splice(i, 1); if (handlers.length === 0) { delete requestHandlers[event]; } return; } } } }, 'getLanguageDef': function (identifier) { var x = languageCache[identifier]; if ((typeof x === 'object') && (x !== null)) { // already defined if (typeof x.then === 'function') { // it's a promise, it means it's not resolved yet return x; } else { // return a resolved promise with the cached value return Q(x); } } x = ajaxGet(ajaxDataPrefix + identifier + '/language.json').then( function getLanguageDefSuccess(data) { languageCache[identifier] = data; return data; // proxy further }, function getLanguageDefFail(exception) { delete languageCache[identifier]; throw exception || new Error('Could not load language ' + identifier); // proxy further } ); languageCache[identifier] = x; return x; }, 'getProblemDef': function (language, group, problem) { return ajaxGet(ajaxDataPrefix + language + '/' + group + '/' + problem + '/problem.json'); }, 'getGuiTranslation': function (lang) { return ajaxGet(ajaxResPrefix + lang + '.json'); }, 'getResourceTree': function () { return ajaxGet(ajaxDataPrefix + 'resources.json'); }, 'samlLogin': function(upgrade_account, upgrade_password){ if (upgrade_account === undefined) return this.send({'action':'saml_login','gui_lang':codeq.getLang()}); else if (upgrade_account) return this.send({'action':'saml_login','gui_lang':codeq.getLang(), 'upgrade_account': true, 'upgrade_password': upgrade_password}); else return this.send({'action':'saml_login','gui_lang':codeq.getLang(), 'upgrade_account': false}); }, 'samlLogout': function(){ return this.send({'action':'saml_logout'}); }, 'forceReset': function () { var p; if (sid !== null) { p = Q.Promise(function (resolve, reject, notify) { var t = setTimeout(function () { t = null; resolve(); }, 3000); // 3 seconds codeq.comms.logout().then(function () { if (t !== null) { clearTimeout(t); t = null; resolve(); } }); }); } else p = Q(); return p.fin(codeq.comms.disconnect); } }; })();