diff --git a/NetfluxWebsocketSrv.js b/NetfluxWebsocketSrv.js index a5e75c6cf..80778864d 100644 --- a/NetfluxWebsocketSrv.js +++ b/NetfluxWebsocketSrv.js @@ -1,5 +1,6 @@ ;(function () { 'use strict'; const Crypto = require('crypto'); +const Nacl = require('tweetnacl'); const LogStore = require('./storage/LogStore'); @@ -12,6 +13,7 @@ const USE_FILE_BACKUP_STORAGE = true; let dropUser; +let historyKeeperKeys = {}; const now = function () { return (new Date()).getTime(); }; @@ -25,6 +27,16 @@ const sendMsg = function (ctx, user, msg) { } }; +const storeMessage = function (ctx, channel, msg) { + ctx.store.message(channel.id, msg, function (err) { + if (err && typeof(err) !== 'function') { + // ignore functions because older datastores + // might pass waitFors into the callback + console.log("Error writing message: " + err); + } + }); +}; + const sendChannelMessage = function (ctx, channel, msgStruct) { msgStruct.unshift(0); channel.forEach(function (user) { @@ -33,13 +45,17 @@ const sendChannelMessage = function (ctx, channel, msgStruct) { } }); if (USE_HISTORY_KEEPER && msgStruct[2] === 'MSG') { - ctx.store.message(channel.id, JSON.stringify(msgStruct), function (err) { - if (err && typeof(err) !== 'function') { - // ignore functions because older datastores - // might pass waitFors into the callback - console.log("Error writing message: " + err); + if (historyKeeperKeys[channel.id]) { + let signedMsg = msgStruct[4].replace(/^cp\|/, ''); + signedMsg = Nacl.util.decodeBase64(signedMsg); + let validateKey = Nacl.util.decodeBase64(historyKeeperKeys[channel.id]); + let validated = Nacl.sign.open(signedMsg, validateKey); + if (!validated) { + console.log("Signed message rejected"); + return; } - }); + } + storeMessage(ctx, channel, JSON.stringify(msgStruct)); } }; @@ -68,6 +84,7 @@ dropUser = function (ctx, user) { if (chan.length === 0) { console.log("Removing empty channel ["+chanName+"]"); delete ctx.channels[chanName]; + delete historyKeeperKeys[chanName]; /* Call removeChannel if it is a function and channel removal is set to true in the config file */ @@ -94,8 +111,15 @@ dropUser = function (ctx, user) { const getHistory = function (ctx, channelName, handler, cb) { var messageBuf = []; + var messageKey; ctx.store.getMessages(channelName, function (msgStr) { - messageBuf.push(JSON.parse(msgStr)); + var parsed = JSON.parse(msgStr); + if (parsed.validateKey) { + historyKeeperKeys[channelName] = parsed.validateKey; + handler(parsed); + return; + } + messageBuf.push(parsed); }, function (err) { if (err) { console.log("Error getting messages " + err.stack); @@ -120,7 +144,7 @@ const getHistory = function (ctx, channelName, handler, cb) { // no checkpoints. for (var x = msgBuff2.pop(); x; x = msgBuff2.pop()) { handler(x); } } - cb(); + cb(messageBuf); }); }; @@ -166,7 +190,13 @@ const handleMessage = function (ctx, user, msg) { sendMsg(ctx, user, [seq, 'ACK']); getHistory(ctx, parsed[1], function (msg) { sendMsg(ctx, user, [0, HISTORY_KEEPER_ID, 'MSG', user.id, JSON.stringify(msg)]); - }, function () { + }, function (messages) { + // parsed[2] is a validation key if it exists + if (messages.length === 0 && parsed[2] && !historyKeeperKeys[parsed[1]]) { + var key = {channel: parsed[1], validateKey: parsed[2]}; + storeMessage(ctx, ctx.channels[parsed[1]], JSON.stringify(key)); + historyKeeperKeys[parsed[1]] = parsed[2]; + } sendMsg(ctx, user, [0, HISTORY_KEEPER_ID, 'MSG', user.id, 0]); }); } diff --git a/customize.dist/main.js b/customize.dist/main.js index 3e9eab3b5..d9220a8d7 100644 --- a/customize.dist/main.js +++ b/customize.dist/main.js @@ -106,9 +106,17 @@ define([ }); }); + var readOnly = false; + if (pad.href.indexOf('#') !== -1) { + var modeArray = pad.href.split('#')[1].split('/'); + if (modeArray.length >= 3 && modeArray[2] === 'view') { + readOnly = true; + } + } + var readOnlyText = readOnly ? '(' + Messages.readonly + ') ' : ''; $row .append($('').text(name)) - .append($('').append($('', { + .append($('').append(readOnlyText).append($('', { href: pad.href, title: pad.title, }).text(shortTitle))) @@ -134,6 +142,9 @@ define([ if (hasRecent) { $('table').attr('style', ''); + // Race condition here, this is triggered before the localization in HTML + // so we have to remove the data-localization attr + $tryit.removeAttr('data-localization'); $tryit.text(Messages.recentPads); } }); diff --git a/customize.dist/toolbar.css b/customize.dist/toolbar.css index 61b592eda..b03039473 100644 --- a/customize.dist/toolbar.css +++ b/customize.dist/toolbar.css @@ -83,3 +83,8 @@ .cryptpad-spinner { float: left; } +.cryptpad-readonly { + margin-right: 20px; + font-weight: bold; + text-transform: uppercase; +} \ No newline at end of file diff --git a/customize.dist/translations/messages.fr.js b/customize.dist/translations/messages.fr.js index df37b3d63..6f5fc6c8d 100644 --- a/customize.dist/translations/messages.fr.js +++ b/customize.dist/translations/messages.fr.js @@ -3,6 +3,11 @@ define(function () { out.main_title = "Cryptpad: Editeur collaboratif en temps réel, zero knowledge"; + out.type = {}; + out.type.pad = 'Pad'; + out.type.code = 'Code'; + out.type.poll = 'Sondage'; + out.type.slide = 'Présentation'; out.errorBox_errorType_disconnected = 'Connexion perdue'; out.errorBox_errorExplanation_disconnected = [ @@ -20,6 +25,12 @@ define(function () { out.synchronizing = 'Synchronisation'; out.reconnecting = 'Reconnexion...'; out.lag = 'Latence'; + out.readonly = 'Lecture seule'; + out.nobodyIsEditing = "Personne n'édite le document"; + out.onePersonIsEditing = 'Une personne édite le document'; + out.peopleAreEditing = '{0} personnes éditent le document'; + out.oneViewer = '1 lecteur'; + out.viewers = '{0} lecteurs'; out.importButton = 'IMPORTER'; out.importButtonTitle = 'Importer un document depuis un fichier local'; @@ -54,6 +65,10 @@ define(function () { out.commitButton = 'VALIDER'; + out.getViewButton = 'LECTURE SEULE'; + out.getViewButtonTitle = "Obtenir l'adresse d'accès à ce document en lecture seule"; + out.readonlyUrl = 'URL de lecture seule'; + out.disconnectAlert = 'Perte de la connexion au réseau !'; out.tryIt = 'Essayez-le !'; @@ -65,12 +80,6 @@ define(function () { out.loginText = '

Votre nom d\'utilisateur et votre mot de passe sont utilisés pour générer une clé unique qui reste inconnue de notre serveur.

\n' + '

Faites attention de ne pas oublier vos identifiants puisqu\'ils seront impossible à récupérer.

'; - out.type = {}; - out.type.pad = 'Pad'; - out.type.code = 'Code'; - out.type.poll = 'Sondage'; - out.type.slide = 'Présentation'; - out.forget = "Oublier"; // Polls diff --git a/customize.dist/translations/messages.js b/customize.dist/translations/messages.js index 27c9af85d..f13409b72 100644 --- a/customize.dist/translations/messages.js +++ b/customize.dist/translations/messages.js @@ -3,6 +3,11 @@ define(function () { out.main_title = "Cryptpad: Zero Knowledge, Collaborative Real Time Editing"; + out.type = {}; + out.type.pad = 'Pad'; + out.type.code = 'Code'; + out.type.poll = 'Poll'; + out.type.slide = 'Presentation'; out.errorBox_errorType_disconnected = 'Connection Lost'; out.errorBox_errorExplanation_disconnected = [ @@ -20,6 +25,12 @@ define(function () { out.synchronizing = 'Synchronizing'; out.reconnecting = 'Reconnecting...'; out.lag = 'Lag'; + out.readonly = 'Read only'; + out.nobodyIsEditing = 'Nobody is editing'; + out.onePersonIsEditing = 'One person is editing'; + out.peopleAreEditing = '{0} people are editing'; + out.oneViewer = '1 viewer'; + out.viewers = '{0} viewers'; out.importButton = 'IMPORT'; out.importButtonTitle = 'Import a document from a local file'; @@ -54,6 +65,10 @@ define(function () { out.commitButton = 'COMMIT'; + out.getViewButton = 'READ-ONLY URL'; + out.getViewButtonTitle = 'Get the read-only URL for this document'; + out.readonlyUrl = 'Read only URL'; + out.disconnectAlert = 'Network connection lost!'; out.tryIt = 'Try it out!'; @@ -65,13 +80,6 @@ define(function () { out.loginText = '

Your username and password are used to generate a unique key which is never known by our server.

\n' + '

Be careful not to forget your credentials, as they are impossible to recover

'; - // TODO : move at the beginning - out.type = {}; - out.type.pad = 'Pad'; - out.type.code = 'Code'; - out.type.poll = 'Poll'; - out.type.slide = 'Presentation'; - out.forget = "Forget"; // Polls diff --git a/package.json b/package.json index c61266ee7..ec9dbf734 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "dependencies": { "express": "~4.10.1", "ws": "^1.0.1", - "nthen": "~0.1.0" + "nthen": "~0.1.0", + "tweetnacl": "~0.12.2" }, "devDependencies": { "jshint": "~2.9.1", diff --git a/www/code/main.js b/www/code/main.js index b68fa5254..41e089e70 100644 --- a/www/code/main.js +++ b/www/code/main.js @@ -38,6 +38,10 @@ define([ var toolbar; var secret = Cryptpad.getSecrets(); + var readOnly = secret.keys && !secret.keys.editKeyStr; + if (!secret.keys) { + secret.keys = secret.key; + } var andThen = function (CMeditor) { var CodeMirror = module.CodeMirror = CMeditor; @@ -105,6 +109,7 @@ define([ }()); var setEditable = module.setEditable = function (bool) { + if (readOnly && bool) { return; } editor.setOption('readOnly', !bool); }; @@ -131,7 +136,10 @@ define([ initialState: '{}', websocketURL: Config.websocketURL, channel: secret.channel, - crypto: Crypto.createEncryptor(secret.key), + // our public key + validateKey: secret.keys.validateKey || undefined, + readOnly: readOnly, + crypto: Crypto.createEncryptor(secret.keys), setMyID: setMyID, transformFunction: JsonOT.validate }; @@ -142,6 +150,7 @@ define([ var onLocal = config.onLocal = function () { if (initializing) { return; } + if (readOnly) { return; } editor.save(); var textValue = canonicalize($textarea.val()); @@ -275,12 +284,21 @@ define([ var config = { userData: userList, changeNameID: Toolbar.constants.changeName, + readOnly: readOnly }; + if (readOnly) {delete config.changeNameID; } toolbar = module.toolbar = Toolbar.create($bar, info.myID, info.realtime, info.getLag, info.userList, config); - createChangeName(Toolbar.constants.changeName, $bar); + if (!readOnly) { createChangeName(Toolbar.constants.changeName, $bar); } var $rightside = $bar.find('.' + Toolbar.constants.rightside); + var editHash; + var viewHash = Cryptpad.getViewHashFromKeys(info.channel, secret.keys); + + if (!readOnly) { + editHash = Cryptpad.getEditHashFromKeys(info.channel, secret.keys); + } + /* add an export button */ var $export = $('