diff --git a/README-roundcube.md b/README-roundcube.md new file mode 100644 index 0000000..6f2dfdf --- /dev/null +++ b/README-roundcube.md @@ -0,0 +1,144 @@ +# 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"}` +5. `{name: "documentChanged" }` is useful for managing the state of a "save" button. diff --git a/README.md b/README.md index 47d1f00..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` +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/import/import.controller.js b/client/components/import/import.controller.js index f031ec9..42b78b4 100644 --- a/client/components/import/import.controller.js +++ b/client/components/import/import.controller.js @@ -1,36 +1,36 @@ 'use strict'; angular.module('manticoreApp') .controller('ImportCtrl', function ($scope, $rootScope, FileUploader, Auth) { var uploader = new FileUploader({ - url: '/api/documents/upload', + url: '/api/documents/', headers: { 'Authorization': 'Bearer ' + Auth.getToken() }, removeAfterUpload: false, autoUpload: true, onCompleteAll: function () { // Wait a little before firing this event, as the upload may not // be accessible from MongoDB immediately window.setTimeout(function () { $rootScope.$broadcast('documentsUploaded'); uploader.clearQueue(); }, 1000); } }); uploader.filters.push({ name: 'sizeFilter', fn: function (item) { return item.size <= 10485760; // 10 Megabytes } }); uploader.filters.push({ name: 'typeFilter', fn: function (item) { return item.type === 'application/vnd.oasis.opendocument.text'; } }); $scope.uploader = uploader; }); diff --git a/client/components/saveButton/saveButton.controller.js b/client/components/saveButton/saveButton.controller.js index 8ffcbfd..3c16b2b 100644 --- a/client/components/saveButton/saveButton.controller.js +++ b/client/components/saveButton/saveButton.controller.js @@ -1,28 +1,62 @@ 'use strict'; angular.module('manticoreApp') .controller('SaveButtonCtrl', function ($scope, $timeout) { $scope.label = 'Save'; $scope.isSaving = false; + $scope.isModified = 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'; + $scope.isModified = false; + 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); + }); + } + + function handleDocumentChanged() { + $timeout(function () { + $scope.isModified = true; + $scope.editor.broadcastIframeEvent({ name: 'documentChanged' }); + }); + } + + $scope.$watch('joined', function (online) { + if (online === undefined) { return; } + if (online) { + $scope.editor.addIframeEventListener('actionSave', iframeActionSave); + $scope.operationRouter.subscribe('documentChanged', handleDocumentChanged); + } else { + $scope.editor.removeIframeEventListener('actionSave', iframeActionSave); + $scope.operationRouter.unsubscribe('documentChanged', handleDocumentChanged); + } + }); }); diff --git a/client/components/saveButton/saveButton.jade b/client/components/saveButton/saveButton.jade index d4c3aa6..ec800f2 100644 --- a/client/components/saveButton/saveButton.jade +++ b/client/components/saveButton/saveButton.jade @@ -1,4 +1,4 @@ div.save-button - button.btn.btn-primary(type='button' ng-click='save()' ng-disabled='isSaving') + button.btn.btn-primary(type='button' ng-click='save()' ng-disabled='isSaving || !isModified') i.fa(ng-class='isSaving ? "fa-spin fa-circle-o-notch": "fa-cloud-upload"') | {{label}} 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 63c5bcb..1ea0a82 100644 --- a/client/components/wodo/adaptor.service.js +++ b/client/components/wodo/adaptor.service.js @@ -1,298 +1,335 @@ /*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', + EVENT_DOCUMENTCHANGED = 'documentChanged', eventNotifier = new core.EventNotifier([ EVENT_BEFORESAVETOFILE, EVENT_SAVEDTOFILE, EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, EVENT_HASSESSIONHOSTCONNECTIONCHANGED, EVENT_MEMBERADDED, EVENT_MEMBERCHANGED, EVENT_MEMBERREMOVED, + EVENT_DOCUMENTCHANGED = 'documentChanged', 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 + }); + } + + if (op.isEdit) { + eventNotifier.emit(EVENT_DOCUMENTCHANGED); + } } } 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); + 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 9b90b1a..1e90882 100644 --- a/client/components/wodo/editor.controller.js +++ b/client/components/wodo/editor.controller.js @@ -1,92 +1,197 @@ 'use strict'; /*global Wodo*/ angular.module('manticoreApp') .controller('WodoCtrl', function ($scope, Auth, Adaptor) { var editorInstance, + operationRouter, clientAdaptor, editorOptions = { collabEditingEnabled: true, - allFeaturesEnabled: 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 setupIframeAPI() { + 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) { + // Members + 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() { + 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(); + $scope.operationRouter = operationRouter; + setupIframeAPI(); + $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) { + clientAdaptor.joinSession(function (memberId, permission) { if (!memberId) { console.log('Could not join; memberId not received'); } else { console.log('Joined with memberId ' + memberId); - openEditor(); + 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/package.json b/package.json index 0cc58af..309705b 100644 --- a/package.json +++ b/package.json @@ -1,100 +1,100 @@ { "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", + "mongoose": "~4.1.12", "gridfs-stream": "1.1.1", - "jsonwebtoken": "^0.3.0", - "express-jwt": "^0.1.3", + "jsonwebtoken": "~5.4.0", + "express-jwt": "~3.1.0", "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.3.2", "async": "~1.3.0", "phantom": "~0.7.2", "dav": "~1.7.6", "request": "~2.60.0", "ldapjs": "mcavage/node-ldapjs", "passport-ldapauth": "0.3.0" }, "devDependencies": { - "grunt": "~0.4.4", + "grunt": "~0.4.5", "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-filerev": "~2.2.0", "grunt-svgmin": "~0.4.0", "grunt-usemin": "~3.0.0", "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/api/document/document.controller.js b/server/api/document/document.controller.js index 45b003b..eafda75 100644 --- a/server/api/document/document.controller.js +++ b/server/api/document/document.controller.js @@ -1,89 +1,110 @@ /** * Using Rails-like standard naming convention for endpoints. * GET /documents -> index * POST /documents -> create * GET /documents/:id -> show * PUT /documents/:id -> update * DELETE /documents/:id -> destroy */ 'use strict'; var _ = require('lodash'); var mongoose = require('mongoose'); var Grid = require('gridfs-stream'); var multer = require('multer'); var storage = require('./storage'); var DocumentChunk = require('./document.model').DocumentChunk; var Document = require('./document.model').Document; var Template = require('../template/template.model'); var gfs = Grid(mongoose.connection.db, mongoose.mongo); // Get list of documents exports.index = storage.index; // Get a single document exports.show = storage.show; // Middleware for handling file uploads exports.upload = storage.upload; exports.acknowledgeUpload = function (req, res) { - return res.send(200); + return res.send(201); }; exports.showSnapshot = function(req, res) { var snapshotId = req.params.id; gfs.findOne({_id: snapshotId}, function (err, file) { if (err) { return handleError(res, err); } if (!file) { return res.send(404); } var download = gfs.createReadStream({ _id: snapshotId }); download.on('error', function (err) { return handleError(res, err); }); res.set('Content-Type', file.contentType); res.attachment(file.filename) download.pipe(res); }); }; exports.createFromTemplate = storage.createFromTemplate; exports.createChunkFromSnapshot = storage.createChunkFromSnapshot; -// Updates an existing document in the DB. -exports.update = function(req, res) { - if(req.body._id) { delete req.body._id; } +exports.overwrite = storage.overwrite || function (req, res) { return res.send(405); }; + +exports.getAccess = function (req, res) { Document.findById(req.params.id, function (err, document) { if (err) { return handleError(res, err); } if(!document) { return res.send(404); } - var updated = _.merge(document, req.body); - updated.save(function (err) { - if (err) { return handleError(res, err); } - return res.json(200, document); - }); + var objectCache = req.app.get('objectCache'), + access = (objectCache.isTracked(document) ? + objectCache.getTrackedObject(document).access : document.access); + + return res.json(200, access); + }); +} + +// Updates an existing access list in the DB. +exports.updateAccess = function(req, res) { + Document.findById(req.params.id, function (err, document) { + if (err) { return handleError(res, err); } + if(!document) { return res.send(404); } + var objectCache = req.app.get('objectCache'); + if (objectCache.isTracked(document)) { + var trackedDoc = objectCache.getTrackedObject(document); + trackedDoc.access = req.body; + trackedDoc.markModified('access'); + return res.send(200); + } else { + document.access = req.body; + document.save(function (err) { + if (err) { return handleError(res, err); } + return res.send(200); + }); + } }); }; // Deletes a document from the DB. exports.destroy = function(req, res) { Document.findById(req.params.id, function (err, document) { if(err) { return handleError(res, err); } if(!document) { return res.send(404); } document.remove(function(err) { if(err) { return handleError(res, err); } return res.send(204); }); }); }; function handleError(res, err) { return res.send(500, err); } diff --git a/server/api/document/document.model.js b/server/api/document/document.model.js index 197ca07..762ed4c 100644 --- a/server/api/document/document.model.js +++ b/server/api/document/document.model.js @@ -1,39 +1,62 @@ 'use strict'; -var mongoose = require('mongoose'), +var _ = require('lodash'), + mongoose = require('mongoose'), Schema = mongoose.Schema; -var storageTypes = ['webdav']; +var config = require('../../config/environment'); + +var storageTypes = ['webdav', 'chwala']; /* * Each DocumentChunk has an associated ODF snapshot file within * GridFS, a list of operations required to bring the snapshot into * a workable initial state for the chunk, and a list of operations * that signifies the edit history after the aforementioned document * state */ var DocumentChunk = new Schema({ sequence: { type: Number, default: 0 }, snapshot: { fileId: { type: Schema.Types.ObjectId, required: true }, operations: { type: Array, default: [] } }, operations: { type: Array, default: [] } }); var DocumentSchema = new Schema({ + _id: { type: Schema.Types.String, default: mongoose.Types.ObjectId, unique: true }, title: String, created: { type: Date, default: Date.now, required: true }, date: { type: Date, default: Date.now }, creator: { type: Schema.Types.ObjectId, ref: 'User' }, editors: { type: [{type: Schema.Types.ObjectId, ref: 'User'}], default: [] }, chunks: { type: [{type: Schema.Types.ObjectId, ref: 'DocumentChunk'}], default: [] }, live: { type: Boolean, default: false }, provider: String, - webdav: {} + webdav: {}, + chwala: {}, + access: { type: [{ identity: String, permission: { type: String, enum: ['read', 'write', 'deny' ]}}], default: [] } }); +DocumentSchema.methods = { + /** Potentially unsafe */ + getAccessType: function (identity) { + return _.result(_.find(this.access, function (accessItem) { + return _.isEqual(accessItem.identity, identity); + }), 'permission') || config.defaultAccess || 'deny'; + } +}; + +// DocumentSchema +// .path('access') +// .validate(function(access) { +// return _.find(this.access, function (accessItem) { +// return _.includes(['read, write', 'deny']permission !== 'read' +// }); +// }, 'Invalid access control info'); + module.exports = { DocumentChunk: mongoose.model('DocumentChunk', DocumentChunk), Document: mongoose.model('Document', DocumentSchema) }; diff --git a/server/api/document/index.js b/server/api/document/index.js index 2e4c75a..2db2299 100644 --- a/server/api/document/index.js +++ b/server/api/document/index.js @@ -1,20 +1,21 @@ 'use strict'; var express = require('express'); var controller = require('./document.controller'); var auth = require('../../auth/auth.service'); var router = express.Router(); router.get('/', auth.isAuthenticated(), controller.index); router.get('/:id', auth.isAuthenticated(), controller.show); router.get('/snapshot/:id', controller.showSnapshot) router.get('/fromTemplate/:id', auth.isAuthenticated(), controller.createFromTemplate); -router.post('/upload', auth.isAuthenticated(), controller.upload, +router.post('/', auth.isAuthenticated(), controller.upload, controller.acknowledgeUpload); -router.put('/:id', auth.isAuthenticated(), controller.update); -router.patch('/:id', auth.isAuthenticated(), controller.update); +router.put('/:id', auth.isAuthenticated(), controller.overwrite); +router.get('/:id/access', auth.isAuthenticated(), controller.getAccess); +router.put('/:id/access', auth.isAuthenticated(), controller.updateAccess); router.delete('/:id', auth.isAuthenticated(), controller.destroy); module.exports = router; diff --git a/server/api/document/storage/chwala/index.js b/server/api/document/storage/chwala/index.js new file mode 100644 index 0000000..1a6af58 --- /dev/null +++ b/server/api/document/storage/chwala/index.js @@ -0,0 +1,209 @@ +'use strict'; + +var _ = require('lodash'); +var async = require('async'); +var mongoose = require('mongoose'); +var https = require('https'); +var fs = require('fs'); +var url = require('url'); +var path = require('path'); +var Grid = require('gridfs-stream'); +var multer = require('multer'); +var request = require('request'); +var querystring = require('querystring'); +var crypto = require('crypto'); + +var config = require('../../../../config/environment'); + +var User = require('../../../user/user.model'); +var Document = require('../../document.model').Document; +var DocumentChunk = require('../../document.model').DocumentChunk; +var Template = require('../../../template/template.model'); + +var gfs = Grid(mongoose.connection.db, mongoose.mongo); + +var serverUrl = config.storage.chwala.server; + +function decrypt(password) { + var decipher = crypto.createDecipher('aes-256-cbc', config.auth.ldap.key); + return decipher.update(password, 'base64', 'utf8') + decipher.final('utf8'); +} + +function downloadToGridFS(user, uuid, fileId, cb) { + var file = gfs.createWriteStream({ + _id: fileId, + filename: uuid, + mode: 'w', + chunkSize: 1024 * 4, + content_type: 'application/vnd.oasis.opendocument.text', + root: 'fs' + }); + + request.get({ + url: serverUrl + '/' + uuid, + auth: { + user: user.ldap.username, + pass: decrypt(user.ldap.password) + } + }) + .on('error', function (err) { + cb(err); + }) + .pipe(file); + + file.on('finish', cb); +} + +function uploadToServer(user, uuid, readStream, cb) { + readStream.pipe(request.put({ + url: serverUrl + '/' + uuid, + auth: { + user: user.ldap.username, + pass: decrypt(user.ldap.password) + }, + headers: { + 'Content-Type': 'application/vnd.oasis.opendocument.text' + } + }, cb)); +} + +function createFirstChunk(user, uuid, cb) { + var chunkId = new mongoose.Types.ObjectId(), + fileId = new mongoose.Types.ObjectId(); + + var firstChunk = new DocumentChunk({ + _id: chunkId, + snapshot: { + fileId: fileId + } + }); + + downloadToGridFS(user, uuid, fileId, function(err) { + if (err) { return cb(err); } + firstChunk.save(function (err) { + cb(err, firstChunk); + }); + }); +} + +exports.index = function (req, res) { + var userId = req.user._id; + + Document.find({ + '$or': [ + { 'creator': userId }, + { 'editors': { '$in': [userId] } } + ] + }) + .populate('creator', 'name email') + .populate('editors', 'name email') + .exec(function (err, documents) { + if(err) { return handleError(res, err); } + return res.json(200, documents); + }); +}; + +exports.show = function(req, res) { + Document.findById(req.params.id) + .populate('creator', 'name email') + .populate('editors', 'name email') + .exec(function (err, document) { + if(err) { return handleError(res, err); } + if(!document) { return res.send(404); } + return res.json(document); + }); +}; + +exports.createFromTemplate = function (req, res) { + return res.send(405); +}; + +exports.upload = function (req, res, next) { + var id = req.body.id, + title = req.body.title, + access = req.body.access; + + Document.findById(id, function (err, document) { + if (err) { return handleError(res, err); } + if (document) { return res.json(422, document); } + + createFirstChunk(req.user, id, function (err, firstChunk) { + if (err) { return handleError(res, err); } + Document.create({ + _id: id, + title: title, + creator: req.user._id, + chunks: [firstChunk._id], + access: access + }, function (err, document) { + if (err) { return handleError(res, err); } + next(); + }); + }); + }); +}; + +exports.overwrite = function (req, res) { + Document.findById(req.params.id, function (err, document) { + if (err) { return handleError(res, err); } + if (!document) { return res.json(404, document); } + + createFirstChunk(req.user, req.params.id, function (err, newFirstChunk) { + if (err) { return handleError(res, err); } + document.date = Date.now(); + document.chunks = [newFirstChunk] + document.markModified('chunks'); + document.save(function (err, document) { + if (err) { return handleError(res, err); } + return res.send(200); + }); + }); + }); +} + +exports.createChunkFromSnapshot = function (document, snapshot, cb) { + var chunkId = new mongoose.Types.ObjectId(), + fileId = new mongoose.Types.ObjectId(); + + var writeStream = gfs.createWriteStream({ + _id: fileId, + filename: document.title + '_' + Date.now(), + mode: 'w', + chunkSize: 1024 * 4, + content_type: 'application/vnd.oasis.opendocument.text', + root: 'fs' + }); + + writeStream.end(new Buffer(snapshot.data), function () { + User.findById(document.creator._id, function (err, user) { + if (err) { return cb(err); } + uploadToServer( + user, + document._id, + gfs.createReadStream({ _id: fileId }), + true, + function (err, response) { + if (err) { return cb(err); } + var chunk = new DocumentChunk({ + _id: chunkId, + sequence: snapshot.sequence, + snapshot: { + fileId: fileId, + operations: snapshot.operations + } + }); + chunk.save(function (err) { + if (err) { return cb(err); } + document.chunks.push(chunkId); + document.markModified('chunks'); + cb(null, chunk); + }); + }); + }); + }); +}; + +function handleError(res, err) { + console.log(err); + return res.send(500, err); +} diff --git a/server/api/user/user.model.js b/server/api/user/user.model.js index bd8a72e..c3955c4 100644 --- a/server/api/user/user.model.js +++ b/server/api/user/user.model.js @@ -1,146 +1,147 @@ 'use strict'; var mongoose = require('mongoose'); var Schema = mongoose.Schema; var crypto = require('crypto'); var authTypes = ['webdav', 'ldap']; var UserSchema = new Schema({ name: String, email: { type: String, lowercase: true }, role: { type: String, default: 'user' }, hashedPassword: String, provider: String, webdav: {}, + ldap: {}, salt: String }); /** * Virtuals */ UserSchema .virtual('password') .set(function(password) { this._password = password; this.salt = this.makeSalt(); this.hashedPassword = this.encryptPassword(password); }) .get(function() { return this._password; }); // Public profile information UserSchema .virtual('profile') .get(function() { return { 'name': this.name, 'role': this.role }; }); // Non-sensitive info we'll be putting in the token UserSchema .virtual('token') .get(function() { return { '_id': this._id, 'role': this.role }; }); /** * Validations */ // Validate empty email UserSchema .path('email') .validate(function(email) { return email.length; }, 'Email cannot be blank'); // Validate empty password UserSchema .path('hashedPassword') .validate(function(hashedPassword) { if (authTypes.indexOf(this.provider) !== -1) return true; return hashedPassword.length; }, 'Password cannot be blank'); // Validate email is not taken UserSchema .path('email') .validate(function(value, respond) { var self = this; this.constructor.findOne({email: value}, function(err, user) { if(err) throw err; if(user) { if(self.id === user.id) return respond(true); return respond(false); } respond(true); }); }, 'The specified email address is already in use.'); var validatePresenceOf = function(value) { return value && value.length; }; /** * Pre-save hook */ UserSchema .pre('save', function(next) { if (!this.isNew) return next(); if (!validatePresenceOf(this.hashedPassword) && authTypes.indexOf(this.provider) === -1) next(new Error('Invalid password')); else next(); }); /** * Methods */ UserSchema.methods = { /** * Authenticate - check if the passwords are the same * * @param {String} plainText * @return {Boolean} * @api public */ authenticate: function(plainText) { return this.encryptPassword(plainText) === this.hashedPassword; }, /** * Make salt * * @return {String} * @api public */ makeSalt: function() { return crypto.randomBytes(16).toString('base64'); }, /** * Encrypt password * * @param {String} password * @return {String} * @api public */ encryptPassword: function(password) { if (!password || !this.salt) return ''; var salt = new Buffer(this.salt, 'base64'); return crypto.pbkdf2Sync(password, salt, 10000, 64).toString('base64'); } }; module.exports = mongoose.model('User', UserSchema); diff --git a/server/auth/ldap/passport.js b/server/auth/ldap/passport.js index cd857ba..cfddbba 100644 --- a/server/auth/ldap/passport.js +++ b/server/auth/ldap/passport.js @@ -1,61 +1,65 @@ var passport = require('passport'); var LdapStrategy = require('passport-ldapauth'); var crypto = require('crypto'); exports.setup = function (User, config) { function encrypt(password) { var cipher = crypto.createCipher('aes-256-cbc', config.storage.webdav.key); return cipher.update(password, 'utf8', 'base64') + cipher.final('base64') } passport.use(new LdapStrategy({ server: { url: config.auth.ldap.server, searchBase: config.auth.ldap.base, searchFilter: config.auth.ldap.filter, bindDn: config.auth.ldap.bindDn, bindCredentials: config.auth.ldap.bindPw, searchAttributes: ['cn'] }, usernameField: 'email', passwordField: 'password', passReqToCallback: true, badRequestMessage: true, invalidCredentials: true, userNotFound: true, constraintViolation: true }, function(req, ldapUser, done) { var email = req.body.email, password = req.body.password, fullName = ldapUser.cn; User.findOne({ email: email }, function (err, user) { if (err) { return done(err); } if (!user) { var newUser = new User({ name: fullName, email: email, provider: 'ldap', role: 'user', webdav: (config.storage.type === 'webdav') ? { username: email, password: encrypt(password) + } : undefined, + ldap: (config.storage.type === 'chwala') ? { + username: email, + password: encrypt(password) } : undefined }); newUser.save(function (err, user) { if (err) { return done(err); } if (!err) { return done(null, user); } }); } else { return done(null, user); } }); } )); }; diff --git a/server/components/adaptor/index.js b/server/components/adaptor/index.js index dfb4f43..192f5ad 100644 --- a/server/components/adaptor/index.js +++ b/server/components/adaptor/index.js @@ -1,100 +1,108 @@ /* * 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 (app, socketServer, objectCache) { var rooms = {}; function addToRoom(documentId, socket) { var room = rooms[documentId]; if (!room) { Document.findById(documentId).populate('creator', 'name email') .exec(function (err, doc) { if (err) { return console.log(err); } if (!doc) { return console.log("documentId unknown:"+documentId); } + if (doc.getAccessType(socket.user.email) === 'deny') { + return console.log("Access denied."); + } + var document = objectCache.getTrackedObject(doc); document.live = true; rooms[documentId] = room = new Room(app, document, objectCache, function () { rooms[documentId] = room; room.attachSocket(socket); }); var interval = setInterval(function () { if (!document.live) { clearInterval(interval); objectCache.forgetTrackedObject(document); room.destroy(function () { delete rooms[documentId]; }); } }, 500); }); } else if (room.isAvailable()) { + var document = objectCache.getTrackedObject({ _id: documentId }); + if (document.getAccessType(socket.user.email) === 'deny') { + return console.log("Access denied."); + } room.attachSocket(socket); } else { console.log("Room currently unavailable, disconnecting client.") socket.disconnect(); } } 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); + console.log("Request by 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 index d565b08..74a32cc 100644 --- a/server/components/adaptor/room.js +++ b/server/components/adaptor/room.js @@ -1,478 +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 + 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 e25996a..e8f2cb2 100644 --- a/server/config/environment/index.js +++ b/server/config/environment/index.js @@ -1,75 +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 } } }, - conversionHost: process.env.LOCODOC_SERVER, + defaultAccess: process.env.DEFAULT_ACCESS, + + 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.WEBDAV_ENCRYPTION_KEY + 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 + 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.WEBDAV_ENCRYPTION_KEY + 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 c9a4bde..17adeb1 100644 --- a/server/config/local.env.sample.js +++ b/server/config/local.env.sample.js @@ -1,49 +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: '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 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'. - * Make sure you provide an encryption key to protect users' webdav credentials. */ - WEBDAV_SERVER: 'https://kolabmachine', - WEBDAV_PATH: '/iRony/files/Files', - WEBDAV_ENCRYPTION_KEY: 'your-AES-encryption-key', + 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, required iff AUTH is 'ldap'. {{username}} will be replaced with users' logins - LDAP_SERVER: 'ldaps://kolabmachine', - LDAP_BASE: 'ou=People,dc=test,dc=example,dc=org', + // 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=kolab-service,ou=Special Users,dc=test,dc=example,dc=org', - LDAP_BIND_PW: 'kolab-service-pass', + LDAP_BIND_DN: 'uid=binderservice,ou=Special Users,dc=example,dc=org', + LDAP_BIND_PW: 'binderpass', - // locodoc (document format conversion) server + // 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'); }); };