diff --git a/README-roundcube.md b/README-roundcube.md new file mode 100644 index 0000000..048c87a --- /dev/null +++ b/README-roundcube.md @@ -0,0 +1,143 @@ +# Integrating into Kolab + +## Configuration + +Set the environment variables thusly: + +1. Set `AUTH` to `"ldap"` +2. Set `STORAGE` to `"chwala"` +3. Set `CHWALA_SERVER` to Chwala's file API endpoint for `GET`ing and `PUT`ing documents. +4. Change `LDAP_` entries to match the Kolab Configuration + +## Server API + +### Auth + +`POST /auth/local` with the payload + +```json +{ + "email":"some@user.com", + "password":"their_password" +} +``` + +will give you this response: + +```json +{ + "token": "" +} +``` + +Make all further requests with the header + +`Authorization: Bearer `. + +### Documents + +1. `GET /api/documents/` returns an array of documents that have been loaded in Manticore and have been created/edited by the requesting user. +2. `GET /api/documents/:id` returns a document with the given ID. +3. `POST /api/documents/` is a request containing three fields: `id`, `title` which can be the filename, and `access` which is an _array_ of access control entries as shown in the example document. Manticore will fetch the actual file from Chwala using the provided `id`. Normally you should initialize the access array with an entry containing the creator of the session even if no one else is collaborating. +4. `PUT /api/documents/:id` lets you overwrite a document with a new one, while keeping the ID. This request can have an empty body, Manticore will take care of fetching the file like above. +5. `GET /api/documents/:id/access` returns the access control array for a document. +5. `PUT /api/documents/:id/access` lets you update the access policy. +5. `DELETE /api/documents/:uuid` deletes the document from Manticore and ends it's corresponding session. + +A document is of the form: +```json +{ + "_id": "", + "title": "Project Report", + "created": "2015-10-16T14:30:43.651Z", + "date": "", + "creator": { + "name": "Administrator", + "email": "admin@admin.com", + }, + "editors": [{ + "name": "Administrator", + "email": "admin@admin.com", + }], + "access": [{ + "identity": "admin@admin.com", + "permission": "write" + }, { + "identity": "test@user.com", + "permission": "read" + }, { + "identity": "another@user.com", + "permission": "deny" + }], + "live": true +} +``` +An access array is of the form: +```json +"access": [{ + "identity": "admin@admin.com", + "permission": "write" +}, { + "identity": "test@user.com", + "permission": "read" +}, { + "identity": "another@user.com", + "permission": "deny" +}], +``` + +##Client API + +### Open + +When a logged-in roundcube uesr tries to open a file, Chwala should immediately equip the Manticore auth token for that user, and open up an `iframe` pointed to this location: + +`http://manticore_server:port/document/:document_id/:auth_token` + +which will contain a ready-to-use collaborative session. + +### Control + +When embedded in Roundcube, Manticore does not draw it's own toolbar, but provides a cross-iframe API instead that lets you do the same things that the Manticore toolbar can. + +For controlling Manticore from Roundcube's js, the `postMessage` API's usage can be demonstrated with this example: + +```js +var domain = "http://manticore_server:port", + manticore = iframeElement.contentWindow; + +manticore.postMessage({ + id: 1234, + name: "actionExport", format: "pdf" +}, domain); + +window.addEventListener("message", function (event) { + console.log(event.data); + /* Looks like + * { id: 1234, successful: true } + */ +}); +``` +Since `postMessage` was not designed for remote function calls, the `id` property is useful for knowing which outgoing message (here, `"actionExport"`) the incoming message is a response to. Roundcube can trivially add a callback abstraction on top of this. + +#### Methods + +The following methods are available, prefixed with `get`, `set`, and `action`: + +| Method | Response | +|--------|----------| +|`{name: "getTitle"}`|`{value: "Current Title"}`| +|`{name: "setTitle", value: "New Title"}`|`{successful: true}`| +|`{name: "getExportFormats"}`|`{value: [{ format: "odt", label: "OpenDocument Text (.odt)" }]}`| +|`{name: "actionExport", value: "pdf"}`|`{successful: true}`| +|`{name: "actionSave"}`|`{successful: true}`| +|`{name: "getMembers"}`|`{value: [ {memberId: "user_234", fullName: "John Doe", color: "#ffee00", email: "john@doe.org"} ]}`| + +#### Events + +The following events are available, suffixed with `Event`. They come with no `id` field. + +1. `{name: "ready"}` fires when all iframe API methods are ready for use. +2. `{name: "titleChanged", value: "New Title"}` +3. `{name: "memberAdded", memberId: "user_234", fullName: "John Doe", color: "#ffee00", email: "john@doe.org"}` +4. `{name: "memberRemoved", memberId: "user_234"}` diff --git a/README.md b/README.md index 5fd4fdb..08b6f6f 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,36 @@ -_This is totally not ready yet. Not even pre-alpha. Only boilerplate scaffolding. Don't look!_ - # Manticore Realtime collaboration for rich office documents. ## Setup 1. Install [MongoDB](https://www.mongodb.org/) via your package manager. 2. `npm install -g bower grunt-cli phantomjs` 3. Get server dependencies: `npm install` 4. Get client dependencies: `bower install` +Optionally, run a [locodoc](https://github.com/adityab/locodoc) server for the ability to export to other document formats (`pdf`, `doc`, `docx`, `txt`). + ## Run ### For frontend development `grunt serve` Runs the node server which listens at `localhost:9000`. This doesn't gracefully shut down the server due to [grunt-express-server](https://github.com/ericclemmons/grunt-express-server) eating the relevant signals. So you'll need to manually kill the node process, till there is a better solution. ### For backend development `node server/app.js` Does the same as the `grunt serve`, except that you don't get live-reloading when you change some code. Gracefully persists objects and disconnects clients and the DB when you `SIGTERM` or `SIGINT` it (just do Ctrl+C). ## Configure All environment variables are stored in `server/config/local.env.js`. Since it is unwise to checkin sensitive information into version control, this file is blacklisted in `.gitignore`. To get your own usable version, copy the existing `local.env.sample.js` under `server/config` to the aforementioned file, and then make the necessary modifications for your deployment. ## Develop Install [AngularJS Batarang](https://chrome.google.com/webstore/detail/angularjs-batarang/ighdmehidhipcmcojjgiloacoafjmpfk) from the Chrome extensions store. This will let you inspect live Angular scopes, among other things. diff --git a/client/app/editor/editor.jade b/client/app/editor/editor.jade index 7392f51..2696356 100644 --- a/client/app/editor/editor.jade +++ b/client/app/editor/editor.jade @@ -1,9 +1,9 @@ -div.toolbar +div.toolbar(ng-attr-embedded='{{$root.config.embedderHost !== undefined}}') div.toolbar-item.title title-editor div.toolbar-item export-button div.toolbar-item save-button -wodo-editor.wodo +wodo-editor.wodo(ng-attr-embedded='{{$root.config.embedderHost !== undefined}}') diff --git a/client/app/editor/editor.js b/client/app/editor/editor.js index c6a67b6..1c46f4e 100644 --- a/client/app/editor/editor.js +++ b/client/app/editor/editor.js @@ -1,41 +1,48 @@ 'use strict'; angular.module('manticoreApp') .config(function ($stateProvider) { $stateProvider .state('manticore.editor', { abstract: true, url: '/document', reload: true, template: '' }) .state('manticore.editor.forDocument', { - url: '/:id', + url: '/:id/:authToken', resolve: { + user: function ($stateParams, $state, Auth) { + if ($stateParams.authToken === 'new') { + $state.go('manticore.editor.fromTemplate', { id: $stateParams.id }); + } else if ($stateParams.authToken) { + return Auth.login($stateParams.authToken); + } + }, socketio: function (angularLoad) { return angularLoad.loadScript('socket.io/socket.io.js'); }, - document: function ($stateParams, $http) { + document: function ($stateParams, user, $http) { 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; } }) .state('manticore.editor.fromTemplate', { url: '/:id/new', resolve: { document: function ($stateParams, $state, $http) { return $http.get('/api/documents/fromTemplate/' + $stateParams.id) .then(function (response) { $state.go('manticore.editor.forDocument', { id: response.data._id }, { location: 'replace' }); }); } } }); }); diff --git a/client/app/editor/editor.styl b/client/app/editor/editor.styl index f85ee8e..685ec3b 100644 --- a/client/app/editor/editor.styl +++ b/client/app/editor/editor.styl @@ -1,17 +1,25 @@ .toolbar border none + border-bottom 1px solid lightgray background-color white height 50px +.toolbar[embedded=true] + display none .toolbar-item display inline-block height 50px float left -#wodoContainer - width 100% - height calc(100% - 50px) - top 50px - padding 0 - position absolute - background-color white +.wodo + #wodoContainer + width 100% + height calc(100% - 50px) + top 50px + padding 0 + position absolute + background-color white + &[embedded=true] + #wodoContainer + height: 100% + top: 0 diff --git a/client/components/auth/auth.service.js b/client/components/auth/auth.service.js index 54ef5e3..bf37c47 100644 --- a/client/components/auth/auth.service.js +++ b/client/components/auth/auth.service.js @@ -1,146 +1,157 @@ 'use strict'; angular.module('manticoreApp') .factory('Auth', function Auth($location, $rootScope, $http, User, $cookieStore, $q) { var currentUser = {}; if($cookieStore.get('token')) { currentUser = User.get(); } return { /** * Authenticate user and save token * - * @param {Object} user - login info - * @param {Function} callback - optional + * @param {Object} credentials - login info. Either token string, or email/password object + * @param {Function} callback - optional * @return {Promise} */ - login: function(user, callback) { + login: function(credentials, callback) { var cb = callback || angular.noop; var deferred = $q.defer(); + var request; - $http.post('/auth/local', { - email: user.email, - password: user.password - }). + if (typeof credentials === 'string') { // User is a token string + request = $http.get('/api/users/me', { + headers: { + 'Authorization': 'Bearer ' + credentials, + } + }); + } else { + request = $http.post('/auth/local', { + email: credentials.email, + password: credentials.password + }); + } + + request. success(function(data) { - $cookieStore.put('token', data.token); + $cookieStore.put('token', data.token || credentials); currentUser = User.get(); deferred.resolve(data); return cb(); }). error(function(err) { this.logout(); deferred.reject(err); return cb(err); }.bind(this)); return deferred.promise; }, /** * Delete access token and user info * * @param {Function} */ logout: function() { $cookieStore.remove('token'); currentUser = {}; }, /** * Create a new user * * @param {Object} user - user info * @param {Function} callback - optional * @return {Promise} */ createUser: function(user, callback) { var cb = callback || angular.noop; return User.save(user, function(data) { $cookieStore.put('token', data.token); currentUser = User.get(); return cb(user); }, function(err) { this.logout(); return cb(err); }.bind(this)).$promise; }, /** * Change password * * @param {String} oldPassword * @param {String} newPassword * @param {Function} callback - optional * @return {Promise} */ changePassword: function(oldPassword, newPassword, callback) { var cb = callback || angular.noop; return User.changePassword({ id: currentUser._id }, { oldPassword: oldPassword, newPassword: newPassword }, function(user) { return cb(user); }, function(err) { return cb(err); }).$promise; }, /** * Gets all available info on authenticated user * * @return {Object} user */ getCurrentUser: function() { return currentUser; }, /** * Check if a user is logged in * * @return {Boolean} */ isLoggedIn: function() { return currentUser.hasOwnProperty('role'); }, /** * Waits for currentUser to resolve before checking if user is logged in */ isLoggedInAsync: function(cb) { if(currentUser.hasOwnProperty('$promise')) { currentUser.$promise.then(function() { cb(true); }).catch(function() { cb(false); }); } else if(currentUser.hasOwnProperty('role')) { cb(true); } else { cb(false); } }, /** * Check if a user is an admin * * @return {Boolean} */ isAdmin: function() { return currentUser.role === 'admin'; }, /** * Get auth token */ getToken: function() { return $cookieStore.get('token'); } }; }); diff --git a/client/components/exportButton/exportButton.controller.js b/client/components/exportButton/exportButton.controller.js index c55a0f5..284b9a2 100644 --- a/client/components/exportButton/exportButton.controller.js +++ b/client/components/exportButton/exportButton.controller.js @@ -1,73 +1,107 @@ 'use strict'; angular.module('manticoreApp') .controller('ExportButtonCtrl', function ($scope, $http, $timeout, SaveAs) { $scope.label = 'Download'; $scope.isExporting = false; $scope.conversionHost = $scope.$root.config.conversionHost; $scope.items = [ { format: 'odt', label: 'OpenDocument Text (.odt)' }, ]; if ($scope.conversionHost) { $scope.items = $scope.items.concat([ { format: 'pdf', label: 'Portable Document Format (.pdf)' }, { format: 'txt', label: 'Plain Text (.txt)' }, { format: 'docx', label: 'Microsoft Word (.docx)' }, { format: 'doc', label: 'Microsoft Word, old (.doc)' }, { format: 'html', label: 'HTML (.html)' }, ]); } - $scope.export = function (format) { + /** + * @param {!string} format + * @param {Function=} cb Optional callback that provides success value (true/false) + */ + $scope.export = function (format, cb) { $scope.label = 'Downloading...'; $scope.isExporting = true; var title = $scope.editor.getMetadata('dc:title') || $scope.document.title, fileName = title.replace(/\.[^.$]+$/, ''); $scope.editor.getDocumentAsByteArray(function (err, data) { if (format === 'odt') { SaveAs.download( [data.buffer], fileName + '.odt', { type: 'application/vnd.oasis.opendocument.text' } ); $scope.label = 'Download'; $scope.isExporting = false; + if (cb) { cb(true); } } else { var formData = new FormData(); formData.append('document', new Blob([data.buffer], { type: 'application/vnd.oasis.opendocument.text' })); $http({ method: 'POST', url: $scope.conversionHost + '/convert/' + format, data: formData, responseType: 'arraybuffer', transformRequest: angular.identity, transformResponse: angular.identity, headers: { 'Content-Type': undefined } }) .success(function (data, status, headers) { SaveAs.download( [data], fileName + '.' + format, { type: headers('content-type') } ); $timeout(function () { $scope.label = 'Download'; $scope.isExporting = false; + if (cb) { cb(true); } }); }) .error(function () { $timeout(function () { $scope.label = 'Error while downloading'; + if (cb) { cb(false); } }); $timeout(function () { $scope.label = 'Download'; $scope.isExporting = false; }, 1000); }); } }); }; + + function iframeGetExportFormats(event) { + event.source.postMessage({ + id: event.data.id, + value: angular.copy($scope.items) + }, event.origin); + } + + function iframeActionExport(event) { + $scope.export(event.data.value, function (successful) { + event.source.postMessage({ + id: event.data.id, + successful: successful + }, event.origin); + }); + } + + $scope.$watch('joined', function (online) { + if (online === undefined) { return; } + if (online) { + $scope.editor.addIframeEventListener('getExportFormats', iframeGetExportFormats); + $scope.editor.addIframeEventListener('actionExport', iframeActionExport); + } else { + $scope.editor.removeIframeEventListener('getExportFormats', iframeGetExportFormats); + $scope.editor.removeIframeEventListener('actionExport', iframeActionExport); + } + }); }); diff --git a/client/components/saveButton/saveButton.controller.js b/client/components/saveButton/saveButton.controller.js index 8ffcbfd..55defce 100644 --- a/client/components/saveButton/saveButton.controller.js +++ b/client/components/saveButton/saveButton.controller.js @@ -1,28 +1,51 @@ 'use strict'; angular.module('manticoreApp') .controller('SaveButtonCtrl', function ($scope, $timeout) { $scope.label = 'Save'; $scope.isSaving = false; - $scope.save = function () { + /** + * @param {Function=} cb Optional callback that provides success value (true/false) + */ + $scope.save = function (cb) { $scope.label = 'Saving'; $scope.isSaving = true; var socket = $scope.editor.clientAdaptor.getSocket(); socket.emit('save', function (err) { $timeout(function () { if (err) { $scope.label = 'Error while saving'; + if (cb) { cb(false); } } else { $scope.label = 'Saved just now'; + if (cb) { cb(true); } } }); $timeout(function () { $scope.label = 'Save'; $scope.isSaving = false; }, 1000); }); }; + + function iframeActionSave(event) { + $scope.save(function (successful) { + event.source.postMessage({ + id: event.data.id, + successful: successful + }, event.origin); + }); + } + + $scope.$watch('joined', function (online) { + if (online === undefined) { return; } + if (online) { + $scope.editor.addIframeEventListener('actionSave', iframeActionSave); + } else { + $scope.editor.removeIframeEventListener('actionSave', iframeActionSave); + } + }); }); diff --git a/client/components/titleEditor/titleEditor.controller.js b/client/components/titleEditor/titleEditor.controller.js index 8613d70..45fe214 100644 --- a/client/components/titleEditor/titleEditor.controller.js +++ b/client/components/titleEditor/titleEditor.controller.js @@ -1,44 +1,71 @@ 'use strict'; /*global Wodo*/ angular.module('manticoreApp') .controller('TitleEditorCtrl', function ($scope, $timeout) { function handleTitleChanged(changes) { var title = changes.setProperties['dc:title']; if (title !== undefined && title !== $scope.title) { $timeout(function () { $scope.title = title; + $scope.editor.broadcastIframeEvent({ + name: 'titleChanged', + value: title + }); }); } } $scope.changeTitle = function () { if ($scope.title !== $scope.editor.getMetadata('dc:title')) { $scope.editor.setMetadata({ 'dc:title': $scope.title }); } }; $scope.handleEnterKey = function ($event) { if ($event.keyCode === 13) { $event.target.blur(); } }; + function iframeGetTitle(event) { + event.source.postMessage({ + id: event.data.id, + value: $scope.title + }, event.origin); + } + + function iframeSetTitle(event) { + $scope.title = event.data.value; + $scope.changeTitle(); + $timeout(function () { + event.source.postMessage({ + id: event.data.id, + successful: true + }, event.origin); + }); + } + $scope.$watch('joined', function (online) { if (online === undefined) { return; } if (online) { $scope.editor.addEventListener(Wodo.EVENT_METADATACHANGED, handleTitleChanged); + $scope.editor.addIframeEventListener('getTitle', iframeGetTitle); + $scope.editor.addIframeEventListener('setTitle', iframeSetTitle); } else { $scope.editor.removeEventListener(Wodo.EVENT_METADATACHANGED, handleTitleChanged); + $scope.editor.removeIframeEventListener('getTitle', iframeGetTitle); + $scope.editor.removeIframeEventListener('setTitle', iframeSetTitle); + } }); function init() { $scope.title = $scope.document.title; } init(); }); diff --git a/client/components/wodo/adaptor.service.js b/client/components/wodo/adaptor.service.js index 9dc207f..127f345 100644 --- a/client/components/wodo/adaptor.service.js +++ b/client/components/wodo/adaptor.service.js @@ -1,298 +1,329 @@ /*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; + /**@const*/sendClientOpspecsDelay = 300, + + members = []; 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 { + var spec = op.spec(); + if (spec.optype === 'AddMember') { + var data = { + memberId: spec.memberid, + fullName: spec.setProperties.fullName, + email: spec.setProperties.email, + color: spec.setProperties.color + }; + members.push(data); + eventNotifier.emit(EVENT_MEMBERADDED, data); + } else if (spec.optype === 'RemoveMember') { + _.remove(members, function (member) { + return member.memberId === spec.memberid; + }); + eventNotifier.emit(EVENT_MEMBERREMOVED, { + memberId: spec.memberid + }); + } } } 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; }; + this.getMembers = function () { + return members; + }; + function init() { var replayed = false, followupHead, followupOps = []; sendClientOpspecsTask = core.Task.createTimeoutTask(function () { if (!sendClientOpspecsLock) { sendClientOpspecs(); } }, sendClientOpspecsDelay); socket.on('replay', function (data) { receiveServerOpspecs(data.head, data.ops); replayed = true; if (followupHead && followupHead > data.head) { receiveServerOpspecs(followupHead, followupOps); } }); socket.on('new_ops', function (data) { if (replayed) { receiveServerOpspecs(data.head, data.ops); } else { followupHead = data.head; followupOps = followupOps.concat(data.ops); } }); } init(); }; var ClientAdaptor = function (documentId, authToken, connectedCb, kickedCb, disconnectedCb) { var self = this, memberId, genesisUrl, - socket; + socket, + router; this.getMemberId = function () { return memberId; }; this.getGenesisUrl = function () { return genesisUrl; }; 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); + router = new OperationRouter(socket, odfContainer, errorCb); + return router; + }; + + this.getOperationRouter = function () { + return router; }; this.joinSession = function (cb) { socket.on('join_success', function handleJoinSuccess(data) { socket.removeListener('join_success', handleJoinSuccess); memberId = data.memberId; genesisUrl = data.genesisUrl; cb(memberId, data.permission); }); 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({ 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 42c0d0e..9560f0e 100644 --- a/client/components/wodo/editor.controller.js +++ b/client/components/wodo/editor.controller.js @@ -1,101 +1,195 @@ 'use strict'; /*global Wodo*/ angular.module('manticoreApp') .controller('WodoCtrl', function ($scope, Auth, Adaptor) { var editorInstance, + operationRouter, clientAdaptor, editorOptions = { collabEditingEnabled: true, unstableFeaturesEnabled: true, imageEditingEnabled: false, hyperlinkEditingEnabled: false }, - onConnectCalled = false; + onConnectCalled = false, + listeners = {}, + allowedOrigin; + + function addIframeEventListener(name, callback) { + if (!listeners[name]) { + listeners[name] = []; + } + listeners[name].push(callback); + } + + function removeIframeEventListener(name, callback) { + if (!listeners[name]) { + return; + } + + var index = listeners[name].indexOf(callback); + if (index !== -1) { + listeners[name].splice(index, 1); + } + } + + function broadcastIframeEvent(data) { + if (allowedOrigin) { + window.parent.postMessage(data, allowedOrigin); + } + } + + function setupCrossWindowMessaging() { + var embedderHost = $scope.$root.config.embedderHost; + if (embedderHost) { + var temp = document.createElement('a'); + temp.href = embedderHost; + allowedOrigin = temp.protocol + '//' + temp.host; + + window.addEventListener('message', function (event) { + if (event.origin !== allowedOrigin || !event.data.name) { + return; + } + + console.log('Received message from Embedder: ' + event.data.name); + + var subscribers = listeners[event.data.name]; + if (subscribers && subscribers.length) { + for(var i = 0; i < subscribers.length; i += 1) { + subscribers[i](event); + } + } + }); + } + } + + function setupMemberAPI() { + function handleMemberAdded(data) { + $scope.editor.broadcastIframeEvent(_.merge({ name: 'memberAdded' }, data)); + } + + function handleMemberRemoved(data) { + $scope.editor.broadcastIframeEvent(_.merge({ name: 'memberRemoved' }, data)); + } + + function getMembers(event) { + event.source.postMessage({ + id: event.data.id, + value: operationRouter.getMembers() + }, event.origin); + } + + $scope.$watch('joined', function (online) { + if (online === undefined) { return; } + if (online) { + operationRouter.subscribe('memberAdded', handleMemberAdded); + operationRouter.subscribe('memberRemoved', handleMemberRemoved); + $scope.editor.addIframeEventListener('getMembers', getMembers); + } else { + operationRouter.unsubscribe('memberAdded', handleMemberAdded); + operationRouter.unsubscribe('memberRemoved', handleMemberRemoved); + $scope.editor.removeIframeEventListener('getMembers', getMembers); + } + }); + } function closeEditing() { editorInstance.leaveSession(function () { $scope.$apply(function () { $scope.joined = false; }); clientAdaptor.leaveSession(function () { console.log('Closed editing, left session.'); }); }); } function handleEditingError(error) { alert('Something went wrong!\n' + error); console.log(error); closeEditing(); } function openEditor(permission) { + setupCrossWindowMessaging(); + if (permission === 'write') { editorOptions.allFeaturesEnabled = true; editorOptions.reviewModeEnabled = false; } else { editorOptions.reviewModeEnabled = true; } Wodo.createCollabTextEditor('wodoContainer', editorOptions, function (err, editor) { editorInstance = editor; $scope.editor = editor; + + $scope.editor.addIframeEventListener = addIframeEventListener; + $scope.editor.removeIframeEventListener = removeIframeEventListener; + $scope.editor.broadcastIframeEvent = broadcastIframeEvent; + editorInstance.clientAdaptor = clientAdaptor; editorInstance.addEventListener(Wodo.EVENT_UNKNOWNERROR, handleEditingError); editorInstance.joinSession(clientAdaptor, function () { + operationRouter = clientAdaptor.getOperationRouter(); + setupMemberAPI(); + $scope.$apply(function () { $scope.joined = true; }); + $scope.editor.broadcastIframeEvent({ + name: 'ready' + }); }); }); } function boot() { clientAdaptor = new Adaptor( $scope.document._id, Auth.getToken(), function onConnect() { console.log('onConnect'); if (onConnectCalled) { console.log('Reconnecting not yet supported'); return; } onConnectCalled = true; clientAdaptor.joinSession(function (memberId, permission) { if (!memberId) { console.log('Could not join; memberId not received'); } else { console.log('Joined with memberId ' + memberId); openEditor(permission); } }); }, function onKick() { console.log('onKick'); closeEditing(); }, function onDisconnect() { console.log('onDisconnect'); } ); } function destroy (cb) { if (editorInstance) { 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.styl b/client/components/wodo/editor.styl index be79528..8b032ed 100644 --- a/client/components/wodo/editor.styl +++ b/client/components/wodo/editor.styl @@ -1,36 +1,35 @@ // // Wodo/Bootstrap overrides // // Hide member list view .webodfeditor-editor width 100% !important height 100% !important border none !important .webodfeditor-members display none .dijitToolbar background-image none !important background-color #f8f8f8 !important - border-top: 1px solid lightgray; // Resolve bootstrap conflicts and hide cursors' avatar handles .webodf-caretOverlay .caret border-right none border-top none margin none height inherit !important .handle display none !important .webodf-selectionOverlay stroke initial !important .editInfoMarker width 3px !important border-radius 0 !important box-shadow none !important outline 1px solid black diff --git a/server/components/adaptor/room.js b/server/components/adaptor/room.js index f8ecc42..74a32cc 100644 --- a/server/components/adaptor/room.js +++ b/server/components/adaptor/room.js @@ -1,479 +1,480 @@ /* * 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 RColor = require('../colors'); var DocumentChunk = require("../../api/document/document.model").DocumentChunk; var DocumentController = require("../../api/document/document.controller"); var Recorder = require('./recorder'); var Room = function (app, document, objectCache, cb) { var ChunkManager = function (seedChunk) { var serverSeq, chunks = []; function getOperationsAfter(seq) { var ops = []; for (var i = chunks.length - 1; i >= 0; i--) { if (chunks[i].sequence >= seq) { ops = chunks[i].operations.concat(ops); } else { var basedOn = seq - chunks[i].sequence; ops = chunks[i].operations.slice(basedOn).concat(ops); break; } } return ops; } this.getOperationsAfter = getOperationsAfter; function appendOperations(ops) { var lastChunk = getLastChunk(); lastChunk.operations = lastChunk.operations.concat(ops); serverSeq += ops.length; } this.appendOperations = appendOperations; function appendChunk(chunk) { var trackedChunk = objectCache.getTrackedObject(chunk); chunks.push(trackedChunk); serverSeq = trackedChunk.sequence + trackedChunk.operations.length; } this.appendChunk = appendChunk; function getLastChunk() { return _.last(chunks); } this.getLastChunk = getLastChunk; this.getServerSequence = function () { return serverSeq; }; appendChunk(seedChunk); }; var chunkManager, recorder, hasCursor = {}, sockets = [], userColorMap = {}, randomColor = new RColor(), saveInProgress = false, isAvailable = false; 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; } } } // Removes all cursors and members in the correct order within the last chunk function sanitizeDocument() { var chunk = chunkManager.getLastChunk(), ops = chunk.snapshot.operations.concat(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 chunkManager.appendOperations(newOps); } } function broadcastMessage(message, data) { sockets.forEach(function (peerSocket) { peerSocket.emit(message, data) }); } function sendOpsToMember(socket, ops) { socket.emit("new_ops", { head: chunkManager.getServerSequence(), ops: ops }); } function setupMemberSnapshot(socket, snapshot) { socket.emit("replay", { head: chunkManager.getServerSequence(), ops: snapshot.operations.concat(chunkManager.getOperationsAfter(snapshot.sequence)) }); } function broadcastOpsByMember(socket, ops) { if (!ops.length) { return; } sockets.forEach(function (peerSocket) { if (peerSocket.memberId !== socket.memberId) { sendOpsToMember(peerSocket, ops); } }); } function writeOpsToDocument(ops, cb) { if (!ops.length || !document.live) { cb(); } recorder.push(ops, function () { trackTitle(ops); trackEditors(); // Update op stack chunkManager.appendOperations(ops); // Update modified date document.date = new Date(); cb(); }); } function addMember(user, cb) { var memberId, op, timestamp = Date.now(), color = userColorMap[user._id]; memberId = user.name + "_" + timestamp.toString(); // Let user colors persist in a Room even after they've // left and joined. if (!color) { userColorMap[user._id] = color = randomColor.get(true, 0.7); } op = { optype: "AddMember", memberid: memberId, timestamp: timestamp, setProperties: { fullName: user.name, + email: user.email, color: color } }; 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); }); } 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); // Generate genesis URL with the latest document version's snapshot // We use a two-time URL because WebODF makes two identical GET requests (!) var genesisUrl = '/genesis/' + Date.now().toString(), usages = 0; recorder.getSnapshot(function (snapshot) { var buffer = new Buffer(snapshot.data); app.get(genesisUrl, function (req, res) { usages++; res.set('Content-Type', 'application/vnd.oasis.opendocument.text'); res.attachment(document.title); res.send(buffer); var routes = app._router.stack; if (usages === 2) { buffer = null; for (var i = 0; i < routes.length; i++) { if (routes[i].path === genesisUrl) { routes.splice(i, 1); break; } } } }); socket.emit("join_success", { memberId: memberId, genesisUrl: genesisUrl, permission: document.getAccessType(socket.user.email) }); // Service replay requests socket.on("replay", function () { setupMemberSnapshot(socket, snapshot); }); // Store, analyze, and broadcast incoming commits socket.on("commit_ops", function (data, cb) { var clientSeq = data.head, ops = data.ops; if (clientSeq === chunkManager.getServerSequence()) { writeOpsToDocument(ops, function () { cb({ conflict: false, head: chunkManager.getServerSequence() }); trackCursors(ops); broadcastOpsByMember(socket, data.ops); }); } else { cb({ conflict: true }); } }); // Service save requests. A save is a commit + socket.on("save", function (cb) { // Saves are blocking inside the phantomjs process, and they affect everyone, // therefore use a lock. if (saveInProgress) { var checkIfSaved = setInterval(function () { if (!saveInProgress) { clearInterval(checkIfSaved); cb(); } }, 1000); } else { saveInProgress = true; recorder.getSnapshot(function (snapshot) { DocumentController.createChunkFromSnapshot(document, snapshot, function (err, chunk) { saveInProgress = false; if (err) { return cb(err); } chunkManager.appendChunk(chunk); cb(); }); }); } }); // 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.isAvailable = function () { return isAvailable; }; this.destroy = function (callback) { async.each(sockets, function (socket, cb) { detachSocket(socket, cb); }, function () { //objectCache.forgetTrackedObject(chunk); document.live = false; sockets.length = 0; recorder.destroy(callback); }); }; function init() { // Setup caching DocumentChunk.findById(_.last(document.chunks), function (err, lastChunk) { chunkManager = new ChunkManager(lastChunk); // Sanitize leftovers from previous session, if any sanitizeDocument(); recorder = new Recorder(chunkManager.getLastChunk(), function () { isAvailable = true; cb(); }); }); } init(); }; module.exports = Room; diff --git a/server/config/environment/index.js b/server/config/environment/index.js index 9c277a1..e8f2cb2 100644 --- a/server/config/environment/index.js +++ b/server/config/environment/index.js @@ -1,80 +1,84 @@ 'use strict'; var path = require('path'); var _ = require('lodash'); function requiredProcessEnv(name) { if(!process.env[name]) { throw new Error('You must set the ' + name + ' environment variable'); } return process.env[name]; } // All configurations will extend these options // ============================================ var all = { env: process.env.NODE_ENV, // Root path of server root: path.normalize(__dirname + '/../../..'), // Server port port: process.env.PORT || 9000, // Should we populate the DB with sample data? seedDB: true, // Secret for session, you will want to change this and make it an environment variable secrets: { session: 'manticore-secret' }, // List of user roles userRoles: ['guest', 'user', 'admin'], // MongoDB connection options mongo: { options: { db: { safe: true } } }, defaultAccess: process.env.DEFAULT_ACCESS, - conversionHost: process.env.LOCODOC_SERVER, + + client: { + conversionHost: process.env.LOCODOC_SERVER, + embedderHost: (process.env.STORAGE === 'chwala' && process.env.ROUNDCUBE_SERVER) || undefined + }, auth: { type: process.env.AUTH || 'local', 'webdav': { server: process.env.WEBDAV_SERVER, path: process.env.WEBDAV_PATH, key: process.env.AUTH_ENCRYPTION_KEY }, 'ldap': { server: process.env.LDAP_SERVER, base: process.env.LDAP_BASE, filter: process.env.LDAP_FILTER, bindDn: process.env.LDAP_BIND_DN, bindPw: process.env.LDAP_BIND_PW, key: process.env.AUTH_ENCRYPTION_KEY } }, storage: { type: process.env.STORAGE || 'local', 'webdav': { server: process.env.WEBDAV_SERVER, path: process.env.WEBDAV_PATH, key: process.env.AUTH_ENCRYPTION_KEY }, 'chwala': { server: process.env.CHWALA_SERVER } } }; // Export the config object based on the NODE_ENV // ============================================== module.exports = _.merge( all, require('./' + process.env.NODE_ENV + '.js') || {}); diff --git a/server/config/local.env.sample.js b/server/config/local.env.sample.js index e78d7b3..17adeb1 100644 --- a/server/config/local.env.sample.js +++ b/server/config/local.env.sample.js @@ -1,67 +1,73 @@ 'use strict'; // Use local.env.js for environment variables that grunt will set when the server starts locally. // Use for your api keys, secrets, etc. This file should not be tracked by git. // // You will need to set these on the server you deploy to. module.exports = { DOMAIN: 'http://localhost:9000', SESSION_SECRET: 'manticore-secret', // Control debug level for modules using visionmedia/debug DEBUG: '', /* * Default access permissions for documents. * If a user has a link to a session and tries to open it, this represents the * access type they have if no permission has been explicitly set for them. * Possible values: 'write', 'read', 'deny'. * By default, is set to 'write' for testing purposes. * If completely outsourcing access control to a third party service (like Kolab), set it to 'deny'. * If left blank, defaults to 'deny'. */ - DEFAULT_ACCESS: 'deny', + DEFAULT_ACCESS: 'allow', /* * Supported authentication strategies. * 1. 'local' for using Manticore's built-in accounts system. Allow signups. * 2. 'webdav' for authenticating against a WebDAV server. Only login, no signups. * 3. 'ldap' for authenticating against an LDAP service. Only login, no signups. */ AUTH: 'local', /* * Supported storage backends. * 1. 'local' for storing everything in Mongo/GridFS. The fastest and most reliable way. * Can be used with any AUTH strategy. * 2. 'webdav' for two-way synchronizing of documents with a WebDAV server. * Can be used if AUTH is 'ldap' or 'webdav'; those credentials are used to talk to the storage server. * 3. 'chwala' can be used for integrating with Kolab. */ STORAGE: 'local', /* * WebDAV server config, only if AUTH or STORAGE is 'webdav'. */ WEBDAV_SERVER: 'https://demo.owncloud.org', WEBDAV_PATH: '/remote.php/webdav', + /* + * When using Chwala storage, it is expected that Manticore will be embedded within Roundcube, + * so make sure you provide the host for the Roundcube server. This is intended for safe + * cross-origin communication. + */ CHWALA_SERVER: 'http://172.17.0.12', + ROUNDCUBE_SERVER: 'http://172.17.0.12', /* * Make sure you provide an encryption key to protect users' auth credentials. * This is necessary because the storage server may not support authentication tokens. */ AUTH_ENCRYPTION_KEY: 'suchauth123muchkey456', // LDAP server config, only if AUTH is 'ldap' LDAP_SERVER: 'ldap://172.17.0.12', LDAP_BASE: 'ou=People,dc=example,dc=org', LDAP_FILTER: '(&(objectclass=person)(|(uid={{username}})(mail={{username}})))', LDAP_BIND_DN: 'uid=binderservice,ou=Special Users,dc=example,dc=org', LDAP_BIND_PW: 'binderpass', // locodoc Server config LOCODOC_SERVER: 'http://localhost:3030' }; diff --git a/server/routes.js b/server/routes.js index c4c3470..c9503f6 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1,35 +1,34 @@ /** * Main application routes */ 'use strict'; var errors = require('./components/errors'); var config = require('./config/environment'); module.exports = function(app) { // Insert routes below app.use('/api/templates', require('./api/template')); app.use('/api/documents', require('./api/document')); app.use('/api/users', require('./api/user')); app.use('/auth', require('./auth')); app.get('/config', function (req, res) { - res.json(200, { - conversionHost: config.conversionHost - }); + var configObject; + res.json(200, config.client); }); // All undefined asset or api routes should return a 404 app.route('/:url(api|auth|components|app|bower_components|assets)/*') .get(errors[404]); // All other routes (except dynamically-added genesis routes) // should redirect to the index.html app.route(/^\/(?!genesis).*/) .get(function(req, res) { res.sendfile(app.get('appPath') + '/index.html'); }); };