diff --git a/CHANGELOG.md b/CHANGELOG.md index 2abf99b75..a3a77352f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ +# Zebra release (v2.25.0) + +## Goals + +This release coincided with XWiki's yearly seminar, so our regular schedule was interrupted a bit. We spent the time we had working towards implementing components of "editable metadata", which will allow pad owners to add new owners or transfer ownership to friends, among other things. + +Otherwise we wanted to deploy a built-in support system to improve our ability to debug issues as well as to make it easier for users to report problems. Along the way we did our best to improve usability and fix small annoying bugs. + +As this is the last release in our 2.0 cycle, we're going to take some extra time to prepare some big features for our 3.0.0 release, which we expect to deploy on August 20th, 2019. + +## Update notes + +* We've updated some dependencies that are used to lint the CryptPad codebase to detect errors. Run `npm install` if you plan to develop for CryptPad and you want to use the linter +* This release introduces a _support_ tab within the admin panel. If you generate an asymmetric keypair and add it to your server-side configuration file then users will have the option of opening support tickets if they encounter errors. Their support tickets will include some basic information about their account which might help you to solve their issues. To set up your _"encrypted support mailbox"_: + 1. run `node ./scripts/generate-admin-keys.js` + 2. copy the "public key" and add it to your config.js file like so: + * `supportMailboxPublicKey: "BL3kgYBM0HNw5ms8ULWU1wMTb5ePBbxAPjDZKamkuB8=", + 3. copy the private key and store it in a safe place + 4. navigate to the "support" tab in the admin panel and enter the private key + 5. share the private key with any other administrators who should be able to read the support tickets + 6. restart so that your users receive the public key stored in your configuration file + * this will allow them to submit tickets via the support page + * if you don't know how to fix the issue and want to open a ticket on our public tracker, include the information submitted along with their ticket + +## Features + +* The feature added in the previous release which displayed a preview of the theme and highlighting mode chosen for the code and slide editors has been improved to also display previews when navigating through the dropdowns using keyboard arrow keys. +* We've followed up on our initial work on notifications by adding a full notifications page which offers the ability to review older notifications that you might have accidentally dismissed. +* When you right-click on an element in the CryptDrive the resulting menu now includes icons to make it easier to find the action for which you are looking +* We now include folders in search results which used to only include files +* You can right-click to add colors to folders, in case that helps you organize your content more effectively + # Yak release (v2.24.0) ## Goals diff --git a/config/config.example.js b/config/config.example.js index eb134591c..5f4473b99 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -64,6 +64,19 @@ module.exports = { //"https://my.awesome.website/user/#/1/cryptpad-user1/YZgXQxKR0Rcb6r6CmxHPdAGLVludrAF2lEnkbx1vVOo=", ], + /* CryptPad's administration panel includes a "support" tab + * wherein administrators with a secret key can view messages + * sent from users via the encrypted forms on the /support/ page + * + * To enable this functionality: + * run `node ./scripts/generate-admin-keys.js` + * save the public key in your config in the value below + * add the private key via the admin panel + * and back it up in a secure manner + * + */ + // supportMailboxPublicKey: "", + /* ===================== * Infra setup * ===================== */ diff --git a/customize.dist/fonts/cptools/fonts/cptools.svg b/customize.dist/fonts/cptools/fonts/cptools.svg index f7fe0879f..93eef8d38 100644 --- a/customize.dist/fonts/cptools/fonts/cptools.svg +++ b/customize.dist/fonts/cptools/fonts/cptools.svg @@ -25,4 +25,5 @@ + \ No newline at end of file diff --git a/customize.dist/fonts/cptools/fonts/cptools.ttf b/customize.dist/fonts/cptools/fonts/cptools.ttf index 1dac2ff87..18338a9ee 100644 Binary files a/customize.dist/fonts/cptools/fonts/cptools.ttf and b/customize.dist/fonts/cptools/fonts/cptools.ttf differ diff --git a/customize.dist/fonts/cptools/fonts/cptools.woff b/customize.dist/fonts/cptools/fonts/cptools.woff index 4f01d5d15..d8f56ba86 100644 Binary files a/customize.dist/fonts/cptools/fonts/cptools.woff and b/customize.dist/fonts/cptools/fonts/cptools.woff differ diff --git a/customize.dist/fonts/cptools/style.css b/customize.dist/fonts/cptools/style.css index 952207f15..349b62f2b 100644 --- a/customize.dist/fonts/cptools/style.css +++ b/customize.dist/fonts/cptools/style.css @@ -1,9 +1,9 @@ @font-face { font-family: 'cptools'; src: - url('fonts/cptools.ttf?yr9e7c') format('truetype'), - url('fonts/cptools.woff?yr9e7c') format('woff'), - url('fonts/cptools.svg?yr9e7c#cptools') format('svg'); + url('fonts/cptools.ttf?cljhos') format('truetype'), + url('fonts/cptools.woff?cljhos') format('woff'), + url('fonts/cptools.svg?cljhos#cptools') format('svg'); font-weight: normal; font-style: normal; } @@ -24,6 +24,9 @@ -moz-osx-font-smoothing: grayscale; } +.cptools-folder-upload:before { + content: "\e912"; +} .cptools-folder-no-color:before { content: "\e900"; } diff --git a/customize.dist/pages.js b/customize.dist/pages.js index 8a21fa233..3fd5e63bb 100644 --- a/customize.dist/pages.js +++ b/customize.dist/pages.js @@ -103,7 +103,7 @@ define([ ])*/ ]) ]), - h('div.cp-version-footer', "CryptPad v2.24.0 (Yak)") + h('div.cp-version-footer', "CryptPad v2.25.0 (Zebra)") ]); }; diff --git a/customize.dist/src/less2/include/colortheme.less b/customize.dist/src/less2/include/colortheme.less index abbe07955..8aa99e403 100644 --- a/customize.dist/src/less2/include/colortheme.less +++ b/customize.dist/src/less2/include/colortheme.less @@ -26,6 +26,8 @@ @colortheme_form-warning: #f49842; @colortheme_form-warning-hov: darken(@colortheme_form-warning, 5%); +@colortheme_context-menu-icon-color: #7b7b7b; + @colortheme_modal-bg: @colortheme_form-bg-alt; // TODO Modals bg @colortheme_modal-fg: @colortheme_form-color-alt; @colortheme_modal-link: @colortheme_link-color; @@ -135,6 +137,15 @@ @colortheme_admin-color: #FFF; @colortheme_admin-warn: #ffae00; +@colortheme_notifications-bg: #4ae397; +@colortheme_notifications-color: #000; +@colortheme_notifications-warn: #e34a85; + +@colortheme_support-bg: #42d1f4; +@colortheme_support-color: #000; +@colortheme_support-warn: #9A37F7; + + // Sidebar layout (profile / settings) @colortheme_sidebar-active: #fff; @colortheme_sidebar-left-bg: #eee; diff --git a/customize.dist/src/less2/include/contextmenu.less b/customize.dist/src/less2/include/contextmenu.less index 1a9049b54..8b1ef8c21 100644 --- a/customize.dist/src/less2/include/contextmenu.less +++ b/customize.dist/src/less2/include/contextmenu.less @@ -11,9 +11,41 @@ li { padding: 0; font-size: @colortheme_app-font-size; + &.dropdown-submenu { + position: relative; + &> a { + cursor: default; + // reset bootstrap active style + &:active { + background: inherit; + color: inherit; + } + } + .dropdown-toggle { + margin-left: 1rem; + } + .dropdown-menu { + top: -0.7rem; + left: 100%; + &.left { + left: -10rem; + } + } + } a { cursor: pointer; + .fa, .cptools { + margin-right: 1rem; + color: @colortheme_context-menu-icon-color; + width: 16px; + } } } + .cp-app-drive-context-noAction { + font-style: italic; + color: #aaa; + cursor: default; + display: none; + } } } diff --git a/customize.dist/src/less2/include/notifications.less b/customize.dist/src/less2/include/notifications.less index 4b37c14b4..f27d6ed60 100644 --- a/customize.dist/src/less2/include/notifications.less +++ b/customize.dist/src/less2/include/notifications.less @@ -1,4 +1,5 @@ @import (reference) "./colortheme-all.less"; +@import (reference) "./avatar.less"; .notifications_main() { --LessLoader_require: LessLoader_currentFile(); @@ -14,6 +15,7 @@ display: flex; .cp-notification-content { flex: 1; + align-items: stretch; min-width: 0; p { word-break: break-word; @@ -28,8 +30,7 @@ .cp-notification-dismiss { color: black; width: 25px; - height: 100%; - display: none; + display: flex; align-items: center; justify-content: center; cursor: pointer; @@ -39,6 +40,33 @@ } } } + hr { + margin: 0px !important; + } + .cp-notifications-gotoapp { + p { + padding: 10px 0 !important; + text-align: center !important; + font-weight: bold; + cursor: pointer; + &:hover { + background-color: rgba(0,0,0,0.1); + } + } + } + .cp-notifications-requestedit-verified { + display: flex; + align-items: center; + &> span.cp-avatar { + .avatar_main(30px); + } + &> span { + margin-right: 10px; + } + &> p { + margin: 0; + } + } } diff --git a/customize.dist/src/less2/include/sidebar-layout.less b/customize.dist/src/less2/include/sidebar-layout.less index 07471183a..b25cbf43c 100644 --- a/customize.dist/src/less2/include/sidebar-layout.less +++ b/customize.dist/src/less2/include/sidebar-layout.less @@ -90,12 +90,21 @@ button.btn { @button-bg: @colortheme_sidebar-button-bg; @button-red-bg: @colortheme_sidebar-button-red-bg; + @button-alt-bg: @colortheme_sidebar-button-alt-bg; background-color: @button-bg; border-color: darken(@button-bg, 10%); color: white; &:hover { background-color: darken(@button-bg, 10%); } + &.btn-secondary { + background-color: @button-alt-bg; + border-color: darken(@button-alt-bg, 10%); + color: black; + &:hover { + background-color: darken(@button-alt-bg, 10%); + } + } &.btn-danger { background-color: @button-red-bg; border-color: darken(@button-red-bg, 10%); diff --git a/customize.dist/src/less2/include/support.less b/customize.dist/src/less2/include/support.less new file mode 100644 index 000000000..0a352ec32 --- /dev/null +++ b/customize.dist/src/less2/include/support.less @@ -0,0 +1,86 @@ +@import (reference) "./colortheme-all.less"; +.support_main () { + @ticket-bg: #F7F7F7; + @msg-bg: #eee; + @fromme-bg: #ddd; + .cp-support-form-container { + [type="text"] { + width: @sidebar_button-width; + margin-bottom: 10px; + } + textarea { + width: 2*@sidebar_button-width; + max-width: 90%; + padding: 10px 15px; + height: 300px; + } + } + .cp-support-container { + .cp-support-list-ticket { + display: flex; + flex-flow: column; + background-color: @ticket-bg; + padding: 10px; + width: 1200px; + max-width: 90%; + margin: 5px auto; + .cp-support-list-message { + background-color: @msg-bg; + margin: 2px; + padding: 2px 5px; + .cp-support-fromme { + background-color: @fromme-bg; + } + .cp-support-showdata { + cursor: pointer; + background-color: @fromme-bg; + .cp-support-message-data { + display: none; + cursor: default; + } + } + .cp-support-message-time { + float: right; + } + pre { + margin-bottom: 0; + white-space: pre-wrap; + &.cp-support-message-content { + margin-top: 10px; + margin-bottom: 10px; + } + } + } + .cp-support-list-actions { + order: 3; + .cp-support-hide { + display: none; + } + } + .cp-support-form-container { + order: 2; + } + &.cp-support-list-closed { + .cp-support-list-actions { + display: block !important; + .cp-support-answer, .cp-support-close { + display: none; + } + .cp-support-hide { + display: inline; + } + } + .cp-support-form-container { + display: none !important; + } + } + button { + margin-left: 2px; + margin-right: 5px; + } + } + } +} + + + diff --git a/package-lock.json b/package-lock.json index 30b43f03e..7a91644fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "cryptpad", - "version": "2.23.0", + "version": "2.25.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -573,16 +573,16 @@ "dev": true }, "jshint": { - "version": "2.9.7", - "resolved": "https://registry.npmjs.org/jshint/-/jshint-2.9.7.tgz", - "integrity": "sha512-Q8XN38hGsVQhdlM+4gd1Xl7OB1VieSuCJf+fEJjpo59JH99bVJhXRXAh26qQ15wfdd1VPMuDWNeSWoNl53T4YA==", + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/jshint/-/jshint-2.10.2.tgz", + "integrity": "sha512-e7KZgCSXMJxznE/4WULzybCMNXNAd/bf5TSrvVEq78Q/K8ZwFpmBqQeDtNiHc3l49nV4E/+YeHU/JZjSUIrLAA==", "dev": true, "requires": { "cli": "~1.0.0", "console-browserify": "1.1.x", "exit": "0.1.x", "htmlparser2": "3.8.x", - "lodash": "~4.17.10", + "lodash": "~4.17.11", "minimatch": "~3.0.2", "shelljs": "0.3.x", "strip-json-comments": "1.0.x" @@ -697,9 +697,9 @@ } }, "lodash": { - "version": "4.17.11", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", - "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", + "version": "4.17.14", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.14.tgz", + "integrity": "sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw==", "dev": true }, "lodash.clonedeep": { @@ -709,10 +709,9 @@ "dev": true }, "lodash.merge": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.1.tgz", - "integrity": "sha512-AOYza4+Hf5z1/0Hztxpm2/xiPZgi/cjMqdnKTUWTBSKchJlxXXuUSxCCl8rJlf4g6yww/j6mA8nC8Hw/EZWxKQ==", - "dev": true + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, "lodash.sortby": { "version": "4.7.0", diff --git a/package.json b/package.json index 2020d6df4..585e8b8fc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cryptpad", "description": "realtime collaborative visual editor with zero knowlege server", - "version": "2.24.0", + "version": "2.25.0", "license": "AGPL-3.0+", "repository": { "type": "git", @@ -23,7 +23,7 @@ }, "devDependencies": { "flow-bin": "^0.59.0", - "jshint": "~2.9.1", + "jshint": "^2.10.2", "less": "2.7.1", "lesshint": "^4.5.0", "selenium-webdriver": "^3.6.0" diff --git a/scripts/generate-admin-keys.js b/scripts/generate-admin-keys.js new file mode 100644 index 000000000..26d26537e --- /dev/null +++ b/scripts/generate-admin-keys.js @@ -0,0 +1,25 @@ +/* jshint esversion: 6, node: true */ + +const Nacl = require('tweetnacl'); + +const keyPair = Nacl.box.keyPair(); +console.log("You've just generated a new key pair for your support mailbox."); + +console.log("The public key should first be added to your config.js file ('supportMailboxPublicKey'), then save and restart the server."); +console.log("Once restarted, administrators (specified with 'adminKeys' in config.js too) will be able to add the private key into their account. This can be done using the administration panel."); +console.log("You will have to send the private key to each administrator manually so that they can add it to their account."); +console.log(); +console.log("WARNING: the public and private keys must come from the same key pair to have a working encrypted support mailbox."); +console.log(); +console.log("NOTE: You can change the key pair at any time if you want to revoke access to the support mailbox. You just have to generate a new key pair using this file, and replace the value in config.js, and then send the new private key to the administrators of your choice."); + + +console.log(); +console.log(); +console.log("Your public key (add it to config.js):"); +console.log(Nacl.util.encodeBase64(keyPair.publicKey)); + +console.log(); +console.log(); +console.log("Your private key (store it in a safe place and send it to your instance's admins):"); +console.log(Nacl.util.encodeBase64(keyPair.secretKey)); diff --git a/server.js b/server.js index b10b32da8..b8c2b8163 100644 --- a/server.js +++ b/server.js @@ -193,6 +193,7 @@ app.get('/api/config', function(req, res){ httpUnsafeOrigin: config.httpUnsafeOrigin, adminEmail: config.adminEmail, adminKeys: admins, + supportMailbox: config.supportMailboxPublicKey }, null, '\t'), 'obj.httpSafeOrigin = ' + (function () { if (config.httpSafeOrigin) { return '"' + config.httpSafeOrigin + '"'; } diff --git a/www/admin/app-admin.less b/www/admin/app-admin.less index c3b8a31de..a1c1ee779 100644 --- a/www/admin/app-admin.less +++ b/www/admin/app-admin.less @@ -1,5 +1,6 @@ @import (reference) '../../customize/src/less2/include/framework.less'; @import (reference) '../../customize/src/less2/include/sidebar-layout.less'; +@import (reference) '../../customize/src/less2/include/support.less'; &.cp-app-admin { @@ -9,6 +10,11 @@ @color: @colortheme_admin-color ); .sidebar-layout_main(); + .support_main(); + + .cp-hidden { + display: none !important; + } display: flex; flex-flow: column; diff --git a/www/admin/inner.js b/www/admin/inner.js index 83dec1415..4c335dd12 100644 --- a/www/admin/inner.js +++ b/www/admin/inner.js @@ -9,6 +9,8 @@ define([ '/customize/messages.js', '/common/common-interface.js', '/common/common-util.js', + '/common/common-hash.js', + '/support/ui.js', 'css!/bower_components/bootstrap/dist/css/bootstrap.min.css', 'css!/bower_components/components-font-awesome/css/font-awesome.min.css', @@ -23,7 +25,9 @@ define([ h, Messages, UI, - Util + Util, + Hash, + Support ) { var APP = {}; @@ -41,6 +45,10 @@ define([ 'cp-admin-active-pads', 'cp-admin-registered', 'cp-admin-disk-usage', + ], + 'support': [ + 'cp-admin-support-list', + 'cp-admin-support-init' ] }; @@ -94,7 +102,6 @@ define([ sFrameChan.query('Q_ADMIN_RPC', { cmd: 'ACTIVE_SESSIONS', }, function (e, data) { - console.log(e, data); var total = data[0]; var ips = data[1]; $div.append(h('pre', total + ' (' + ips + ')')); @@ -160,6 +167,108 @@ define([ return $div; }; + var supportKey = ApiConfig.supportMailbox; + create['support-list'] = function () { + if (!supportKey || !APP.privateKey) { return; } + var $div = makeBlock('support-list'); + $div.addClass('cp-support-container'); + var hashesById = {}; + + // Register to the "support" mailbox + common.mailbox.subscribe(['supportadmin'], { + onMessage: function (data) { + /* + Get ID of the ticket + If we already have a div for this ID + Push the message to the end of the ticket + If it's a new ticket ID + Make a new div for this ID + */ + var msg = data.content.msg; + var hash = data.content.hash; + var content = msg.content; + var id = content.id; + var $ticket = $div.find('.cp-support-list-ticket[data-id="'+id+'"]'); + + hashesById[id] = hashesById[id] || []; + if (hashesById[id].indexOf(hash) === -1) { + hashesById[id].push(data); + } + + if (msg.type === 'CLOSE') { + // A ticket has been closed by the admins... + if (!$ticket.length) { return; } + $ticket.addClass('cp-support-list-closed'); + $ticket.append(APP.support.makeCloseMessage(content, hash)); + return; + } + if (msg.type !== 'TICKET') { return; } + + if (!$ticket.length) { + $ticket = APP.support.makeTicket($div, content, function () { + var error = false; + hashesById[id].forEach(function (d) { + common.mailbox.dismiss(d, function (err) { + if (err) { + error = true; + console.error(err); + } + }); + }); + if (!error) { $ticket.remove(); } + }); + } + $ticket.append(APP.support.makeMessage(content, hash)); + } + }); + return $div; + }; + + var checkAdminKey = function (priv) { + if (!supportKey) { return; } + return Hash.checkBoxKeyPair(priv, supportKey); + }; + + create['support-init'] = function () { + var $div = makeBlock('support-init'); + if (!supportKey) { + $div.append(h('p', Messages.admin_supportInitHelp)); + return $div; + } + if (!APP.privateKey || !checkAdminKey(APP.privateKey)) { + $div.append(h('p', Messages.admin_supportInitPrivate)); + + var error = h('div.cp-admin-support-error'); + var input = h('input.cp-admin-add-private-key'); + var button = h('button.btn.btn-primary', Messages.admin_supportAddKey); + + if (APP.privateKey && !checkAdminKey(APP.privateKey)) { + $(error).text(Messages.admin_supportAddError); + } + + $div.append(h('div', [ + error, + input, + button + ])); + + $(button).click(function () { + var key = $(input).val(); + if (!checkAdminKey(key)) { + $(input).val(''); + return void $(error).text(Messages.admin_supportAddError); + } + sFrameChan.query("Q_ADMIN_MAILBOX", key, function () { + APP.privateKey = key; + $('.cp-admin-support-init').hide(); + APP.$rightside.append(create['support-list']()); + }); + }); + return $div; + } + return; + }; + var hideCategories = function () { APP.$rightside.find('> div').hide(); }; @@ -180,6 +289,7 @@ define([ var $category = $('
', {'class': 'cp-sidebarlayout-category'}).appendTo($categories); if (key === 'general') { $category.append($('', {'class': 'fa fa-user-o'})); } if (key === 'stats') { $category.append($('', {'class': 'fa fa-hdd-o'})); } + if (key === 'support') { $category.append($('', {'class': 'fa fa-life-ring'})); } if (key === active) { $category.addClass('cp-leftside-active'); @@ -236,8 +346,10 @@ define([ return void UI.errorLoadingScreen(Messages.admin_authError || '403 Forbidden'); } + APP.privateKey = privateData.supportPrivateKey; APP.origin = privateData.origin; APP.readOnly = privateData.readOnly; + APP.support = Support.create(common, true); // Content var $rightside = APP.$rightside; diff --git a/www/admin/main.js b/www/admin/main.js index cdfaf115c..817d2bd2e 100644 --- a/www/admin/main.js +++ b/www/admin/main.js @@ -38,6 +38,9 @@ define([ }).nThen(function (/*waitFor*/) { var addRpc = function (sframeChan, Cryptpad/*, Utils*/) { // Adding a new avatar from the profile: pin it and store it in the object + sframeChan.on('Q_ADMIN_MAILBOX', function (data, cb) { + Cryptpad.addAdminMailbox(data, cb); + }); sframeChan.on('Q_ADMIN_RPC', function (data, cb) { Cryptpad.adminRpc(data, cb); }); diff --git a/www/code/inner.js b/www/code/inner.js index 9e4d0a207..b061e36fa 100644 --- a/www/code/inner.js +++ b/www/code/inner.js @@ -363,7 +363,15 @@ define([ }); framework.setFileExporter(CodeMirror.getContentExtension, CodeMirror.fileExporter); - framework.setFileImporter({}, CodeMirror.fileImporter); + framework.setFileImporter({}, function () { + /* setFileImporter currently takes a function with the following signature: + (content, file) => {} + I used 'apply' with 'arguments' to avoid breaking things if this API ever changes. + */ + var ret = CodeMirror.fileImporter.apply(null, Array.prototype.slice.call(arguments)); + previewPane.modeChange(ret.mode); + return ret; + }); framework.setNormalizer(function (c) { return { diff --git a/www/common/application_config_internal.js b/www/common/application_config_internal.js index 49be02723..e4d5aa4f9 100644 --- a/www/common/application_config_internal.js +++ b/www/common/application_config_internal.js @@ -20,7 +20,7 @@ define(function() { * users and these users will be redirected to the login page if they still try to access * the app */ - config.registeredOnlyTypes = ['file', 'contacts', 'oodoc', 'ooslide', 'sheet']; + config.registeredOnlyTypes = ['file', 'contacts', 'oodoc', 'ooslide', 'sheet', 'notifications']; /* CryptPad is available is multiple languages, but only English and French are maintained * by the developers. The other languages may be outdated, and any missing string for a langauge diff --git a/www/common/common-constants.js b/www/common/common-constants.js index 9eb3c4075..986e115e2 100644 --- a/www/common/common-constants.js +++ b/www/common/common-constants.js @@ -17,6 +17,6 @@ define(function () { // Sub plan: 'CryptPad_plan', // Apps - criticalApps: ['profile', 'settings', 'debug', 'admin'] + criticalApps: ['profile', 'settings', 'debug', 'admin', 'support', 'notifications'] }; }); diff --git a/www/common/common-hash.js b/www/common/common-hash.js index 3ef802681..e49b0e217 100644 --- a/www/common/common-hash.js +++ b/www/common/common-hash.js @@ -85,6 +85,34 @@ define([ return id; }; + /* Given a base64-encoded public key, deterministically derive a channel id + Used for support mailboxes + */ + Hash.getChannelIdFromKey = function (publicKey) { + if (!publicKey) { return; } + return uint8ArrayToHex(Hash.decodeBase64(publicKey).subarray(0,16)); + }; + + /* Given a base64-encoded asymmetric private key + derive the corresponding public key + */ + Hash.getBoxPublicFromSecret = function (priv) { + if (!priv) { return; } + var u8_priv = Hash.decodeBase64(priv); + var pair = Nacl.box.keyPair.fromSecretKey(u8_priv); + return Hash.encodeBase64(pair.publicKey); + }; + + /* Given a base64-encoded private key and public key + check that the keys are part of a valid keypair + */ + Hash.checkBoxKeyPair = function (priv, pub) { + if (!pub || !priv) { return false; } + var u8_priv = Hash.decodeBase64(priv); + var pair = Nacl.box.keyPair.fromSecretKey(u8_priv); + return pub === Hash.encodeBase64(pair.publicKey); + }; + Hash.createRandomHash = function (type, password) { var cryptor; if (type === 'file') { diff --git a/www/common/common-messenger.js b/www/common/common-messenger.js index 9868e0266..1102f94a2 100644 --- a/www/common/common-messenger.js +++ b/www/common/common-messenger.js @@ -451,9 +451,7 @@ define([ var txid = parsed[1]; var req = getRangeRequest(txid); var type = parsed[0]; - if (!req) { - return void console.error("received response to unknown request"); - } + if (!req) { return; } if (!req.cb) { // This is the initial history for a pad chat diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index 70da31523..bc465cdfb 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -162,7 +162,7 @@ define([ } var parsed = Hash.parsePadUrl(data.href || data.roHref); - if (!data.noEditPassword && owned && parsed.hashData.type === 'pad') { + if (!data.noEditPassword && owned && parsed.hashData.type === 'pad' && parsed.type !== "sheet") { // FIXME SHEET fix password change for sheets var sframeChan = common.getSframeChannel(); var changePwTitle = Messages.properties_changePassword; var changePwConfirm = Messages.properties_confirmChange; @@ -412,6 +412,7 @@ define([ if (!friend.notifications || !friend.curvePublic) { return; } common.mailbox.sendTo("SHARE_PAD", { href: href, + password: config.password, name: myName, title: title }, { @@ -700,7 +701,10 @@ define([ }, keys: [13] }]; - var frameLink = UI.dialog.customModal(link, {buttons: linkButtons}); + var frameLink = UI.dialog.customModal(link, { + buttons: linkButtons, + onClose: config.onClose, + }); // Embed tab var embed = h('div.cp-share-modal', [ @@ -727,7 +731,10 @@ define([ }, keys: [13] }]; - var frameEmbed = UI.dialog.customModal(embed, { buttons: embedButtons}); + var frameEmbed = UI.dialog.customModal(embed, { + buttons: embedButtons, + onClose: config.onClose, + }); // Create modal var tabs = [{ @@ -1866,6 +1873,13 @@ define([ content: h('span', Messages.adminPage || 'Admin') }); } + if (padType !== 'support' && accountName && Config.supportMailbox) { + options.push({ + tag: 'a', + attributes: {'class': 'cp-toolbar-menu-support fa fa-life-ring'}, + content: h('span', Messages.supportPage || 'Support') + }); + } // Add login or logout button depending on the current status if (accountName) { options.push({ @@ -1961,6 +1975,13 @@ define([ window.parent.location = origin+'/settings/'; } }); + $userAdmin.find('a.cp-toolbar-menu-support').click(function () { + if (padType) { + window.open(origin+'/support/'); + } else { + window.parent.location = origin+'/support/'; + } + }); $userAdmin.find('a.cp-toolbar-menu-admin').click(function () { if (padType) { window.open(origin+'/admin/'); diff --git a/www/common/cryptpad-common.js b/www/common/cryptpad-common.js index 66ad6a442..0311387ee 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -620,6 +620,9 @@ define([ common.adminRpc = function (data, cb) { postMessage("ADMIN_RPC", data, cb); }; + common.addAdminMailbox = function (data, cb) { + postMessage("ADMIN_ADD_MAILBOX", data, cb); + }; // Network common.onNetworkDisconnect = Util.mkEvent(); @@ -690,6 +693,13 @@ define([ pad.onConnectEvent = Util.mkEvent(); pad.onErrorEvent = Util.mkEvent(); + pad.requestAccess = function (data, cb) { + postMessage("REQUEST_PAD_ACCESS", data, cb); + }; + pad.giveAccess = function (data, cb) { + postMessage("GIVE_PAD_ACCESS", data, cb); + }; + common.changePadPassword = function (Crypt, href, newPassword, edPublic, cb) { if (!href) { return void cb({ error: 'EINVAL_HREF' }); } var parsed = Hash.parsePadUrl(href); diff --git a/www/common/curve-put.js b/www/common/curve-put.js deleted file mode 100644 index 450b62564..000000000 --- a/www/common/curve-put.js +++ /dev/null @@ -1,51 +0,0 @@ -define([ - '/common/curve.js', - '/bower_components/chainpad-listmap/chainpad-listmap.js', -], function (Curve, Listmap) { - var Edit = {}; - - Edit.create = function (config, cb) { //network, channel, theirs, mine, cb) { - var network = config.network; - var channel = config.channel; - var keys = config.keys; - - try { - var encryptor = Curve.createEncryptor(keys); - var lm = Listmap.create({ - network: network, - data: {}, - channel: channel, - readOnly: false, - validateKey: keys.validateKey || undefined, - crypto: encryptor, - userName: 'lol', - logLevel: 1, - }); - - var done = function () { - // TODO make this abort and disconnect the session after the - // user has finished making changes to the object, and they - // have propagated. - }; - - lm.proxy - .on('create', function () { - console.log('created'); - }) - .on('ready', function () { - console.log('ready'); - cb(lm, done); - }) - .on('disconnect', function () { - console.log('disconnected'); - }) - .on('change', [], function (o, n, p) { - console.log(o, n, p); - }); - } catch (e) { - console.error(e); - } - }; - - return Edit; -}); diff --git a/www/common/mergeDrive.js b/www/common/mergeDrive.js index 9247656e1..aa6e57b0a 100644 --- a/www/common/mergeDrive.js +++ b/www/common/mergeDrive.js @@ -49,7 +49,7 @@ define([ // We want to merge an edit pad: check if we have the same channel // but read-only and upgrade it in that case datas.forEach(function (pad) { - if (!pad.href) { data.href = pad.href; } + if (pad.data && !pad.data.href) { pad.data.href = data.href; } }); return; } diff --git a/www/common/notifications.js b/www/common/notifications.js index 136117c6f..7ab504fb6 100644 --- a/www/common/notifications.js +++ b/www/common/notifications.js @@ -2,74 +2,201 @@ define([ 'jquery', '/common/hyperscript.js', '/common/common-hash.js', + '/common/common-interface.js', '/common/common-ui-elements.js', '/customize/messages.js', -], function ($, h, Hash, UIElements, Messages) { +], function ($, h, Hash, UI, UIElements, Messages) { var handlers = {}; + var defaultDismiss = function (common, data) { + return function (e) { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + common.mailbox.dismiss(data, function (err) { + if (err) { return void console.error(err); } + }); + }; + }; + // Friend request - handlers['FRIEND_REQUEST'] = function (common, data, el) { + handlers['FRIEND_REQUEST'] = function (common, data) { var content = data.content; var msg = content.msg; + // Display the notification + content.getFormatText = function () { + return Messages._getKey('friendRequest_notification', [msg.content.displayName || Messages.anonymous]); + }; + // Check authenticity if (msg.author !== msg.content.curvePublic) { return; } - common.addFriendRequest(data); - - // Display the notification - $(el).find('.cp-notification-content p') - .html(Messages._getKey('friendRequest_notification', [msg.content.displayName || Messages.anonymous])); - $(el).find('.cp-notification-content').addClass("cp-clickable") - .click(function () { + // if not archived, add handlers + if (!content.archived) { + content.handler = function () { UIElements.displayFriendRequestModal(common, data); - }); + }; + common.addFriendRequest(data); + } }; - handlers['FRIEND_REQUEST_ACCEPTED'] = function (common, data, el) { + handlers['FRIEND_REQUEST_ACCEPTED'] = function (common, data) { var content = data.content; var msg = content.msg; - $(el).find('.cp-notification-content p') - .html(Messages._getKey('friendRequest_accepted', [msg.content.name || Messages.anonymous])); - $(el).find('.cp-notification-dismiss').css('display', 'flex'); + content.getFormatText = function () { + return Messages._getKey('friendRequest_accepted', [msg.content.name || Messages.anonymous]); + }; + if (!content.archived) { + content.dismissHandler = defaultDismiss(common, data); + } }; - handlers['FRIEND_REQUEST_DECLINED'] = function (common, data, el) { + handlers['FRIEND_REQUEST_DECLINED'] = function (common, data) { var content = data.content; var msg = content.msg; - $(el).find('.cp-notification-content p') - .html(Messages._getKey('friendRequest_declined', [msg.content.name || Messages.anonymous])); - $(el).find('.cp-notification-dismiss').css('display', 'flex'); + content.getFormatText = function () { + return Messages._getKey('friendRequest_declined', [msg.content.name || Messages.anonymous]); + }; + if (!content.archived) { + content.dismissHandler = defaultDismiss(common, data); + } }; // Share pad - handlers['SHARE_PAD'] = function (common, data, el) { + handlers['SHARE_PAD'] = function (common, data) { var content = data.content; var msg = content.msg; var type = Hash.parsePadUrl(msg.content.href).type; var key = type === 'drive' ? 'notification_folderShared' : (type === 'file' ? 'notification_fileShared' : - 'notification_padShared'); - $(el).find('.cp-notification-content p') - .html(Messages._getKey(key, [msg.content.name || Messages.anonymous, msg.content.title])); - $(el).find('.cp-notification-content').addClass("cp-clickable") - .click(function () { + 'notification_padShared'); + content.getFormatText = function () { + return Messages._getKey(key, [msg.content.name || Messages.anonymous, msg.content.title]); + }; + content.handler = function () { + var todo = function () { + common.openURL(msg.content.href); + defaultDismiss(common, data)(); + }; + if (!msg.content.password) { return void todo(); } + common.getSframeChannel().query('Q_SESSIONSTORAGE_PUT', { + key: 'newPadPassword', + value: msg.content.password + }, todo); + }; + if (!content.archived) { + content.dismissHandler = defaultDismiss(common, data); + } + }; + + // New support message from the admins + handlers['SUPPORT_MESSAGE'] = function (common, data) { + var content = data.content; + content.getFormatText = function () { + return Messages.support_notification; + }; + content.handler = function () { + common.openURL('/support/'); + defaultDismiss(common, data)(); + }; + }; + + handlers['REQUEST_PAD_ACCESS'] = function (common, data) { + var content = data.content; + var msg = content.msg; + + // Check authenticity + if (msg.author !== msg.content.user.curvePublic) { return; } + + // Display the notification + content.getFormatText = function () { + return Messages._getKey('requestEdit_request', [msg.content.title, msg.content.user.displayName]); + }; + + // if not archived, add handlers + content.handler = function () { + var metadataMgr = common.getMetadataMgr(); + var priv = metadataMgr.getPrivateData(); + + var link = h('a', { + href: '#' + }, Messages.requestEdit_viewPad); + var verified = h('p.cp-notifications-requestedit-verified'); + var $verified = $(verified); + + if (priv.friends && priv.friends[msg.author]) { + var f = priv.friends[msg.author]; + $verified.append(h('span.fa.fa-certificate')); + var $avatar = $(h('span.cp-avatar')).appendTo($verified); + $verified.append(h('p', Messages._getKey('requestEdit_fromFriend', [f.displayName]))); + common.displayAvatar($avatar, f.avatar, f.displayName); + } else { + $verified.append(Messages.requestEdit_fromStranger); + } + + var div = h('div', [ + UI.setHTML(h('p'), Messages._getKey('requestEdit_confirm', [msg.content.title, msg.content.user.displayName])), + verified, + link + ]); + $(link).click(function (e) { + e.preventDefault(); + e.stopPropagation(); common.openURL(msg.content.href); }); - $(el).find('.cp-notification-dismiss').css('display', 'flex'); + UI.confirm(div, function (yes) { + if (!yes) { return; } + common.getSframeChannel().event('EV_GIVE_ACCESS', { + channel: msg.content.channel, + user: msg.content.user + }); + defaultDismiss(common, data)(); + }, { + ok: Messages.friendRequest_accept, + cancel: Messages.later + }); + }; + + if (!content.archived) { + content.dismissHandler = defaultDismiss(common, data); + } + }; + + handlers['GIVE_PAD_ACCESS'] = function (common, data) { + var content = data.content; + var msg = content.msg; + + // Check authenticity + if (msg.author !== msg.content.user.curvePublic) { return; } + + if (!msg.content.href) { return; } + + // Display the notification + content.getFormatText = function () { + return Messages._getKey('requestEdit_accepted', [msg.content.title, msg.content.user.displayName]); + }; + + // if not archived, add handlers + content.handler = function () { + common.openURL(msg.content.href); + defaultDismiss(common, data)(); + }; }; return { - add: function (common, data, el) { + add: function (common, data) { var type = data.content.msg.type; if (handlers[type]) { - handlers[type](common, data, el); - } else { - $(el).find('.cp-notification-dismiss').css('display', 'flex'); + handlers[type](common, data); + // add getters to access simply some informations + data.content.isClickable = typeof data.content.handler === "function"; + data.content.isDismissible = typeof data.content.dismissHandler === "function"; } }, remove: function (common, data) { diff --git a/www/common/onlyoffice/app-oo.less b/www/common/onlyoffice/app-oo.less index 60510914d..c7bd64e01 100644 --- a/www/common/onlyoffice/app-oo.less +++ b/www/common/onlyoffice/app-oo.less @@ -44,6 +44,7 @@ body.cp-app-sheet, body.cp-app-oodoc, body.cp-app-ooslide { height: 100%; background-color: lightgrey; display: flex; + min-height: 0; } #cp-app-oo-editor { flex: 1; diff --git a/www/common/onlyoffice/main.js b/www/common/onlyoffice/main.js index d056cad42..6bd6c7df5 100644 --- a/www/common/onlyoffice/main.js +++ b/www/common/onlyoffice/main.js @@ -102,9 +102,12 @@ define([ Cryptpad.onlyoffice.onEvent.reg(function (obj) { if (obj.ev === 'MESSAGE' && !/^cp\|/.test(obj.data)) { try { + var validateKey = obj.data.validateKey || true; + var skipCheck = validateKey === true; + var msg = obj.data.msg; obj.data = { - msg: JSON.parse(Utils.crypto.decrypt(obj.data, Utils.secret.keys.validateKey)), - hash: obj.data.slice(0,64) + msg: JSON.parse(Utils.crypto.decrypt(msg, validateKey, skipCheck)), + hash: msg.slice(0,64) }; } catch (e) { console.error(e); diff --git a/www/common/outer/async-store.js b/www/common/outer/async-store.js index 492446781..ca7e4cd7c 100644 --- a/www/common/outer/async-store.js +++ b/www/common/outer/async-store.js @@ -478,7 +478,9 @@ define([ settings: store.proxy.settings, thumbnails: disableThumbnails === false, isDriveOwned: Boolean(Util.find(store, ['driveMetadata', 'owners'])), - pendingFriends: store.proxy.friends_pending || {} + support: Util.find(store.proxy, ['mailboxes', 'support', 'channel']), + pendingFriends: store.proxy.friends_pending || {}, + supportPrivateKey: Util.find(store.proxy, ['mailboxes', 'supportadmin', 'keys', 'curvePrivate']) } }; cb(JSON.parse(JSON.stringify(metadata))); @@ -1059,6 +1061,27 @@ define([ cb(res); }); }; + Store.addAdminMailbox = function (clientId, data, cb) { + var priv = data; + var pub = Hash.getBoxPublicFromSecret(priv); + if (!priv || !pub) { return void cb({error: 'EINVAL'}); } + var channel = Hash.getChannelIdFromKey(pub); + var mailboxes = store.proxy.mailboxes = store.proxy.mailboxes || {}; + var box = mailboxes.supportadmin = { + channel: channel, + viewed: [], + lastKnownHash: '', + keys: { + curvePublic: pub, + curvePrivate: priv + } + }; + Store.pinPads(null, [channel], function () {}); + store.mailbox.open('supportadmin', box, function () { + console.log('ready'); + }); + onSync(cb); + }; ////////////////////////////////////////////////////////////////// /////////////////////// PAD ////////////////////////////////////// @@ -1067,6 +1090,7 @@ define([ var channels = Store.channels = store.channels = {}; Store.joinPad = function (clientId, data) { + console.log('joining', data.channel); var isNew = typeof channels[data.channel] === "undefined"; var channel = channels[data.channel] = channels[data.channel] || { queue: [], @@ -1220,6 +1244,97 @@ define([ channel.sendMessage(msg, clientId, cb); }; + Store.requestPadAccess = function (clientId, data, cb) { + // Get owners from pad metadata + // Try to find an owner in our friend list + // Mailbox... + var channel = channels[data.channel]; + if (!data.send && channel && (!channel.data || !channel.data.channel)) { + var i = 0; + var it = setInterval(function () { + if (channel.data && channel.data.channel) { + clearInterval(it); + Store.requestPadAccess(clientId, data, cb); + return; + } + if (i >= 300) { // One minute timeout + clearInterval(it); + } + i++; + }, 200); + return; + } + var fData = channel.data || {}; + if (fData.owners) { + var friends = store.proxy.friends || {}; + if (Object.keys(friends).length > 1) { + var owner; + fData.owners.some(function (edPublic) { + return Object.keys(friends).some(function (curve) { + if (curve === "me") { return; } + if (edPublic === friends[curve].edPublic && + friends[curve].notifications) { + owner = friends[curve]; + return true; + } + }); + }); + if (owner) { + if (data.send) { + var myData = Messaging.createData(store.proxy); + delete myData.channel; + store.mailbox.sendTo('REQUEST_PAD_ACCESS', { + channel: data.channel, + user: myData + }, { + channel: owner.notifications, + curvePublic: owner.curvePublic + }, function () { + cb({state: true}); + }); + return; + } + return void cb({state: true}); + } + } + } + cb({sent: false}); + }; + Store.givePadAccess = function (clientId, data, cb) { + var edPublic = store.proxy.edPublic; + var channel = data.channel; + var res = store.manager.findChannel(channel); + + if (!data.user || !data.user.notifications || !data.user.curvePublic) { + return void cb({error: 'EINVAL'}); + } + + var href, title; + + if (!res.some(function (obj) { + if (obj.data && + Array.isArray(obj.data.owners) && obj.data.owners.indexOf(edPublic) !== -1 && + obj.data.href) { + href = obj.data.href; + title = obj.data.title; + return true; + } + })) { return void cb({error: 'ENOTFOUND'}); } + + var myData = Messaging.createData(store.proxy); + delete myData.channel; + store.mailbox.sendTo("GIVE_PAD_ACCESS", { + channel: channel, + href: href, + title: title, + user: myData + }, { + channel: data.user.notifications, + curvePublic: data.user.curvePublic + }); + cb(); + }; + // GET_FULL_HISTORY from sframe-common-outer Store.getFullHistory = function (clientId, data, cb) { var network = store.network; diff --git a/www/common/outer/mailbox-handlers.js b/www/common/outer/mailbox-handlers.js index 0ab804dcf..9d383caa1 100644 --- a/www/common/outer/mailbox-handlers.js +++ b/www/common/outer/mailbox-handlers.js @@ -1,6 +1,7 @@ define([ '/common/common-messaging.js', -], function (Messaging) { + '/common/common-hash.js', +], function (Messaging, Hash) { var getRandomTimeout = function (ctx) { var lag = ctx.store.realtime.getLag().lag || 0; @@ -156,6 +157,108 @@ define([ cb(true); }; + // Hide duplicates when receiving a SHARE_PAD notification: + // Keep only one notification per channel: the stronger and more recent one + var channels = {}; + handlers['SHARE_PAD'] = function (ctx, box, data, cb) { + var msg = data.msg; + var hash = data.hash; + var content = msg.content; + // content.name, content.title, content.href, content.password + + var channel = Hash.hrefToHexChannelId(content.href, content.password); + var parsed = Hash.parsePadUrl(content.href); + var mode = parsed.hashData && parsed.hashData.mode || 'n/a'; + + var old = channels[channel]; + var toRemove; + if (old) { + // New hash is weaker, ignore + if (old.mode === 'edit' && mode === 'view') { + return void cb(true); + } + // New hash is not weaker, clear the old one + toRemove = old.data; + } + + // Update the data + channels[channel] = { + mode: mode, + data: { + type: box.type, + hash: hash + } + }; + + cb(false, toRemove); + }; + removeHandlers['SHARE_PAD'] = function (ctx, box, data, hash) { + var content = data.content; + var channel = Hash.hrefToHexChannelId(content.href, content.password); + var old = channels[channel]; + if (old && old.data && old.data.hash === hash) { + delete channels[channel]; + } + }; + + // Hide duplicates when receiving a SUPPORT_MESSAGE notification + var supportMessage = false; + handlers['SUPPORT_MESSAGE'] = function (ctx, box, data, cb) { + if (supportMessage) { return void cb(true); } + supportMessage = true; + cb(); + }; + + // Incoming edit rights request: add data before sending it to inner + handlers['REQUEST_PAD_ACCESS'] = function (ctx, box, data, cb) { + var msg = data.msg; + var content = msg.content; + + if (msg.author !== content.user.curvePublic) { return void cb(true); } + + var channel = content.channel; + var res = ctx.store.manager.findChannel(channel); + + if (!res.length) { return void cb(true); } + + var edPublic = ctx.store.proxy.edPublic; + var title, href; + if (!res.some(function (obj) { + if (obj.data && + Array.isArray(obj.data.owners) && obj.data.owners.indexOf(edPublic) !== -1 && + obj.data.href) { + href = obj.data.href; + title = obj.data.filename || obj.data.title; + return true; + } + })) { return void cb(true); } + + content.title = title; + content.href = href; + cb(false); + }; + + handlers['GIVE_PAD_ACCESS'] = function (ctx, box, data, cb) { + var msg = data.msg; + var content = msg.content; + + if (msg.author !== content.user.curvePublic) { return void cb(true); } + + var channel = content.channel; + var res = ctx.store.manager.findChannel(channel); + + var title; + res.forEach(function (obj) { + if (obj.data && !obj.data.href) { + if (!title) { title = obj.data.filename || obj.data.title; } + obj.data.href = content.href; + } + }); + + content.title = title || content.title; + cb(false); + }; + return { add: function (ctx, box, data, cb) { /** diff --git a/www/common/outer/mailbox.js b/www/common/outer/mailbox.js index 550573411..081f64cc3 100644 --- a/www/common/outer/mailbox.js +++ b/www/common/outer/mailbox.js @@ -10,6 +10,8 @@ define([ var TYPES = [ 'notifications', + 'supportadmin', + 'support' ]; var BLOCKING_TYPES = [ ]; @@ -25,6 +27,16 @@ define([ if (res.error) { console.error(res); } }); } + if (!mailboxes['support']) { + mailboxes.support = { + channel: Hash.createChannelId(), + lastKnownHash: '', + viewed: [] + }; + ctx.pinPads([mailboxes.support.channel], function (res) { + if (res.error) { console.error(res); } + }); + } }; /* @@ -76,15 +88,32 @@ proxy.mailboxes = { var crypto = Crypto.Mailbox.createEncryptor(keys); var network = ctx.store.network; - var ciphertext = crypto.encrypt(JSON.stringify({ + var text = JSON.stringify({ type: type, content: msg - }), user.curvePublic); + }); + var ciphertext = crypto.encrypt(text, user.curvePublic); network.join(user.channel).then(function (wc) { wc.bcast(ciphertext).then(function () { cb(); - wc.leave(); + + // If we've just sent a message to one of our mailboxes, we have to trigger the handler manually + // (the server won't send back our message to us) + // If it isn't one of our mailboxes, we can close it now + var box; + if (Object.keys(ctx.boxes).some(function (t) { + var _box = ctx.boxes[t]; + if (_box.channel === user.channel) { + box = _box; + return true; + } + })) { + var hash = ciphertext.slice(0, 64); + box.onMessage(text, null, null, null, hash, user.curvePublic); + } else { + wc.leave(); + } }); }, function (err) { cb({error: err}); @@ -157,6 +186,8 @@ proxy.mailboxes = { var openChannel = function (ctx, type, m, onReady) { var box = ctx.boxes[type] = { + channel: m.channel, + type: type, queue: [], // Store the messages to send when the channel is ready history: [], // All the hashes loaded from the server in corretc order content: {}, // Content of the messages that should be displayed @@ -173,9 +204,10 @@ proxy.mailboxes = { if (!Crypto.Mailbox) { return void console.error("chainpad-crypto is outdated and doesn't support mailboxes."); } - var keys = getMyKeys(ctx); + var keys = m.keys || getMyKeys(ctx); if (!keys) { return void console.error("missing asymmetric encryption keys"); } var crypto = Crypto.Mailbox.createEncryptor(keys); + box.encryptor = crypto; var cfg = { network: ctx.store.network, channel: m.channel, @@ -213,8 +245,11 @@ proxy.mailboxes = { }); box.queue = []; }; - cfg.onMessage = function (msg, user, vKey, isCp, hash, author) { + var lastReceivedHash; // Don't send a duplicate of the last known hash on reconnect + box.onMessage = cfg.onMessage = function (msg, user, vKey, isCp, hash, author) { if (hash === m.lastKnownHash) { return; } + if (hash === lastReceivedHash) { return; } + lastReceivedHash = hash; try { msg = JSON.parse(msg); } catch (e) { @@ -228,8 +263,8 @@ proxy.mailboxes = { msg: msg, hash: hash }; - Handlers.add(ctx, box, message, function (toDismiss) { - if (toDismiss) { + Handlers.add(ctx, box, message, function (dismissed, toDismiss) { + if (dismissed) { // This message should be removed dismiss(ctx, { type: type, hash: hash @@ -238,6 +273,11 @@ proxy.mailboxes = { }); return; } + if (toDismiss) { // List of other messages to remove + dismiss(ctx, toDismiss, '', function () { + console.log('Notification handled automatically'); + }); + } box.content[hash] = msg; showMessage(ctx, type, message); }); @@ -293,6 +333,63 @@ proxy.mailboxes = { CpNetflux.start(cfg); }; + var initializeHistory = function (ctx) { + var network = ctx.store.network; + network.on('message', function (msg, sender) { + if (sender !== network.historyKeeper) { return; } + var parsed = JSON.parse(msg); + if (!/HISTORY_RANGE/.test(parsed[0])) { return; } + + var txid = parsed[1]; + var req = ctx.req[txid]; + if (!req) { return; } + var type = parsed[0]; + var _msg = parsed[2]; + var box = req.box; + + if (type === 'HISTORY_RANGE') { + if (!Array.isArray(_msg)) { return; } + var message; + try { + var decrypted = box.encryptor.decrypt(_msg[4]); + message = JSON.parse(decrypted.content); + } catch (e) { + console.log(e); + } + ctx.emit('HISTORY', { + txid: txid, + time: _msg[5], + message: message, + hash: _msg[4].slice(0,64) + }, [req.cId]); + } else if (type === 'HISTORY_RANGE_END') { + ctx.emit('HISTORY', { + txid: txid, + complete: true + }, [req.cId]); + delete ctx.req[txid]; + } + }); + }; + var loadHistory = function (ctx, clientId, data, cb) { + var box = ctx.boxes[data.type]; + if (!box) { return void cb({error: 'ENOENT'}); } + var msg = [ 'GET_HISTORY_RANGE', box.channel, { + from: data.lastKnownHash, + count: data.count, + txid: data.txid + } + ]; + ctx.req[data.txid] = { + cId: clientId, + box: box + }; + var network = ctx.store.network; + network.sendto(network.historyKeeper, JSON.stringify(msg)).then(function () { + }, function (err) { + console.error(err); + }); + }; var subscribe = function (ctx, data, cId, cb) { // Get existing notifications @@ -327,12 +424,14 @@ proxy.mailboxes = { updateMetadata: cfg.updateMetadata, emit: emit, clients: [], - boxes: {} + boxes: {}, + req: {} }; var mailboxes = store.proxy.mailboxes = store.proxy.mailboxes || {}; initializeMailboxes(ctx, mailboxes); + initializeHistory(ctx); Object.keys(mailboxes).forEach(function (key) { if (TYPES.indexOf(key) === -1) { return; } @@ -359,6 +458,11 @@ proxy.mailboxes = { }); }; + mailbox.open = function (key, m, cb) { + if (TYPES.indexOf(key) === -1) { return; } + openChannel(ctx, key, m, cb); + }; + mailbox.dismiss = function (data, cb) { dismiss(ctx, data, '', cb); }; @@ -382,6 +486,9 @@ proxy.mailboxes = { if (cmd === 'SENDTO') { return void sendTo(ctx, data.type, data.msg, data.user, cb); } + if (cmd === 'LOAD_HISTORY') { + return void loadHistory(ctx, clientId, data, cb); + } }; return mailbox; diff --git a/www/common/outer/onlyoffice.js b/www/common/outer/onlyoffice.js index 3b57b4123..70bc413ec 100644 --- a/www/common/outer/onlyoffice.js +++ b/www/common/outer/onlyoffice.js @@ -26,7 +26,10 @@ define([ if (!c.id) { c.id = chan.wc.myID + '-' + client; } chan.history.forEach(function (msg) { - ctx.emit('MESSAGE', msg, [client]); + ctx.emit('MESSAGE', { + msg: msg, + validateKey: chan.validateKey + }, [client]); }); // ==> And push the new tab to the list @@ -37,7 +40,8 @@ define([ var onOpen = function (wc) { ctx.channels[channel] = ctx.channels[channel] || { - history: [] + history: [], + validateKey: obj.validateKey }; chan = ctx.channels[channel]; @@ -61,7 +65,10 @@ define([ }); wc.on('message', function (msg) { chan.history.push(msg); - ctx.emit('MESSAGE', msg, chan.clients); + ctx.emit('MESSAGE', { + msg: msg, + validateKey: chan.validateKey + }, chan.clients); }); chan.wc = wc; @@ -101,6 +108,7 @@ define([ }; network.on('message', function (msg, sender) { + if (!ctx.channels[channel]) { return; } var hk = network.historyKeeper; if (sender !== hk) { return; } @@ -115,7 +123,12 @@ define([ // Keep only metadata messages for the current channel if (parsed.channel && parsed.channel !== channel) { return; } // Ignore the metadata message - if (parsed.validateKey && parsed.channel) { return; } + if (parsed.validateKey && parsed.channel) { + if (!chan.validateKey) { + chan.validateKey = parsed.validateKey; + } + return; + } // End of history: emit READY if (parsed.state && parsed.state === 1 && parsed.channel) { ctx.emit('READY', '', chan.clients); @@ -132,7 +145,9 @@ define([ if (hash === chan.lastKnownHash || hash === chan.lastCpHash) { return; } chan.lastKnownHash = hash; - ctx.emit('MESSAGE', msg, chan.clients); + ctx.emit('MESSAGE', { + msg: msg, + }, chan.clients); chan.history.push(msg); }); @@ -176,7 +191,9 @@ define([ return void chan.sendMsg(data.isCp, cb); } chan.sendMsg(data.msg, cb); - ctx.emit('MESSAGE', data.msg, chan.clients.filter(function (cl) { + ctx.emit('MESSAGE', { + msg: data.msg + }, chan.clients.filter(function (cl) { return cl !== clientId; })); }; diff --git a/www/common/outer/store-rpc.js b/www/common/outer/store-rpc.js index 4bc2dfbc8..bb8e0a674 100644 --- a/www/common/outer/store-rpc.js +++ b/www/common/outer/store-rpc.js @@ -78,12 +78,15 @@ define([ GET_FULL_HISTORY: Store.getFullHistory, GET_HISTORY_RANGE: Store.getHistoryRange, IS_NEW_CHANNEL: Store.isNewChannel, + REQUEST_PAD_ACCESS: Store.requestPadAccess, + GIVE_PAD_ACCESS: Store.givePadAccess, // Drive DRIVE_USEROBJECT: Store.userObjectCommand, // Settings, DELETE_ACCOUNT: Store.deleteAccount, // Admin ADMIN_RPC: Store.adminRpc, + ADMIN_ADD_MAILBOX: Store.addAdminMailbox, }; Rpc.query = function (cmd, data, cb) { diff --git a/www/common/outer/userObject.js b/www/common/outer/userObject.js index e67dd5f01..4f235366a 100644 --- a/www/common/outer/userObject.js +++ b/www/common/outer/userObject.js @@ -506,8 +506,14 @@ define([ var fixRoot = function (elem) { if (typeof(files[ROOT]) !== "object") { debug("ROOT was not an object"); files[ROOT] = {}; } var element = elem || files[ROOT]; + if (!element) { return console.error("Invalid element in root"); } var nbMetadataFolders = 0; for (var el in element) { + if (element[el] === null) { + console.error('element[%s] is null', el); + delete element[el]; + continue; + } if (exp.isFolderData(element[el])) { if (nbMetadataFolders !== 0) { debug("Multiple metadata files in folder"); @@ -625,6 +631,11 @@ define([ var root = exp.find([ROOT]); var toClean = []; for (var id in fd) { + if (String(id) !== String(Number(id))) { + debug("Invalid file ID in filesData.", id); + toClean.push(id); + continue; + } id = Number(id); var el = fd[id]; diff --git a/www/common/proxy-manager.js b/www/common/proxy-manager.js index 017a403bb..1257f3ad4 100644 --- a/www/common/proxy-manager.js +++ b/www/common/proxy-manager.js @@ -252,6 +252,9 @@ define([ var obj = Env.folders[el].proxy.metadata || {}; if (obj) { key = obj.title; } } else { + try { + el = JSON.parse(JSON.stringify(el)); + } catch (e) { return undefined; } userObject.getFilesRecursively(el, files); } @@ -342,7 +345,7 @@ define([ }); // Remove the elements from the old location (without unpinning) - Env.user.userObject.delete(resolved.main, waitFor()); + Env.user.userObject.delete(resolved.main, waitFor()); // FIXME waitFor() is called synchronously } } } @@ -369,7 +372,7 @@ define([ if (copy) { return; } // Remove the elements from the old location (without unpinning) - uoFrom.delete(paths, waitFor()); + uoFrom.delete(paths, waitFor()); // FIXME waitFor() is called synchronously } }); } @@ -707,6 +710,7 @@ define([ if (type === 'expirable') { return function (fileId) { var data = userObject.getFileData(fileId); + if (!data) { return; } // Don't push duplicates if (result.indexOf(data.channel) !== -1) { return; } // Return pads owned by someone else or expired by time @@ -718,6 +722,7 @@ define([ if (type === 'owned') { return function (fileId) { var data = userObject.getFileData(fileId); + if (!data) { return; } // Don't push duplicates if (result.indexOf(data.channel) !== -1) { return; } // Return owned pads @@ -729,6 +734,7 @@ define([ if (type === "pin") { return function (fileId) { var data = userObject.getFileData(fileId); + if (!data) { return; } // Don't pin pads owned by someone else if (_ownedByOther(Env, data.owners)) { return; } // Don't push duplicates diff --git a/www/common/sframe-app-framework.js b/www/common/sframe-app-framework.js index f1068a6de..af856d1d7 100644 --- a/www/common/sframe-app-framework.js +++ b/www/common/sframe-app-framework.js @@ -603,6 +603,7 @@ define([ 'newpad', 'share', 'limit', + 'request', 'unpinnedWarning', 'notifications' ], diff --git a/www/common/sframe-common-codemirror.js b/www/common/sframe-common-codemirror.js index ebf7d752e..8a0e2de63 100644 --- a/www/common/sframe-common-codemirror.js +++ b/www/common/sframe-common-codemirror.js @@ -323,7 +323,7 @@ define([ var mode; if (!mime) { var ext = /.+\.([^.]+)$/.exec(file.name); - if (ext[1]) { + if (ext && ext[1]) { mode = CMeditor.findModeByExtension(ext[1]); mode = mode && mode.mode || null; } @@ -339,7 +339,8 @@ define([ exp.setMode('text'); $toolbarContainer.find('#language-mode').val('text'); } - return { content: content }; + // return the mode so that the code editor can decide how to display the new content + return { content: content, mode: mode }; }; exp.setValueAndCursor = function (oldDoc, remoteDoc) { diff --git a/www/common/sframe-common-mailbox.js b/www/common/sframe-common-mailbox.js index 814966a22..cf2ea4516 100644 --- a/www/common/sframe-common-mailbox.js +++ b/www/common/sframe-common-mailbox.js @@ -47,30 +47,29 @@ define([ var formatData = function (data) { return JSON.stringify(data.content.msg.content); }; - var createElement = function (data) { + var createElement = mailbox.createElement = function (data) { var notif; - var dismissIcon = h('span.fa.fa-times'); - var dismiss = h('div.cp-notification-dismiss', { - title: Messages.notifications_dismiss - }, dismissIcon); - dismiss.addEventListener('click', function (e) { - e.preventDefault(); - e.stopPropagation(); - mailbox.dismiss(data, function (err) { - if (err) { return void console.error(err); } - /*if (notif && notif.parentNode) { - try { - notif.parentNode.removeChild(notif); - } catch (e) { console.error(e); } - }*/ - }); - }); notif = h('div.cp-notification', { 'data-hash': data.content.hash - }, [ - h('div.cp-notification-content', h('p', formatData(data))), - dismiss - ]); + }, [h('div.cp-notification-content', h('p', formatData(data)))]); + + if (data.content.getFormatText) { + $(notif).find('.cp-notification-content p').html(data.content.getFormatText()); + } + + if (data.content.isClickable) { + $(notif).find('.cp-notification-content').addClass("cp-clickable") + .click(data.content.handler); + } + if (data.content.isDismissible) { + var dismissIcon = h('span.fa.fa-times'); + var dismiss = h('div.cp-notification-dismiss', { + title: Messages.notifications_dismiss + }, dismissIcon); + $(dismiss).addClass("cp-clickable") + .click(data.content.dismissHandler); + $(notif).append(dismiss); + } return notif; }; @@ -80,7 +79,7 @@ define([ onViewedHandlers.push(function (data) { var hash = data.hash.replace(/"/g, '\\\"'); - var $notif = $('.cp-notification[data-hash="'+hash+'"]'); + var $notif = $('.cp-notification[data-hash="'+hash+'"]:not(.cp-app-notification-archived)'); if ($notif.length) { $notif.remove(); } @@ -90,8 +89,11 @@ define([ var pushMessage = function (data, handler) { var todo = function (f) { try { - var el = createElement(data); - Notifications.add(Common, data, el); + var el; + if (data.type === 'notifications') { + Notifications.add(Common, data); + el = createElement(data); + } f(data, el); } catch (e) { console.error(e); @@ -108,7 +110,9 @@ define([ onViewedHandlers.forEach(function (f) { try { f(data); - Notifications.remove(Common, data); + if (data.type === 'notifications') { + Notifications.remove(Common, data); + } } catch (e) { console.error(e); } @@ -118,7 +122,7 @@ define([ var onMessage = function (data) { // data = { type: 'type', content: {msg: 'msg', hash: 'hash'} } - console.log(data.content); + console.log(data.type, data.content); pushMessage(data); if (!history[data.type]) { history[data.type] = []; } history[data.type].push(data.content); @@ -139,18 +143,25 @@ define([ var subscribed = false; // Get all existing notifications + the new ones when they come - mailbox.subscribe = function (cfg) { + mailbox.subscribe = function (types, cfg) { if (!subscribed) { execCommand('SUBSCRIBE', null, function () {}); subscribed = true; } if (typeof(cfg.onViewed) === "function") { - onViewedHandlers.push(cfg.onViewed); + onViewedHandlers.push(function (data) { + if (types.indexOf(data.type) === -1) { return; } + cfg.onViewed(data); + }); } if (typeof(cfg.onMessage) === "function") { - onMessageHandlers.push(cfg.onMessage); + onMessageHandlers.push(function (data, el) { + if (types.indexOf(data.type) === -1) { return; } + cfg.onMessage(data, el); + }); } Object.keys(history).forEach(function (type) { + if (types.indexOf(type) === -1) { return; } history[type].forEach(function (data) { pushMessage({ type: type, @@ -160,6 +171,52 @@ define([ }); }; + var historyState = false; + var onHistory = function () {}; + mailbox.getMoreHistory = function (type, count, lastKnownHash, cb) { + if (historyState) { return void cb("ALREADY_CALLED"); } + historyState = true; + var txid = Util.uid(); + execCommand('LOAD_HISTORY', { + type: type, + count: lastKnownHash ? count + 1 : count, + txid: txid, + lastKnownHash: lastKnownHash + }, function (err, obj) { + if (obj && obj.error) { console.error(obj.error); } + }); + var messages = []; + onHistory = function (data) { + if (data.txid !== txid) { return; } + if (data.complete) { + onHistory = function () {}; + var end = messages.length < count; + cb(null, messages, end); + historyState = false; + return; + } + if (data.hash !== lastKnownHash) { + messages.push({ + type: type, + content: { + msg: data.message, + time: data.time, + hash: data.hash + } + }); + } + }; + }; + mailbox.getNotificationsHistory = function (type, count, lastKnownHash, cb) { + mailbox.getMoreHistory(type, count, lastKnownHash, function (err, messages, end) { + if (!Array.isArray(messages)) { return void cb(err); } + messages.forEach(function (data) { + data.content.archived = true; + Notifications.add(Common, data); + }); + cb(err, messages, end); + }); + }; // CHANNEL WITH WORKER @@ -167,6 +224,9 @@ define([ // obj = { ev: 'type', data: obj } var ev = obj.ev; var data = obj.data; + if (ev === 'HISTORY') { + return void onHistory(data); + } if (ev === 'MESSAGE') { return void onMessage(data); } diff --git a/www/common/sframe-common-outer.js b/www/common/sframe-common-outer.js index 838f415e4..81ea43d81 100644 --- a/www/common/sframe-common-outer.js +++ b/www/common/sframe-common-outer.js @@ -223,6 +223,11 @@ define([ sframeChan.event("EV_PAD_PASSWORD"); }; + if (!val && sessionStorage.newPadPassword) { + val = sessionStorage.newPadPassword; + delete sessionStorage.newPadPassword; + } + if (val) { password = val; Cryptpad.getFileSize(window.location.href, password, waitFor(function (e, size) { @@ -936,6 +941,19 @@ define([ sframeChan.event('EV_WORKER_TIMEOUT'); }); + sframeChan.on('EV_GIVE_ACCESS', function (data, cb) { + Cryptpad.padRpc.giveAccess(data, cb); + }); + sframeChan.on('Q_REQUEST_ACCESS', function (data, cb) { + if (readOnly && hashes.editHash) { + return void cb({error: 'ALREADYKNOWN'}); + } + Cryptpad.padRpc.requestAccess({ + send: data, + channel: secret.channel + }, cb); + }); + if (cfg.messaging) { Notifier.getPermission(); diff --git a/www/common/sframe-common-title.js b/www/common/sframe-common-title.js index 496cbfcb9..5f748347c 100644 --- a/www/common/sframe-common-title.js +++ b/www/common/sframe-common-title.js @@ -79,7 +79,8 @@ define([ return { updateTitle: exp.updateTitle, suggestName: suggestTitle, - defaultName: exp.defaultTitle + defaultName: exp.defaultTitle, + getTitle: exp.getTitle }; }; diff --git a/www/common/toolbar3.js b/www/common/toolbar3.js index 303a983e0..6c262e2d1 100644 --- a/www/common/toolbar3.js +++ b/www/common/toolbar3.js @@ -534,8 +534,11 @@ MessengerUI, Messages) { hidden: true }); $shareBlock.click(function () { + var title = (config.title && config.title.getTitle && config.title.getTitle()) + || (config.title && config.title.defaultName) + || ""; Common.getSframeChannel().event('EV_SHARE_OPEN', { - title: Common.getMetadataMgr().getMetadata().title + title: title }); }); @@ -559,7 +562,10 @@ MessengerUI, Messages) { file: true }); $shareBlock.click(function () { + var title = (config.title && config.title.getTitle && config.title.getTitle()) + || ""; Common.getSframeChannel().event('EV_SHARE_OPEN', { + title: title, file: true }); }); @@ -568,6 +574,48 @@ MessengerUI, Messages) { return $shareBlock; }; + var createRequest = function (toolbar, config) { + console.error('test'); + if (!config.metadataMgr) { + throw new Error("You must provide a `metadataMgr` to display the request access button"); + } + + // We can only requets more access if we're in read-only mode + if (config.readOnly !== 1) { return; } + + var $requestBlock = $('