diff --git a/client/app/app.styl b/client/app/app.styl index f6a3884..f5b180a 100644 --- a/client/app/app.styl +++ b/client/app/app.styl @@ -1,48 +1,49 @@ @import "font-awesome/css/font-awesome.css" @import "bootstrap/dist/css/bootstrap.css" // // Bootstrap Fonts // @font-face font-family: 'Glyphicons Halflings' src: url('../bower_components/bootstrap/fonts/glyphicons-halflings-regular.eot') src: url('../bower_components/bootstrap/fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../bower_components/bootstrap/fonts/glyphicons-halflings-regular.woff') format('woff'), url('../bower_components/bootstrap/fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../bower_components/bootstrap/fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); // // Font Awesome Fonts // @font-face font-family: 'FontAwesome' src: url('../bower_components/font-awesome/fonts/fontawesome-webfont.eot?v=4.1.0') src: url('../bower_components/font-awesome/fonts/fontawesome-webfont.eot?#iefix&v=4.1.0') format('embedded-opentype'), url('../bower_components/font-awesome/fonts/fontawesome-webfont.woff?v=4.1.0') format('woff'), url('../bower_components/font-awesome/fonts/fontawesome-webfont.ttf?v=4.1.0') format('truetype'), url('../bower_components/font-awesome/fonts/fontawesome-webfont.svg?v=4.1.0#fontawesomeregular') format('svg'); font-weight: normal font-style: normal // // App-wide Styles // .browsehappy background #ccc color #000 margin 0.2em 0 padding 0.2em 0 // Component styles are injected through grunt // injector @import 'account/login/login.styl'; @import 'admin/admin.styl'; @import 'editor/editor.styl'; @import 'main/main.styl'; @import 'import/import.styl'; @import 'modal/modal.styl'; -// endinjector \ No newline at end of file +@import 'wodo/editor.styl'; +// endinjector diff --git a/client/app/editor/editor.jade b/client/app/editor/editor.jade index dfe5988..e7aa789 100644 --- a/client/app/editor/editor.jade +++ b/client/app/editor/editor.jade @@ -1,2 +1,2 @@ div(ng-include='"components/navbar/navbar.html"') -wodo-editor +wodo-editor.wodo diff --git a/client/app/editor/editor.js b/client/app/editor/editor.js index 067854c..0fcfbda 100644 --- a/client/app/editor/editor.js +++ b/client/app/editor/editor.js @@ -1,18 +1,22 @@ 'use strict'; angular.module('manticoreApp') .config(function ($stateProvider) { $stateProvider .state('editor', { url: '/document/:id', + reload: true, resolve: { document: function ($stateParams, $http) { - return $http.get('/api/documents/' + $stateParams.id); + return $http.get('/api/documents/' + $stateParams.id) + .then(function(response) { + return response.data; + }); } }, templateUrl: 'app/editor/editor.html', controller: function ($scope, document) { $scope.document = document; } }); }); diff --git a/client/app/editor/editor.styl b/client/app/editor/editor.styl index 574b044..cc15fa1 100644 --- a/client/app/editor/editor.styl +++ b/client/app/editor/editor.styl @@ -1,4 +1,9 @@ +.navbar + border none + #wodoContainer width 100% - height 500px + height calc(100% - 50px) + top 50px padding 0 + position absolute diff --git a/client/components/wodo/adaptor.service.js b/client/components/wodo/adaptor.service.js index b45fc1c..5684769 100644 --- a/client/components/wodo/adaptor.service.js +++ b/client/components/wodo/adaptor.service.js @@ -1,283 +1,283 @@ /*jslint unparam: true*/ /*global runtime, core, ops, io*/ 'use strict'; angular.module('manticoreApp') .factory('Adaptor', function () { var OperationRouter = function (socket, odfContainer, errorCb) { var EVENT_BEFORESAVETOFILE = 'beforeSaveToFile', EVENT_SAVEDTOFILE = 'savedToFile', EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED = 'hasLocalUnsyncedOperationsChanged', EVENT_HASSESSIONHOSTCONNECTIONCHANGED = 'hasSessionHostConnectionChanged', EVENT_MEMBERADDED = 'memberAdded', EVENT_MEMBERCHANGED = 'memberChanged', EVENT_MEMBERREMOVED = 'memberRemoved', eventNotifier = new core.EventNotifier([ EVENT_BEFORESAVETOFILE, EVENT_SAVEDTOFILE, EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, EVENT_HASSESSIONHOSTCONNECTIONCHANGED, EVENT_MEMBERADDED, EVENT_MEMBERCHANGED, EVENT_MEMBERREMOVED, ops.OperationRouter.signalProcessingBatchStart, ops.OperationRouter.signalProcessingBatchEnd ]), operationFactory, playbackFunction, lastServerSyncHeadId = 0, sendClientOpspecsLock = false, sendClientOpspecsTask, hasSessionHostConnection = true, unplayedServerOpSpecQueue = [], unsyncedClientOpSpecQueue = [], operationTransformer = new ops.OperationTransformer(), /**@const*/sendClientOpspecsDelay = 300; function playbackOpspecs(opspecs) { var op, i; if (!opspecs.length) { return; } eventNotifier.emit(ops.OperationRouter.signalProcessingBatchStart, {}); for (i = 0; i < opspecs.length; i += 1) { op = operationFactory.create(opspecs[i]); if (op !== null) { if (!playbackFunction(op)) { eventNotifier.emit(ops.OperationRouter.signalProcessingBatchEnd, {}); errorCb('opExecutionFailure'); return; } } else { eventNotifier.emit(ops.OperationRouter.signalProcessingBatchEnd, {}); errorCb('Unknown opspec: ' + runtime.toJson(opspecs[i])); return; } } eventNotifier.emit(ops.OperationRouter.signalProcessingBatchEnd, {}); } function handleNewServerOpsWithUnsyncedClientOps(serverOps) { var transformResult = operationTransformer.transform(unsyncedClientOpSpecQueue, serverOps); if (!transformResult) { errorCb('Has unresolvable conflict.'); return false; } unsyncedClientOpSpecQueue = transformResult.opSpecsA; unplayedServerOpSpecQueue = unplayedServerOpSpecQueue.concat(transformResult.opSpecsB); return true; } function handleNewClientOpsWithUnplayedServerOps(clientOps) { var transformResult = operationTransformer.transform(clientOps, unplayedServerOpSpecQueue); if (!transformResult) { errorCb('Has unresolvable conflict.'); return false; } unsyncedClientOpSpecQueue = unsyncedClientOpSpecQueue.concat(transformResult.opSpecsA); unplayedServerOpSpecQueue = transformResult.opSpecsB; return true; } function receiveServerOpspecs(headId, serverOpspecs) { if (unsyncedClientOpSpecQueue.length > 0) { handleNewServerOpsWithUnsyncedClientOps(serverOpspecs); // could happen that ops from server make client ops obsolete if (unsyncedClientOpSpecQueue.length === 0) { eventNotifier.emit(EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, false); } } else { // apply directly playbackOpspecs(serverOpspecs); } lastServerSyncHeadId = headId; } function sendClientOpspecs() { var originalUnsyncedLength = unsyncedClientOpSpecQueue.length; if (originalUnsyncedLength) { sendClientOpspecsLock = true; socket.emit('commit_ops', { head: lastServerSyncHeadId, ops: unsyncedClientOpSpecQueue }, function (response) { if (response.conflict === true) { sendClientOpspecs(); } else { lastServerSyncHeadId = response.head; // on success no other server ops should have sneaked in meanwhile, so no need to check // got no other client ops meanwhile? if (unsyncedClientOpSpecQueue.length === originalUnsyncedLength) { unsyncedClientOpSpecQueue.length = 0; // finally apply all server ops collected while waiting for sync playbackOpspecs(unplayedServerOpSpecQueue); unplayedServerOpSpecQueue.length = 0; eventNotifier.emit(EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, false); sendClientOpspecsLock = false; } else { // send off the new client ops directly unsyncedClientOpSpecQueue.splice(0, originalUnsyncedLength); sendClientOpspecs(); } } }); } } this.setOperationFactory = function (f) { operationFactory = f; }; this.setPlaybackFunction = function (f) { playbackFunction = f; }; this.push = function (operations) { var clientOpspecs = [], now = Date.now(), hasLocalUnsyncedOpsBefore = (unsyncedClientOpSpecQueue.length !== 0), hasLocalUnsyncedOpsNow; operations.forEach(function(op) { var opspec = op.spec(); opspec.timestamp = now; clientOpspecs.push(opspec); }); playbackOpspecs(clientOpspecs); if (unplayedServerOpSpecQueue.length > 0) { handleNewClientOpsWithUnplayedServerOps(clientOpspecs); } else { unsyncedClientOpSpecQueue = unsyncedClientOpSpecQueue.concat(clientOpspecs); } hasLocalUnsyncedOpsNow = (unsyncedClientOpSpecQueue.length !== 0); if (hasLocalUnsyncedOpsNow !== hasLocalUnsyncedOpsBefore) { eventNotifier.emit(EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, hasLocalUnsyncedOpsNow); } sendClientOpspecsTask.trigger(); }; this.requestReplay = function (cb) { var cbOnce = function () { eventNotifier.unsubscribe(ops.OperationRouter.signalProcessingBatchEnd, cbOnce); cb(); }; // hack: relies on at least addmember op being added for ourselves and being executed eventNotifier.subscribe(ops.OperationRouter.signalProcessingBatchEnd, cbOnce); socket.emit('replay', {}); }; this.close = function (cb) { cb(); }; this.subscribe = function (eventId, cb) { eventNotifier.subscribe(eventId, cb); }; this.unsubscribe = function (eventId, cb) { eventNotifier.unsubscribe(eventId, cb); }; this.hasLocalUnsyncedOps = function () { return unsyncedClientOpSpecQueue.length !== 0; }; this.hasSessionHostConnection = function () { return hasSessionHostConnection; }; function init() { sendClientOpspecsTask = core.Task.createTimeoutTask(function () { if (!sendClientOpspecsLock) { sendClientOpspecs(); } }, sendClientOpspecsDelay); socket.on('replay', function (data) { receiveServerOpspecs(data.head, data.ops); socket.on('new_ops', function (data) { receiveServerOpspecs(data.head, data.ops); }); }); } init(); }; var ClientAdaptor = function (documentId, documentURL, authToken, connectedCb, kickedCb, disconnectedCb) { var self = this, memberId, socket; this.getMemberId = function () { return memberId; }; this.getGenesisUrl = function () { return documentURL; }; this.createOperationRouter = function (odfContainer, errorCb) { runtime.assert(Boolean(memberId), 'You must be connected to a session before creating an operation router'); return new OperationRouter(socket, odfContainer, errorCb); }; this.joinSession = function (cb) { socket.on('join_success', function handleJoinSuccess(data) { socket.removeListener('join_success', handleJoinSuccess); memberId = data.memberId; cb(memberId); }); socket.emit('join', { documentId: documentId }); }; this.leaveSession = function (cb) { socket.emit('leave', {}, cb); socket.removeAllListeners(); }; this.getSocket = function () { return socket; }; this.destroy = function () { socket.disconnect(); }; function init() { - socket = io('', { + socket = io({ query: 'token=' + authToken, forceNew: true }); socket.on('connect', connectedCb); socket.on('kick', kickedCb); socket.on('disconnect', disconnectedCb); } init(); }; return ClientAdaptor; }); diff --git a/client/components/wodo/editor.controller.js b/client/components/wodo/editor.controller.js index a16b851..cb0fa72 100644 --- a/client/components/wodo/editor.controller.js +++ b/client/components/wodo/editor.controller.js @@ -1,84 +1,85 @@ 'use strict'; /*global Wodo*/ angular.module('manticoreApp') .controller('WodoCtrl', function ($scope, Auth, Adaptor) { var editorInstance, clientAdaptor, editorOptions = { - collabEditingEnabled: true + collabEditingEnabled: true, + allFeaturesEnabled: true }, onConnectCalled = false; - function closeEditing(cb) { + function closeEditing() { editorInstance.leaveSession(function () { clientAdaptor.leaveSession(function () { console.log('Closed editing, left session.'); - cb(); }); }); } function handleEditingError(error) { alert('Something went wrong!\n' + error); console.log(error); closeEditing(); } function openEditor() { Wodo.createCollabTextEditor('wodoContainer', editorOptions, function (err, editor) { editorInstance = editor; editorInstance.addEventListener(Wodo.EVENT_UNKNOWNERROR, handleEditingError); editorInstance.joinSession(clientAdaptor, function () {}); }); } function boot() { clientAdaptor = new Adaptor( - $scope.document.id, + $scope.document._id, '/api/documents/snapshot/' + _.last($scope.document.chunks), Auth.getToken(), function onConnect() { console.log('onConnect'); if (onConnectCalled) { console.log('Reconnecting not yet supported'); return; } onConnectCalled = true; clientAdaptor.joinSession(function (memberId) { if (!memberId) { console.log('Could not join; memberId not received'); } else { console.log('Joined with memberId ' + memberId); openEditor(); } }); }, function onKick() { console.log('onKick'); closeEditing(); }, function onDisconnect() { console.log('onDisconnect'); } ); } function destroy (cb) { if (editorInstance) { - closeEditing(cb); + closeEditing(); + editorInstance.destroy(cb); } else { if (clientAdaptor) { clientAdaptor.leaveSession(); clientAdaptor.destroy(); cb(); } } } this.boot = boot; this.destroy = destroy; }); diff --git a/client/components/wodo/editor.directive.js b/client/components/wodo/editor.directive.js index 54ce0d3..6fa60d7 100644 --- a/client/components/wodo/editor.directive.js +++ b/client/components/wodo/editor.directive.js @@ -1,95 +1,101 @@ 'use strict'; angular.module('manticoreApp') .directive('wodoEditor', function () { return { restrict: 'E', templateUrl: 'components/wodo/editor.html', controller: 'WodoCtrl', controllerAs: 'wodoCtrl', link: function (scope) { var wodoCtrl = scope.wodoCtrl; var usedLocale = 'C'; var wodoPrefix = '/bower_components/wodo/wodo'; var head = document.getElementsByTagName('head')[0], frag = document.createDocumentFragment(); if (navigator && navigator.language.match(/^(de)/)) { usedLocale = navigator.language.substr(0, 2); } window.dojoConfig = { locale: usedLocale, paths: { 'webodf/editor': wodoPrefix, 'dijit': wodoPrefix + '/dijit', 'dojox': wodoPrefix + '/dojox', 'dojo': wodoPrefix + '/dojo', 'resources': wodoPrefix + '/resources' } }; function loadDependencies(callback) { var link, script; // append two link and two script elements to the header link = document.createElement('link'); link.rel = 'stylesheet'; link.href = wodoPrefix + '/app/resources/app.css'; link.type = 'text/css'; link.async = false; frag.appendChild(link); link = document.createElement('link'); link.rel = 'stylesheet'; link.href = wodoPrefix + '/wodocollabpane.css'; link.type = 'text/css'; link.async = false; frag.appendChild(link); + link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = wodoPrefix + '/wodotexteditor.css'; + link.type = 'text/css'; + link.async = false; + frag.appendChild(link); script = document.createElement('script'); script.src = wodoPrefix + '/dojo-amalgamation.js'; script['data-dojo-config'] = 'async: true'; script.charset = 'utf-8'; script.type = 'text/javascript'; script.async = false; frag.appendChild(script); script = document.createElement('script'); script.src = wodoPrefix + '/webodf.js'; script.charset = 'utf-8'; script.type = 'text/javascript'; script.async = false; frag.appendChild(script); script = document.createElement('script'); script.src = wodoPrefix + '/wodocollabtexteditor.js'; script.charset = 'utf-8'; script.type = 'text/javascript'; script.async = false; script.onload = callback; frag.appendChild(script); _.each(frag.children, function (el) { el.setAttribute('wodo', true); }); head.appendChild(frag); } function cleanUp() { wodoCtrl.destroy(function () { _.each(head.children, function (el) { if (el && el.hasAttribute('wodo')) { head.removeChild(el); } }); }); } loadDependencies(function () { wodoCtrl.boot(); }); scope.$on('$destroy', cleanUp); } }; }); diff --git a/client/components/wodo/editor.styl b/client/components/wodo/editor.styl new file mode 100644 index 0000000..0b52e7e --- /dev/null +++ b/client/components/wodo/editor.styl @@ -0,0 +1,26 @@ +// +// Wodo/Bootstrap overrides +// + +// Hide member list view +.wodo .webodfeditor-editor + width 100% !important + height 100% !important + border none !important + +.wodo .webodfeditor-members + display none + +.wodo .dijitToolbar + background-image none !important + background-color #f8f8f8 !important + +// Resolve bootstrap conflicts and hide cursors' avatar handles +.wodo .webodf-caretOverlay + .caret + border-right none + border-top none + margin none + height inherit !important + .handle + display none !important diff --git a/package.json b/package.json index 9142a8a..69afda9 100644 --- a/package.json +++ b/package.json @@ -1,94 +1,95 @@ { "name": "manticore", "version": "0.0.0", "main": "server/app.js", "dependencies": { "express": "~4.0.0", "morgan": "~1.0.0", "body-parser": "~1.5.0", "method-override": "~1.0.0", "serve-favicon": "~2.0.1", "cookie-parser": "~1.0.1", "express-session": "~1.0.2", "errorhandler": "~1.0.0", "compression": "~1.0.1", "lodash": "~2.4.1", "jade": "~1.2.0", "mongoose": "~3.8.8", "gridfs-stream": "1.1.1", "jsonwebtoken": "^0.3.0", "express-jwt": "^0.1.3", "passport": "~0.2.0", "passport-local": "~0.1.6", "composable-middleware": "^0.3.0", "connect-mongo": "^0.4.1", "multer": "0.1.8", "socket.io": "1.3.5", - "socketio-jwt": "4.2.0" + "socketio-jwt": "4.2.0", + "async": "~1.3.0" }, "devDependencies": { "grunt": "~0.4.4", "grunt-autoprefixer": "~0.7.2", "grunt-wiredep": "~1.8.0", "grunt-concurrent": "~0.5.0", "grunt-contrib-clean": "~0.5.0", "grunt-contrib-concat": "~0.4.0", "grunt-contrib-copy": "~0.5.0", "grunt-contrib-cssmin": "~0.9.0", "grunt-contrib-htmlmin": "~0.2.0", "grunt-contrib-imagemin": "~0.7.1", "grunt-contrib-jshint": "~0.10.0", "grunt-contrib-uglify": "~0.4.0", "grunt-contrib-watch": "~0.6.1", "grunt-contrib-jade": "^0.11.0", "grunt-google-cdn": "~0.4.0", "grunt-newer": "~0.7.0", "grunt-ng-annotate": "^0.2.3", "grunt-rev": "~0.1.0", "grunt-svgmin": "~0.4.0", "grunt-usemin": "~2.1.1", "grunt-env": "~0.4.1", "grunt-node-inspector": "~0.1.5", "grunt-nodemon": "~0.2.0", "grunt-angular-templates": "^0.5.4", "grunt-dom-munger": "^3.4.0", "grunt-protractor-runner": "^1.1.0", "grunt-asset-injector": "^0.1.0", "grunt-karma": "~0.8.2", "grunt-build-control": "DaftMonk/grunt-build-control", "grunt-mocha-test": "~0.10.2", "grunt-contrib-stylus": "latest", "jit-grunt": "^0.5.0", "time-grunt": "~0.3.1", "grunt-express-server": "~0.4.17", "grunt-open": "~0.2.3", "open": "~0.0.4", "jshint-stylish": "~0.1.5", "connect-livereload": "~0.4.0", "karma-ng-scenario": "~0.1.0", "karma-firefox-launcher": "~0.1.3", "karma-script-launcher": "~0.1.0", "karma-html2js-preprocessor": "~0.1.0", "karma-ng-jade2js-preprocessor": "^0.1.2", "karma-jasmine": "~0.1.5", "karma-chrome-launcher": "~0.1.3", "requirejs": "~2.1.11", "karma-requirejs": "~0.2.1", "karma-coffee-preprocessor": "~0.2.1", "karma-jade-preprocessor": "0.0.11", "karma-phantomjs-launcher": "~0.1.4", "karma": "~0.12.9", "karma-ng-html2js-preprocessor": "~0.1.0", "supertest": "~0.11.0", "should": "~3.3.1" }, "engines": { "node": ">=0.10.0" }, "scripts": { "start": "node server/app.js", "test": "grunt test", "update-webdriver": "node node_modules/grunt-protractor-runner/node_modules/protractor/bin/webdriver-manager update" }, "private": true } diff --git a/server/app.js b/server/app.js index 4580da9..95dc9cf 100644 --- a/server/app.js +++ b/server/app.js @@ -1,36 +1,43 @@ /** * Main application file */ 'use strict'; // Set default node environment to development process.env.NODE_ENV = process.env.NODE_ENV || 'development'; var express = require('express'); var mongoose = require('mongoose'); var config = require('./config/environment'); +var Adaptor = require('./components/adaptor'); +var ObjectCache = require('./components/objectcache'); // Connect to database mongoose.connect(config.mongo.uri, config.mongo.options); // Populate DB with sample data if(config.seedDB) { require('./config/seed'); } // Setup server var app = express(); var server = require('http').createServer(app); var socketio = require('socket.io')(server, { path: '/socket.io' }); +var adaptor; +var objectCache; + require('./config/socketio')(socketio); require('./config/express')(app); require('./routes')(app); // Start server server.listen(config.port, config.ip, function () { console.log('Express server listening on %d, in %s mode', config.port, app.get('env')); + objectCache = new ObjectCache(); + adaptor = new Adaptor(socketio, objectCache); }); // Expose app exports = module.exports = app; diff --git a/server/auth/auth.service.js b/server/auth/auth.service.js index 38ec343..1f37ddb 100644 --- a/server/auth/auth.service.js +++ b/server/auth/auth.service.js @@ -1,76 +1,76 @@ 'use strict'; var mongoose = require('mongoose'); var passport = require('passport'); var config = require('../config/environment'); var jwt = require('jsonwebtoken'); var expressJwt = require('express-jwt'); var compose = require('composable-middleware'); var User = require('../api/user/user.model'); var validateJwt = expressJwt({ secret: config.secrets.session }); /** * Attaches the user object to the request if authenticated * Otherwise returns 403 */ function isAuthenticated() { return compose() // Validate jwt .use(function(req, res, next) { // allow access_token to be passed through query parameter as well if(req.query && req.query.hasOwnProperty('access_token')) { req.headers.authorization = 'Bearer ' + req.query.access_token; } validateJwt(req, res, next); }) // Attach user to request .use(function(req, res, next) { User.findById(req.user._id, function (err, user) { if (err) return next(err); if (!user) return res.send(401); req.user = user; next(); }); }); } /** * Checks if the user role meets the minimum requirements of the route */ function hasRole(roleRequired) { if (!roleRequired) throw new Error('Required role needs to be set'); return compose() .use(isAuthenticated()) .use(function meetsRequirements(req, res, next) { if (config.userRoles.indexOf(req.user.role) >= config.userRoles.indexOf(roleRequired)) { next(); } else { res.send(403); } }); } /** * Returns a jwt token signed by the app secret */ function signToken(id) { return jwt.sign({ _id: id }, config.secrets.session, { expiresInMinutes: 60*5 }); } /** * Set token cookie directly for oAuth strategies */ function setTokenCookie(req, res) { if (!req.user) return res.json(404, { message: 'Something went wrong, please try again.'}); var token = signToken(req.user._id, req.user.role); res.cookie('token', JSON.stringify(token)); res.redirect('/'); } exports.isAuthenticated = isAuthenticated; exports.hasRole = hasRole; exports.signToken = signToken; -exports.setTokenCookie = setTokenCookie; \ No newline at end of file +exports.setTokenCookie = setTokenCookie; diff --git a/server/components/adaptor/index.js b/server/components/adaptor/index.js new file mode 100644 index 0000000..88a0866 --- /dev/null +++ b/server/components/adaptor/index.js @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2015 KO GmbH + * + * @licstart + * This file is part of Kotype. + * + * Kotype is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License (GNU AGPL) + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * Kotype 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 Kotype. If not, see . + * @licend + * + * @source: https://github.com/kogmbh/Kotype/ + */ +"use strict"; + +/*jslint nomen: true, unparam: true */ +/*global require, console, setInterval, module */ +var async = require("async"), + Room = require("./room"), + Document = require("../../api/document/document.model").Document, + User = require("../../api/user/user.model"); + +// Maintains an in-memory cache of users, documents, and sessions. +// And writes/reads them from the DB on demand. +var ServerAdaptor = function (socketServer, objectCache) { + var rooms = {}; + + function addToRoom(documentId, socket) { + var room = rooms[documentId]; + + if (!room) { + Document.findById(documentId, function (err, doc) { + if (err) { return console.log(err); } + if (!doc) { return console.log("documentId unknown:"+documentId); } + + room = new Room(doc, objectCache, function () { + rooms[documentId] = room; + room.attachSocket(socket); + }); + }); + } else { + room.attachSocket(socket); + } + } + + this.destroy = function (callback) { + async.each(Object.keys(rooms), function (documentId, cb) { + rooms[documentId].destroy(cb); + }, function () { + rooms = {}; + callback() + }); + }; + + function init() { + socketServer.on("connection", function (socket) { + User.findById(socket.decoded_token._id, function (err, user) { + if (err) { return console.log(err); } + socket.user = user; + socket.on("join", function (data) { + var documentId = data.documentId; + if (documentId) { + console.log("Authorized user " + user.name + " for document " + documentId); + addToRoom(documentId, socket); + } else { + console.log("Error: Client did not specify a document ID"); + } + }); + }); + }); + } + + init(); +}; +module.exports = ServerAdaptor; diff --git a/server/components/adaptor/room.js b/server/components/adaptor/room.js new file mode 100644 index 0000000..67ab019 --- /dev/null +++ b/server/components/adaptor/room.js @@ -0,0 +1,361 @@ +/* + * Copyright (C) 2015 KO GmbH + * + * @licstart + * This file is part of Kotype. + * + * Kotype is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License (GNU AGPL) + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * Kotype 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 Kotype. If not, see . + * @licend + * + * @source: https://github.com/kogmbh/Kotype/ + */ + +var async = require("async"); +var _ = require("lodash"); +var DocumentChunk = require("../../api/document/document.model").DocumentChunk; + +var Room = function (doc, objectCache, cb) { + var document, + chunk, + hasCursor = {}, + sockets = [], + serverSeq = 0; + + function trackTitle(ops) { + var newTitle, i; + + for (i = 0; i < ops.length; i += 1) { + if (ops[i].optype === "UpdateMetadata" && ops[i].setProperties["dc:title"] !== undefined) { + newTitle = ops[i].setProperties["dc:title"]; + } + } + + if (newTitle !== undefined) { + if (newTitle.length === 0) { + newTitle = "Untitled Document"; + } + } + + if (newTitle) { + document.title = newTitle; + } + } + + function trackEditors() { + // TODO: rather track by ops, to decouple from socket implementation + sockets.forEach(function (socket) { + var _id = socket.user._id; + if (document.editors.indexOf(_id) === -1) { + document.editors.push(_id); + } + }); + } + + function trackCursors(ops) { + var i; + + for (i = 0; i < ops.length; i += 1) { + if (ops[i].optype === "AddCursor") { + hasCursor[ops[i].memberid] = true; + } + if (ops[i].optype === "RemoveCursor") { + hasCursor[ops[i].memberid] = false; + } + } + } + + function sanitizeDocument() { + var ops = chunk.operations, + unbalancedCursors = {}, + unbalancedMembers = {}, + lastAccessDate = document.date, + newOps = [], + i; + + for (i = 0; i < ops.length; i += 1) { + if (ops[i].optype === "AddCursor") { + unbalancedCursors[ops[i].memberid] = true; + } else if (ops[i].optype === "RemoveCursor") { + unbalancedCursors[ops[i].memberid] = false; + } else if (ops[i].optype === "AddMember") { + unbalancedMembers[ops[i].memberid] = true; + } else if (ops[i].optype === "RemoveMember") { + unbalancedMembers[ops[i].memberid] = false; + } + } + + Object.keys(unbalancedCursors).forEach(function (memberId) { + if (unbalancedCursors[memberId]) { + newOps.push({ + optype: "RemoveCursor", + memberid: memberId, + timestamp: lastAccessDate + }); + } + }); + + Object.keys(unbalancedMembers).forEach(function (memberId) { + if (unbalancedMembers[memberId]) { + newOps.push({ + optype: "RemoveMember", + memberid: memberId, + timestamp: lastAccessDate + }); + } + }); + + if (newOps.length) { + // Update op stack + chunk.operations = chunk.operations.concat(newOps); + serverSeq = chunk.operations.length; + } + } + + function broadcastMessage(message, data) { + sockets.forEach(function (peerSocket) { + peerSocket.emit(message, data) + }); + } + + function sendOpsToMember(socket, ops) { + socket.emit("new_ops", { + head: serverSeq, + ops: ops + }); + } + + function replayOpsToMember(socket) { + socket.emit("replay", { + head: serverSeq, + ops: chunk.operations + }); + } + + function broadcastOpsByMember(socket, ops) { + if (!ops.length) { + return; + } + sockets.forEach(function (peerSocket) { + if (peerSocket !== socket) { + sendOpsToMember(peerSocket, ops); + } + }); + } + + function writeOpsToDocument(ops, cb) { + if (!ops.length) { + cb(); + } + + trackTitle(ops); + trackEditors(); + + // Update op stack + chunk.operations = chunk.operations.concat(ops); + serverSeq = chunk.operations.length; + + // Update modified date + document.date = new Date(); + + cb(); + } + + function addMember(user, cb) { + var memberId, + op, + timestamp = Date.now(); + + memberId = user.name + "_" + timestamp.toString(); + + op = { + optype: "AddMember", + memberid: memberId, + timestamp: timestamp, + setProperties: { + fullName: user.name, + color: "red" + } + }; + writeOpsToDocument([op], function () { + cb(memberId, [op]); + }); + } + + function removeMember(memberId, cb) { + var ops = [], + timestamp = Date.now(); + + if (hasCursor[memberId]) { + ops.push({ + optype: "RemoveCursor", + memberid: memberId, + timestamp: timestamp + }); + } + ops.push({ + optype: "RemoveMember", + memberid: memberId, + timestamp: timestamp + }); + writeOpsToDocument(ops, function () { + cb(ops); + }); + } + + function getOpsAfter(basedOn) { + return chunk.operations.slice(basedOn, serverSeq); + } + + this.socketCount = function () { + return sockets.length; + }; + + this.attachSocket = function (socket) { + // Add the socket to the room and give the + // client it's unique memberId + addMember(socket.user, function (memberId, ops) { + socket.memberId = memberId; + sockets.push(socket); + + broadcastOpsByMember(socket, ops); + + socket.emit("join_success", { + memberId: memberId + }); + // Service replay requests + socket.on("replay", function () { + replayOpsToMember(socket); + }); + // Store, analyze, and broadcast incoming commits + socket.on("commit_ops", function (data, cb) { + var clientSeq = data.head, + ops = data.ops; + if (clientSeq === serverSeq) { + writeOpsToDocument(ops, function () { + cb({ + conflict: false, + head: serverSeq + }); + trackCursors(ops); + broadcastOpsByMember(socket, data.ops); + }); + } else { + cb({ + conflict: true + }); + } + }); + + // Service various requests + socket.on("access_get", function (data, cb) { + cb({ + access: document.isPublic ? "public" : "normal" + }); + }); + + if (socket.user.identity !== "guest") { + socket.on("access_change", function (data) { + document.isPublic = data.access === "public"; + broadcastMessage("access_changed", { + access: data.access === "public" ? "public" : "normal" + }); + if (data.access !== "public") { + sockets.forEach(function (peerSocket) { + if (peerSocket.user.identity === "guest") { + console.log(peerSocket.user.name); + removeSocket(peerSocket); + } + }); + } + }); + } + + // Handle dropout events + socket.on("leave", function () { + removeSocket(socket); + }); + socket.on("disconnect", function () { + removeSocket(socket); + }); + }); + }; + + function detachSocket(socket, callback) { + removeMember(socket.memberId, function (ops) { + broadcastOpsByMember(socket, ops); + + socket.removeAllListeners(); + + function lastCB() { + socket.removeAllListeners(); + if (callback) { + callback(); + } + } + // If a socket that is already connected is being + // removed, this means that this is a deliberate + // kicking-out, and not a natural event that could + // result in a reconnection later. Therefore, clean + // up. + if (socket.connected) { + console.log(socket.user.name + " is connected, removing"); + socket.on('disconnect', lastCB); + socket.emit("kick"); + socket.emit("disconnect"); + } else { + console.log(socket.user.name + " is not connected, removing"); + lastCB(); + } + }); + } + + function removeSocket(socket) { + var index = sockets.indexOf(socket); + + detachSocket(socket); + + if (index !== -1) { + sockets.splice(index, 1); + } + } + + this.getDocument = function () { + return document; + }; + + this.destroy = function (callback) { + async.each(sockets, function (socket, cb) { + detachSocket(socket, cb); + }, function () { + sockets.length = 0; + callback(); + }); + }; + + function init() { + // Setup caching + document = objectCache.getTrackedObject(doc); + DocumentChunk.findById(_.last(document.chunks), function (err, lastChunk) { + chunk = objectCache.getTrackedObject(lastChunk); + console.log(chunk); + // Sanitize leftovers from previous session, if any + sanitizeDocument(); + cb(); + }); + } + + init(); +}; + +module.exports = Room; diff --git a/server/components/objectcache/index.js b/server/components/objectcache/index.js new file mode 100644 index 0000000..a9a484a --- /dev/null +++ b/server/components/objectcache/index.js @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2015 KO GmbH + * + * @licstart + * This file is part of Kotype. + * + * Kotype is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License (GNU AGPL) + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * Kotype 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 Kotype. If not, see . + * @licend + * + * @source: https://github.com/kogmbh/Kotype/ + */ + +/*jslint nomen: true, unparam: true */ +/*global require, console, setInterval, module */ +var mongoose = require("mongoose"), + async = require("async"), + ObjectCache; + +// Maintains an in-memory cache of objects from mongoose collections, +// and writes them to the DB periodically. + +var ObjectCache = function () { + "use strict"; + + var objects = {}, + timer, + writeInterval = 1000 * 5; + + function isTracked(object) { + return objects.hasOwnProperty(object._id); + } + this.isTracked = isTracked; + + function getTrackedObject(object) { + var id = object._id; + + if (!objects[id]) { + objects[id] = object; + } + + return objects[id]; + } + this.getTrackedObject = getTrackedObject; + + function forgetTrackedObject(object) { + var id = object._id; + + if (objects.hasOwnProperty(id)) { + delete objects[id]; + } + } + this.forgetTrackedObject = forgetTrackedObject; + + function saveObjects(callback) { + async.each(Object.keys(objects), function (id, cb) { + if (objects[id].isModified()) { + objects[id].save(cb); + } else { + cb(); + } + }, callback); + } + + this.destroy = function (callback) { + clearInterval(timer); + saveObjects(callback); + }; + + function init() { + timer = setInterval(function () { + saveObjects(); + }, writeInterval); + } + + init(); +}; + +module.exports = ObjectCache; diff --git a/server/config/socketio.js b/server/config/socketio.js index ad1d3b7..4de6d59 100644 --- a/server/config/socketio.js +++ b/server/config/socketio.js @@ -1,23 +1,23 @@ /** * Socket.io configuration */ 'use strict'; var config = require('./environment'); module.exports = function (socketio) { // socket.io (v1.x.x) is powered by debug. // In order to see all the debug output, set DEBUG (in server/config/local.env.js) to including the desired scope. // // ex: DEBUG: "http*,socket.io:socket" // We can authenticate socket.io users and access their token through socket.handshake.decoded_token // // You will need to send the token in `client/components/socket/socket.service.js` // socketio.use(require('socketio-jwt').authorize({ secret: config.secrets.session, handshake: true - })); + })); };