diff --git a/CHANGELOG.md b/CHANGELOG.md index a4f63651f..a056e5d56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,50 @@ +# HimalayanQuail (3.7.0) + +## Goals + +As we are getting closer to the end of our CryptPad Teams project we planned to spend this release addressing some of the difficulties that users have reported regarding the usage of our newer social features. + +## Update notes + +This release includes an upgrade to a newer version of JQuery which mitigates a minor vulnerability which could have contributed to the presence of an XSS attack. We weren't using the affected methods in the library, but there's no harm in updating as it will protect against the vulnerability affecting user data in the future. + +We've also made some non-critical fixes to the server code, so you'll need to restart after pulling the latest code to take advantage of these improvements. + +Update to 3.7.0 from 3.6.0 using the normal update procedure: + +1. stop your server +2. pull the latest code via git +3. run `bower update` +4. restart your server + +If you're using an up-to-date version of NPM you should find that running `npm update` prints a notice that one of the packages you've installed is seeking funding. Entering `npm fund` will print information about our OpenCollective funding campaign. If you're running a slightly older version of NPM and you wish to support CryptPad's development you can do so by visiting https://opencollective.com/cryptpad . + +## Features + +* Many users have contacted us via support tickets to ask how to add contacts on the platform. The easiest way is to share the link to your profile page. Once on that page registered users will be able to send a contact request which will appear in your notification tray. Because we believe you shouldn't have to read a manual to use CryptPad (and because we want to minimize how much time we spend answering support tickets) we've integrated this tip into the UI itself. Users that don't have any contacts on the platform will hopefully notice that the sharing menu's contacts tab now prompts them with this information, followed by a button to copy their profile page's URL to their clipboard. +* We've made a lot of other small changes that we hope will have a big impact on the usability of the sharing menu: + * the "Link" section of the modal which includes the URL generated from your chosen access rights has been restyled so that the URL is displayed in a multiline textarea so that users can better see the URL changing as they play with the other controls + * both the "Contacts" and "Link" section include short, unintrusive hints about how passwords interact with the different sharing methods: + * when sharing via a URL we indicate that the recipient will need to enter a password, allowing for the URL to be sent over an insecure channel without leaking your document's content + * when sharing directly with a contact via their encrypted mailbox the password is transferred automatically, since it is assumed that you intend for the recipient to gain access and the platform provides a secure channel through which all the relevant information can be delivered + * this information is only included in cases when the document is protected with a password to limit the amount of information the user has to process to complete their task + * we include brief and dismissable warning within the menu which indicates that URLs provide non-revocable access to documents so that new users of the platform understand the consequences of sharing + * in general we've tried to make the appearance of the modal more appealing and intuitive so that users naturally discover and adopt the workflows which are the most conducive to their privacy and security +* Our premium accounts platform authenticates that you are logged in on a given CryptPad instance by loading it in an iframe and requesting that it use one of your account's cryptographic keys to sign a message. Unfortunately, this process could be quite slow as it would load your CryptDrive and other information related to account, and some users reported that their browser timed out on this process. We've addressed this by loading only the account information required to prove your identity. +* We've also included some changes to CryptPad's server to allow users to share quotas between multiple accounts, though we still have work to do to make this behaviour functional on the web client. +* Spreadsheets now support password change! +* Kanban boards now render long column titles in a much more intuitive way, wrapping the text instead of truncating it. +* Our code editor now features support for Gantt charts in markdown mode via an improved Mermaidjs integration. We've also slowed down the rendering cycle so that updates are displayed once you stop typing for 400ms instead of 150ms, and improved the rendering methods so that all mermaid-generated charts are only redrawn if they have changed since the last time they were rendered. This results in a smoother reading experience while permitting other users to continue to edit the document. +* Finally, after a review of the code responsible for sanitizing the markdown code which we render as HTML, we've decided to remove SVG tags from our sanitizer's filter. This means that you can write SVG markup in the input field and see it rendered, in case you're into that kind of thing. + +## Bug fixes + +* It seems our "contacts" app broke along with the 3.5.0 release and nobody reported it. The regression was introduced when we made some changes to the teams chat integration. We've addressed the issue so that you can once again use the contacts app to chat directly with friends. +* We've found and fixed a "memory puddle" (a non-critical memory leak which was automatically mopped up every now and then). The fix probably won't have much noticeable impact but the server is now a little bit more correct +* We stumbled across a bug which wiped out the contents of a Kanban board and caused the application to crash if you navigated to the affected version of the document in history mode. If you notice that one of your documents was affected please contact us and we'll write a guide instructing you how to recover your content. +* We've found a few bugs lurking in our server which could have caused the amount of data stored in users' drives to be calculated incorrectly under very unlikely circumstances. We've fixed the issue and addressed a number of similar asynchrony-related code paths which should mitigate similar issues in the future. +* Lastly, we spotted some flaws in the code responsible for encrypting pad credentials in shared folders and teams such that viewers don't automatically gain access to the editing keys of a document when they should only have view access. There weren't any access control vulnerabilities, but an error was thrown under rare circumstances which could prevent affected users' drives from loading. We've guarded against the cause and made it such that any affected users will automatically repair their damaged drives. + # GoldenFrog release (3.6.0) ## Goals diff --git a/customize.dist/pages.js b/customize.dist/pages.js index b4cc4a0a4..0bb596ec8 100644 --- a/customize.dist/pages.js +++ b/customize.dist/pages.js @@ -103,7 +103,7 @@ define([ ])*/ ]) ]), - h('div.cp-version-footer', "CryptPad v3.6.0 (GoldenFrog)") + h('div.cp-version-footer', "CryptPad v3.7.0 (HimalayanQuail)") ]); }; diff --git a/customize.dist/src/less2/include/alertify.less b/customize.dist/src/less2/include/alertify.less index 80675b65a..6569de99b 100644 --- a/customize.dist/src/less2/include/alertify.less +++ b/customize.dist/src/less2/include/alertify.less @@ -160,6 +160,9 @@ margin-bottom: @alertify_padding-base; margin: 0; overflow: auto; + :last-child { + margin-bottom: 0; + } } .alertify-tabs { max-height: 100%; @@ -219,6 +222,7 @@ ::-ms-input-placeholder { /* Microsoft Edge */ color: @cryptpad_color_grey; } + span.cp-password-container { display: flex; align-items: center; @@ -413,5 +417,33 @@ overflow-x: auto; } } + // Bootstrap Alerts + .alert { + margin: 0px 0px @alertify_padding-base 0px; + font-size: 12px; + padding: 5px; + border-radius: 0px; + i { + margin-right: 10px; + } + &.alert-primary { + background-color: @alertify-base; + color: @alertify-fg; + border-color: @alertify-fg; + a { + color: @alertify-fg; + text-decoration: underline; + } + } + &.dismissable { + display: flex; + align-items: center; + span.fa-times { + font-size: @colortheme_app-font-size; + margin-left: 20px; + cursor: pointer; + } + } + } } diff --git a/customize.dist/src/less2/include/avatar.less b/customize.dist/src/less2/include/avatar.less index 87b4a3a32..c85c34877 100644 --- a/customize.dist/src/less2/include/avatar.less +++ b/customize.dist/src/less2/include/avatar.less @@ -1,9 +1,12 @@ @import (reference) "./tools.less"; +@import (reference) "./colortheme-all.less"; .avatar_vars( @width: 30px ) { @avatar-width: @width; @avatar-font-size: @width / 1.2; + @avatar-default-bg: #D9D8D8; + @avatar-default-fg: darken(@avatar-default-bg, 40%); } .avatar_main(@width: 30px) { --LessLoader_require: LessLoader_currentFile(); @@ -30,16 +33,16 @@ justify-content: center; align-items: center; - border-radius: 4px; overflow: hidden; box-sizing: content-box; } .cp-avatar-default { .tools_unselectable(); - background: white; - color: black; + background: @avatar-default-bg; + color: @avatar-default-fg; font-size: @avatar-font-size; font-size: var(--avatar-font-size); + text-transform: capitalize; } media-tag { min-height: @avatar-width; diff --git a/customize.dist/src/less2/include/buttons.less b/customize.dist/src/less2/include/buttons.less index 10b759fd4..f3cb50e17 100644 --- a/customize.dist/src/less2/include/buttons.less +++ b/customize.dist/src/less2/include/buttons.less @@ -23,6 +23,15 @@ } } + textarea { + overflow: hidden; + padding: 8px; + &[readonly] { + resize: none; + } + } + + button:not(.pure-button):not(.md-button):not(.mdl-button) { background-color: @colortheme_alertify-cancel; diff --git a/customize.dist/src/less2/include/modals-ui-elements.less b/customize.dist/src/less2/include/modals-ui-elements.less index 6e3921781..21707eab8 100644 --- a/customize.dist/src/less2/include/modals-ui-elements.less +++ b/customize.dist/src/less2/include/modals-ui-elements.less @@ -1,12 +1,16 @@ @import (reference) "./colortheme-all.less"; - +@import (reference) "./variables.less"; .modals-ui-elements_main() { --LessLoader_require: LessLoader_currentFile(); } & { + .cp-spacer { + height: @variables_padding; + } // Share modal .msg.cp-inline-radio-group { overflow: unset !important; + padding: 0px @variables_padding; .radio-group { display: flex; flex-direction: row; diff --git a/customize.dist/src/less2/include/password-input.less b/customize.dist/src/less2/include/password-input.less index 79824bac7..0f476390a 100644 --- a/customize.dist/src/less2/include/password-input.less +++ b/customize.dist/src/less2/include/password-input.less @@ -1,3 +1,4 @@ +@import (reference) "./colortheme-all.less"; .password_main() { --LessLoader_require: LessLoader_currentFile(); } @@ -17,7 +18,7 @@ justify-content: center; cursor: pointer; &:hover { - background-color: rgba(0,0,0,0.1); + color: darken(@colortheme_alertify-primary, 10%); } } } diff --git a/customize.dist/src/less2/include/usergrid.less b/customize.dist/src/less2/include/usergrid.less index 4ff807638..6ba8c2d07 100644 --- a/customize.dist/src/less2/include/usergrid.less +++ b/customize.dist/src/less2/include/usergrid.less @@ -6,16 +6,18 @@ --LessLoader_require: LessLoader_currentFile(); }; & { + .cp-usergrid-container { + margin-bottom: 12px !important; // even when last child of .msg .cp-usergrid-grid { display: flex; flex-wrap: wrap; + margin: -3px; margin-bottom: 6px; - } - &:not(.large) { - .cp-usergrid-grid { - margin: -3px; - margin-bottom: 6px; + max-height: 130px; + overflow-y: auto; + @media screen and (max-height: 515px) { + max-height: unset; // remove double scrollbar } } &.cp-usergrid-empty { @@ -28,17 +30,22 @@ input { flex: 1; min-width: 0; + margin: 0; margin-bottom: 0 !important; + height: 38px; &::placeholder { /* Chrome, Firefox, Opera, Safari 10.1+ */ color: @cryptpad_color_grey; opacity: 1; /* Firefox */ } } - margin-bottom: 15px; + margin-bottom: 10px; &:empty { margin: 0; display: none; } + button:last-child { + margin-right: 0px !important; + } } .cp-usergrid-user { width: 70px; @@ -58,33 +65,48 @@ background-color: @colortheme_alertify-primary; color: @colortheme_alertify-primary-text; order: -1 !important; + .cp-usergrid-avatar { + media-tag, .cp-avatar-default { + opacity: 0.7; + } + } } .cp-usergrid-user-avatar { min-height: 40px; } + .cp-usergrid-user-name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; width: 100%; text-align: center; - line-height: 18px; + line-height: 20px; + flex: 1; } - border: 1px solid @colortheme_alertify-primary; &:not(.large) { .avatar_main(40px); } &.large { .avatar_main(25px); - width: 140px; + width: 145px; height: 35px; flex-flow: row; - margin: 0; - margin-right: 15px; - margin-bottom: 1px; - &:nth-child(3n) { - margin-right: 0; + margin: 3px; + flex-basis: calc(33.3333333% - 6px); + flex-shrink: 1; + min-width: 0; + .cp-usergrid-user-name { + margin-left: 5px; + text-align: left; + line-height: 150%; + color: @cryptpad_text_col; + } + } + &.cp-selected { + .cp-usergrid-user-name { + color: @colortheme_alertify-primary-text; } } } diff --git a/lib/batch-read.js b/lib/batch-read.js index 3e729e66d..2852a0579 100644 --- a/lib/batch-read.js +++ b/lib/batch-read.js @@ -8,6 +8,8 @@ If the result of IO or computation is requested while an identical request is already in progress, wait until the first one completes and provide its result to every routine that requested it. +Asynchrony is guaranteed. + ## Usage Provide: @@ -51,11 +53,12 @@ module.exports = function (/* task */) { var args = Array.prototype.slice.call(arguments); //if (map[id] && map[id].length > 1) { console.log("BATCH-READ DID ITS JOB for [%s][%s]", task, id); } - - map[id].forEach(function (h) { - h.apply(null, args); + setTimeout(function () { + map[id].forEach(function (h) { + h.apply(null, args); + }); + delete map[id]; }); - delete map[id]; }); }; }; diff --git a/lib/write-queue.js b/lib/write-queue.js index c1b64ebaf..c82b12b3b 100644 --- a/lib/write-queue.js +++ b/lib/write-queue.js @@ -4,7 +4,7 @@ q(id, function (next) { // whatever you need to do.... // when you're done - next(); + next(); // guaranteed to be asynchronous :D }); */ @@ -16,9 +16,11 @@ module.exports = function () { var map = {}; var next = function (id) { - if (map[id] && map[id].length === 0) { return void delete map[id]; } - var task = map[id].shift(); - task(fix1(next, id)); + setTimeout(function () { + if (map[id] && map[id].length === 0) { return void delete map[id]; } + var task = map[id].shift(); + task(fix1(next, id)); + }); }; return function (id, task) { diff --git a/package-lock.json b/package-lock.json index ac23d00fa..6643e22c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "cryptpad", - "version": "3.6.0", + "version": "3.7.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index a84f9ef6c..ea1431153 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cryptpad", "description": "realtime collaborative visual editor with zero knowlege server", - "version": "3.6.0", + "version": "3.7.0", "license": "AGPL-3.0+", "repository": { "type": "git", diff --git a/storage/file.js b/storage/file.js index 0f97527ca..402982f6b 100644 --- a/storage/file.js +++ b/storage/file.js @@ -102,7 +102,9 @@ var getMetadataAtPath = function (Env, path, _cb) { var closeChannel = function (env, channelName, cb) { if (!env.channels[channelName]) { return void cb(); } try { - env.channels[channelName].writeStream.close(); + if (typeof(Util.find(env, [ 'channels', channelName, 'writeStream', 'close'])) === 'function') { + env.channels[channelName].writeStream.close(); + } delete env.channels[channelName]; env.openFiles--; cb(); diff --git a/www/auth/main.js b/www/auth/main.js index 7930e96e0..1b3667377 100644 --- a/www/auth/main.js +++ b/www/auth/main.js @@ -1,12 +1,17 @@ define([ 'jquery', - '/common/cryptpad-common.js', + '/common/cryptget.js', + '/common/pinpad.js', '/common/common-constants.js', '/common/outer/local-store.js', + '/common/outer/login-block.js', + '/common/outer/network-config.js', + '/customize/login.js', '/common/test.js', '/bower_components/nthen/index.js', + '/bower_components/netflux-websocket/netflux-client.js', '/bower_components/tweetnacl/nacl-fast.min.js' -], function ($, Cryptpad, Constants, LocalStore, Test, nThen) { +], function ($, Crypt, Pinpad, Constants, LocalStore, Block, NetConfig, Login, Test, nThen, Netflux) { var Nacl = window.nacl; var signMsg = function (msg, privKey) { @@ -27,9 +32,57 @@ define([ sessionStorage[Constants.userHashKey]; var proxy; + var rpc; + var network; + var rpcError; + + var loadProxy = function (hash) { + nThen(function (waitFor) { + var wsUrl = NetConfig.getWebsocketURL(); + var w = waitFor(); + Netflux.connect(wsUrl).then(function (_network) { + network = _network; + w(); + }, function (err) { + rpcError = err; + console.error(err); + }); + }).nThen(function (waitFor) { + Crypt.get(hash, waitFor(function (err, val) { + if (err) { + waitFor.abort(); + console.error(err); + return; + } + try { + var parsed = JSON.parse(val); + proxy = parsed; + } catch (e) { + console.log("Can't parse user drive", e); + } + }), { + network: network + }); + }).nThen(function (waitFor) { + if (!network) { return void waitFor.abort(); } + Pinpad.create(network, proxy, waitFor(function (e, call) { + if (e) { + rpcError = e; + return void waitFor.abort(); + } + rpc = call; + })); + }).nThen(function () { + Test(function () { + // This is only here to maybe trigger an error. + window.drive = proxy['drive']; + Test.passed(); + }); + }); + }; var whenReady = function (cb) { - if (proxy) { return void cb(); } + if (proxy && (rpc || rpcError)) { return void cb(); } console.log('CryptPad not ready...'); setTimeout(function () { whenReady(cb); @@ -45,6 +98,17 @@ define([ console.log('CP receiving', data); if (data.cmd === 'PING') { ret.res = 'PONG'; + } else if (data.cmd === 'LOGIN') { + Login.loginOrRegister(data.data.name, data.data.password, false, false, function (err) { + if (err) { + ret.error = 'LOGIN_ERROR'; + srcWindow.postMessage(JSON.stringify(ret), domain); + return; + } + loadProxy(LocalStore.getUserHash()); + srcWindow.postMessage(JSON.stringify(ret), domain); + }); + return; } else if (data.cmd === 'SIGN') { if (!AUTHORIZED_DOMAINS.filter(function (x) { return x.test(domain); }).length) { ret.error = "UNAUTH_DOMAIN"; @@ -63,7 +127,16 @@ define([ } } else if (data.cmd === 'UPDATE_LIMIT') { return void whenReady(function () { - Cryptpad.updatePinLimit(function (e, limit, plan, note) { + if (rpcError) { + // Tell the user on accounts that there was an issue and they need to wait maximum 24h or contact an admin + ret.warning = true; + srcWindow.postMessage(JSON.stringify(ret), domain); + return; + } + rpc.updatePinLimits(function (e, limit, plan, note) { + if (e) { + ret.warning = true; + } ret.res = [limit, plan, note]; srcWindow.postMessage(JSON.stringify(ret), domain); }); @@ -74,18 +147,8 @@ define([ srcWindow.postMessage(JSON.stringify(ret), domain); }); - nThen(function (waitFor) { - Cryptpad.ready(waitFor()); - }).nThen(function (waitFor) { - Cryptpad.getUserObject(null, waitFor(function (obj) { - proxy = obj; - })); - }).nThen(function () { - console.log('IFRAME READY'); - Test(function () { - // This is only here to maybe trigger an error. - window.drive = proxy['drive']; - Test.passed(); - }); - }); + var userHash = LocalStore.getUserHash(); + if (userHash) { + loadProxy(userHash); + } }); diff --git a/www/code/inner.js b/www/code/inner.js index 7c5e64c4f..e2ed086de 100644 --- a/www/code/inner.js +++ b/www/code/inner.js @@ -131,7 +131,7 @@ define([ if (['markdown', 'gfm'].indexOf(CodeMirror.highlightMode) === -1) { return; } if (!$previewButton.is('.cp-toolbar-button-active')) { return; } forceDrawPreview(); - }, 150); + }, 400); var previewTo; $previewButton.click(function () { diff --git a/www/code/mermaid.css b/www/code/mermaid.css index 769933f0a..60c366bb6 100644 --- a/www/code/mermaid.css +++ b/www/code/mermaid.css @@ -121,6 +121,10 @@ text.actor { font-size: 11px; text-height: 14px; } +.sectionTitle, .titleText { + font-weight: bold; +} + /* Grid and axis */ .grid .tick { stroke: lightgrey; diff --git a/www/common/common-interface.js b/www/common/common-interface.js index 3c758a88b..4528b7a1b 100644 --- a/www/common/common-interface.js +++ b/www/common/common-interface.js @@ -127,6 +127,18 @@ define([ return input; }; + dialog.selectableArea = function (value, opt) { + var attrs = merge({ + readonly: 'readonly', + }, opt); + + var input = h('textarea', attrs); + $(input).val(value).click(function () { + input.select(); + }); + return input; + }; + dialog.okButton = function (content, classString) { var sel = typeof(classString) === 'string'? 'button.ok.' + classString:'button.ok.primary'; return h(sel, { tabindex: '2', }, content || Messages.okButton); @@ -463,7 +475,7 @@ define([ opt = opt || {}; var inputBlock = opt.password ? UI.passwordInput() : dialog.textInput(); - var input = opt.password ? $(inputBlock).find('input')[0] : inputBlock; + var input = $(inputBlock).is('input') ? inputBlock : $(inputBlock).find('input')[0]; input.value = typeof(def) === 'string'? def: ''; var message; diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index 6450ced47..64ae9dd0d 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -147,6 +147,7 @@ define([ : Messages.owner_removeText; var removeCol = UIElements.getUserGrid(msg, { common: common, + large: true, data: _owners, noFilter: true }, function () { @@ -238,6 +239,7 @@ define([ }); var addCol = UIElements.getUserGrid(Messages.owner_addText, { common: common, + large: true, data: _friends }, function () { //console.log(arguments); @@ -254,6 +256,7 @@ define([ }); var teamsList = UIElements.getUserGrid(Messages.owner_addTeamText, { common: common, + large: true, noFilter: true, data: teamsData }, function () {}); @@ -551,9 +554,10 @@ define([ $d.append(password); } - if (!data.noEditPassword && owned && parsed.type !== "sheet") { // FIXME SHEET fix password change for sheets + if (!data.noEditPassword && owned) { // FIXME SHEET fix password change for sheets var sframeChan = common.getSframeChannel(); + var isOO = parsed.type === 'sheet'; var isFile = parsed.hashData.type === 'file'; var isSharedFolder = parsed.type === 'drive'; @@ -586,7 +590,8 @@ define([ UI.confirm(changePwConfirm, function (yes) { if (!yes) { pLocked = false; return; } $(passwordOk).html('').append(h('span.fa.fa-spinner.fa-spin', {style: 'margin-left: 0'})); - var q = isFile ? 'Q_BLOB_PASSWORD_CHANGE' : 'Q_PAD_PASSWORD_CHANGE'; + var q = isFile ? 'Q_BLOB_PASSWORD_CHANGE' : + (isOO ? 'Q_OO_PASSWORD_CHANGE' : 'Q_PAD_PASSWORD_CHANGE'); // If this is a file password change, register to the upload events: // * if there is a pending upload, ask if we shoudl interrupt @@ -737,12 +742,22 @@ define([ UIElements.getProperties = function (common, data, cb) { var c1; var c2; + var button = [{ + className: 'primary', + name: Messages.okButton, + onClick: function () {}, + keys: [13] + }]; NThen(function (waitFor) { getPadProperties(common, data, waitFor(function (e, c) { - c1 = c[0]; + c1 = UI.dialog.customModal(c[0], { + buttons: button + }); })); getRightsProperties(common, data, waitFor(function (e, c) { - c2 = c[0]; + c2 = UI.dialog.customModal(c[0], { + buttons: button + }); })); }).nThen(function () { var tabs = UI.dialog.tabs([{ @@ -782,8 +797,6 @@ define([ var noOthers = icons.length === 0 ? '.cp-usergrid-empty' : ''; - var buttonSelect = h('button', Messages.share_selectAll); - var buttonDeselect = h('button', Messages.share_deselectAll); var inputFilter = h('input', { placeholder: Messages.share_filterFriend }); @@ -791,9 +804,7 @@ define([ var div = h('div.cp-usergrid-container' + noOthers + (config.large?'.large':''), [ label ? h('label', label) : undefined, h('div.cp-usergrid-filter', (config.noFilter || config.noSelect) ? undefined : [ - inputFilter, - buttonSelect, - buttonDeselect + inputFilter ]), ]); var $div = $(div); @@ -806,23 +817,8 @@ define([ $div.find('.cp-usergrid-user:not(.cp-selected):not([data-name*="'+name+'"])').hide(); } }; - $(inputFilter).on('keydown keyup change', redraw); - $(buttonSelect).click(function () { - $div.find('.cp-usergrid-user:not(.cp-selected):visible').addClass('cp-selected'); - onSelect(); - }); - $(buttonDeselect).click(function () { - $div.find('.cp-usergrid-user.cp-selected').removeClass('cp-selected').each(function (i, el) { - var order = $(el).attr('data-order'); - if (!order) { return; } - $(el).attr('style', 'order:'+order); - }); - redraw(); - onSelect(); - }); - $(div).append(h('div.cp-usergrid-grid', icons)); if (!config.noSelect) { $div.on('click', '.cp-usergrid-user', function () { @@ -880,10 +876,11 @@ define([ delete friends[curve]; }); - var friendsList = UIElements.getUserGrid(null, { + var friendsList = UIElements.getUserGrid(Messages.share_linkFriends, { common: common, data: friends, - noFilter: false + noFilter: false, + large: true }, refreshButtons); var friendDiv = friendsList.div; $div.append(friendDiv); @@ -909,6 +906,7 @@ define([ var teamsList = UIElements.getUserGrid(Messages.share_linkTeam, { common: common, noFilter: true, + large: true, data: teams }, refreshButtons); $div.append(teamsList.div); @@ -1002,10 +1000,53 @@ define([ }); return { content: div, - button: shareButton + buttons: [shareButton] }; }; + var noContactsMessage = function(common){ + var metadataMgr = common.getMetadataMgr(); + var data = metadataMgr.getUserData(); + var origin = metadataMgr.getPrivateData().origin; + if (common.isLoggedIn()) { + return { + content: h('p', Messages.share_noContactsLoggedIn), + buttons: [{ + className: 'secondary', + name: Messages.share_copyProfileLink, + onClick: function () { + var profile = data.profile ? (origin + '/profile/#' + data.profile) : ''; + var success = Clipboard.copy(profile); + if (success) { UI.log(Messages.shareSuccess); } + }, + keys: [13] + }] + }; + } else { + return { + content: h('p', Messages.share_noContactsNotLoggedIn), + buttons: [{ + className: 'secondary', + name: Messages.login_register, + onClick: function () { + common.setLoginRedirect(function () { + common.gotoURL('/register/'); + }); + } + }, { + className: 'secondary', + name: Messages.login_login, + onClick: function () { + common.setLoginRedirect(function () { + common.gotoURL('/login/'); + }); + } + } + ] + }; + } + }; + UIElements.createShareModal = function (config) { var origin = config.origin; var pathname = config.pathname; @@ -1014,12 +1055,29 @@ define([ if (!hashes || (!hashes.editHash && !hashes.viewHash)) { return; } + // check if the pad is password protected + var hash = hashes.editHash || hashes.viewHash; + var href = origin + pathname + '#' + hash; + var parsedHref = Hash.parsePadUrl(href); + var hasPassword = parsedHref.hashData.password; + + var makeFaqLink = function () { + var link = h('span', [ + h('i.fa.fa-question-circle'), + h('a', {href: '#'}, Messages.passwordFaqLink) + ]); + $(link).click(function () { + common.openURL(config.origin + "/faq.html#security-pad_password"); + }); + return link; + }; + + var parsed = Hash.parsePadUrl(pathname); var canPresent = ['code', 'slide'].indexOf(parsed.type) !== -1; var rights = h('div.msg.cp-inline-radio-group', [ h('label', Messages.share_linkAccess), - h('br'), h('div.radio-group',[ UI.createRadio('accessRights', 'cp-share-editable-false', Messages.share_linkView, true, { mark: {tabindex:1} }), @@ -1066,9 +1124,42 @@ define([ h('br'), ] : [ UI.createCheckbox('cp-share-embed', Messages.share_linkEmbed, false, { mark: {tabindex:1} }), - h('br'), ]; - linkContent.push(UI.dialog.selectable('', { id: 'cp-share-link-preview', tabindex: 1 })); + linkContent.push(h('div.cp-spacer')); + linkContent.push(UI.dialog.selectableArea('', { id: 'cp-share-link-preview', tabindex: 1, rows:3})); + + // Show alert if the pad is password protected + if (hasPassword) { + linkContent.push(h('div.alert.alert-primary', [ + h('i.fa.fa-lock'), + Messages.share_linkPasswordAlert, h('br'), + makeFaqLink() + ])); + } + + // warning about sharing links + var localStore = window.cryptpadStore; + var dismissButton = h('span.fa.fa-times'); + var shareLinkWarning = h('div.alert.alert-warning.dismissable', + { style: 'display: none;' }, + [ + h('span.cp-inline-alert-text', Messages.share_linkWarning), + dismissButton + ]); + linkContent.push(shareLinkWarning); + + localStore.get('hide-alert-shareLinkWarning', function (val) { + if (val === '1') { return; } + $(shareLinkWarning).show(); + + $(dismissButton).on('click', function () { + localStore.put('hide-alert-shareLinkWarning', '1'); + $(shareLinkWarning).remove(); + }); + + }); + + var link = h('div.cp-share-modal', linkContent); var $link = $(link); @@ -1125,21 +1216,32 @@ define([ var hasFriends = Object.keys(config.friends || {}).length !== 0; var onFriendShare = Util.mkEvent(); - var friendsObject = hasFriends ? createShareWithFriends(config, onFriendShare, getLinkValue) : { - content: h('p', Messages.team_noFriend), - button: {} - }; + + + var friendsObject = hasFriends ? createShareWithFriends(config, onFriendShare, getLinkValue) : noContactsMessage(common); var friendsList = friendsObject.content; onFriendShare.reg(saveValue); // XXX Don't display access rights if no contacts var contactsContent = h('div.cp-share-modal'); - $(contactsContent).append(friendsList); + var $contactsContent = $(contactsContent); + + $contactsContent.append(friendsList); + + // Show alert if the pad is password protected + if (hasPassword) { + $contactsContent.append(h('div.alert.alert-primary', [ + h('i.fa.fa-unlock'), + Messages.share_contactPasswordAlert, h('br'), + makeFaqLink() + ])); + } - var contactButtons = [makeCancelButton(), - friendsObject.button]; + var contactButtons = friendsObject.buttons; + contactButtons.unshift(makeCancelButton()); + var frameContacts = UI.dialog.customModal(contactsContent, { buttons: contactButtons, onClose: config.onClose, @@ -1154,9 +1256,18 @@ define([ }; var embedContent = [ h('p', Messages.viewEmbedTag), - h('br'), - UI.dialog.selectable(getEmbedValue(), { id: 'cp-embed-link-preview', tabindex: 1 }) + UI.dialog.selectableArea(getEmbedValue(), { id: 'cp-embed-link-preview', tabindex: 1, rows: 3}) ]; + + // Show alert if the pad is password protected + if (hasPassword) { + embedContent.push(h('div.alert.alert-primary', [ + h('i.fa.fa-lock'), ' ', + Messages.share_embedPasswordAlert, h('br'), + makeFaqLink() + ])); + } + var embedButtons = [ makeCancelButton(), { className: 'primary', @@ -1261,6 +1372,21 @@ define([ if (!hashes.fileHash) { throw new Error("You must provide a file hash"); } var url = origin + pathname + '#' + hashes.fileHash; + // check if the file is password protected + var parsedHref = Hash.parsePadUrl(url); + var hasPassword = parsedHref.hashData.password; + + var makeFaqLink = function () { + var link = h('span', [ + h('i.fa.fa-question-circle'), + h('a', {href: '#'}, Messages.passwordFaqLink) + ]); + $(link).click(function () { + common.openURL(config.origin + "/faq.html#security-pad_password"); + }); + return link; + }; + var getLinkValue = function () { return url; }; var makeCancelButton = function() { @@ -1272,9 +1398,40 @@ define([ // Share link tab var linkContent = [ - UI.dialog.selectable(getLinkValue(), { id: 'cp-share-link-preview', tabindex: 1 }) + UI.dialog.selectableArea(getLinkValue(), { id: 'cp-share-link-preview', tabindex: 1, rows:2 }) ]; + // Show alert if the pad is password protected + if (hasPassword) { + linkContent.push(h('div.alert.alert-primary', [ + h('i.fa.fa-lock'), + Messages.share_linkPasswordAlert, h('br'), + makeFaqLink() + ])); + } + + // warning about sharing links + var localStore = window.cryptpadStore; + var dismissButton = h('span.fa.fa-times'); + var shareLinkWarning = h('div.alert.alert-warning.dismissable', + { style: 'display: none;' }, + [ + h('span.cp-inline-alert-text', Messages.share_linkWarning), + dismissButton + ]); + linkContent.push(shareLinkWarning); + + localStore.get('hide-alert-shareLinkWarning', function (val) { + if (val === '1') { return; } + $(shareLinkWarning).show(); + + $(dismissButton).on('click', function () { + localStore.put('hide-alert-shareLinkWarning', '1'); + $(shareLinkWarning).remove(); + }); + + }); + var link = h('div.cp-share-modal', linkContent); var linkButtons = [ @@ -1300,17 +1457,24 @@ define([ // share with contacts tab var hasFriends = Object.keys(config.friends || {}).length !== 0; - var friendsObject = hasFriends ? createShareWithFriends(config, null, getLinkValue) : { - content: h('p', Messages.share_noContacts), - button: {} - }; + var friendsObject = hasFriends ? createShareWithFriends(config, null, getLinkValue) : noContactsMessage(common); var friendsList = friendsObject.content; var contactsContent = h('div.cp-share-modal'); - $(contactsContent).append(friendsList); + var $contactsContent = $(contactsContent); + $contactsContent.append(friendsList); + + // Show alert if the pad is password protected + if (hasPassword) { + $contactsContent.append(h('div.alert.alert-primary', [ + h('i.fa.fa-unlock'), + Messages.share_contactPasswordAlert, h('br'), + makeFaqLink() + ])); + } - var contactButtons = [makeCancelButton(), - friendsObject.button]; + var contactButtons = friendsObject.buttons; + contactButtons.unshift(makeCancelButton()); var frameContacts = UI.dialog.customModal(contactsContent, { buttons: contactButtons, @@ -1321,12 +1485,20 @@ define([ // Embed tab var embed = h('div.cp-share-modal', [ h('p', Messages.fileEmbedScript), - h('br'), UI.dialog.selectable(common.getMediatagScript()), h('p', Messages.fileEmbedTag), - h('br'), UI.dialog.selectable(common.getMediatagFromHref(fileData)), ]); + + // Show alert if the pad is password protected + if (hasPassword) { + embed.append(h('div.alert.alert-primary', [ + h('i.fa.fa-lock'), ' ', + Messages.share_embedPasswordAlert, h('br'), + makeFaqLink() + ])); + } + var embedButtons = [{ className: 'cancel', name: Messages.cancel, @@ -1403,6 +1575,7 @@ define([ var list = UIElements.getUserGrid(Messages.team_pickFriends, { common: common, data: config.friends, + large: true }, refreshButton); $div = $(list.div); refreshButton(); @@ -1761,7 +1934,7 @@ define([ if (e) { return void console.error(e); } UIElements.getProperties(common, data, function (e, $prop) { if (e) { return void console.error(e); } - UI.alert($prop[0], undefined, true); + UI.openCustomModal($prop[0]); }); }); }); diff --git a/www/common/cryptpad-common.js b/www/common/cryptpad-common.js index 25a64404d..40db70457 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -1155,6 +1155,242 @@ define([ }); }; + common.changeOOPassword = function (data, _cb) { + var cb = Util.once(Util.mkAsync(_cb)); + var href = data.href; + var newPassword = data.password; + var teamId = data.teamId; + if (!href) { return void cb({ error: 'EINVAL_HREF' }); } + var parsed = Hash.parsePadUrl(href); + if (!parsed.hash) { return void cb({ error: 'EINVAL_HREF' }); } + if (parsed.type !== 'sheet') { return void cb({ error: 'EINVAL_TYPE' }); } + + var warning = false; + var newHash, newRoHref; + var oldSecret; + var oldMetadata; + var oldRtChannel; + var privateData; + var padData; + + var newSecret; + if (parsed.hashData.version >= 2) { + newSecret = Hash.getSecrets(parsed.type, parsed.hash, newPassword); + if (!(newSecret.keys && newSecret.keys.editKeyStr)) { + return void cb({error: 'EAUTH'}); + } + newHash = Hash.getEditHashFromKeys(newSecret); + } + var newHref = '/' + parsed.type + '/#' + newHash; + var newRtChannel = Hash.createChannelId(); + + var Crypt, Crypto; + var cryptgetVal; + var optsPut = { + password: newPassword, + metadata: { + validateKey: newSecret.keys.validateKey + }, + }; + + Nthen(function (waitFor) { + common.getPadAttribute('', waitFor(function (err, _data) { + padData = _data; + }), href); + }).nThen(function (waitFor) { + oldSecret = Hash.getSecrets(parsed.type, parsed.hash, padData.password); + + require([ + '/common/cryptget.js', + '/bower_components/chainpad-crypto/crypto.js', + ], waitFor(function (_Crypt, _Crypto) { + Crypt = _Crypt; + Crypto = _Crypto; + })); + + common.getPadMetadata({channel: oldSecret.channel}, waitFor(function (metadata) { + oldMetadata = metadata; + })); + common.getMetadata(waitFor(function (err, data) { + if (err) { + waitFor.abort(); + return void cb({ error: err }); + } + privateData = data.priv; + })); + }).nThen(function (waitFor) { + // Check if we're allowed to change the password + var owners = oldMetadata.owners; + optsPut.metadata.owners = owners; + var edPublic = teamId ? (privateData.teams[teamId] || {}).edPublic : privateData.edPublic; + var isOwner = Array.isArray(owners) && edPublic && owners.indexOf(edPublic) !== -1; + if (!isOwner) { + // We're not an owner, we shouldn't be able to change the password! + waitFor.abort(); + return void cb({ error: 'EPERM' }); + } + + var mailbox = oldMetadata.mailbox; + if (mailbox) { + // Create the encryptors to be able to decrypt and re-encrypt the mailboxes + var oldCrypto = Crypto.createEncryptor(oldSecret.keys); + var newCrypto = Crypto.createEncryptor(newSecret.keys); + + var m; + if (typeof(mailbox) === "string") { + try { + m = newCrypto.encrypt(oldCrypto.decrypt(mailbox, true, true)); + } catch (e) {} + } else if (mailbox && typeof(mailbox) === "object") { + m = {}; + Object.keys(mailbox).forEach(function (ed) { + try { + m[ed] = newCrypto.encrypt(oldCrypto.decrypt(mailbox[ed], true, true)); + } catch (e) { + console.error(e); + } + }); + } + optsPut.metadata.mailbox = m; + } + + var expire = oldMetadata.expire; + if (expire) { + optsPut.metadata.expire = (expire - (+new Date())) / 1000; // Lifetime in seconds + } + + // Get last cp (cryptget) + Crypt.get(parsed.hash, waitFor(function (err, val) { + if (err) { + waitFor.abort(); + return void cb({ error: err }); + } + try { + cryptgetVal = JSON.parse(val); + if (!cryptgetVal.content) { + waitFor.abort(); + return void cb({ error: 'INVALID_CONTENT' }); + } + } catch (e) { + waitFor.abort(); + return void cb({ error: 'CANT_PARSE' }); + } + }), { + password: padData.password + }); + }).nThen(function (waitFor) { + // Re-encrypt rtchannel + oldRtChannel = Util.find(cryptgetVal, ['content', 'channel']); + var newCrypto = Crypto.createEncryptor(newSecret.keys); + var oldCrypto = Crypto.createEncryptor(oldSecret.keys); + var cps = Util.find(cryptgetVal, ['content', 'hashes']); + var l = Object.keys(cps).length; + var lastCp = l ? cps[l] : {}; + cryptgetVal.content.hashes = {}; + common.getHistory({ + channel: oldRtChannel, + lastKnownHash: lastCp.hash + }, waitFor(function (obj) { + if (obj && obj.error) { + waitFor.abort(); + console.error(obj); + return void cb(obj.error); + } + var msgs = obj; + var newHistory = msgs.map(function (str) { + try { + var d = oldCrypto.decrypt(str, true, true); + return newCrypto.encrypt(d); + } catch (e) { + console.log(e); + waitFor.abort(); + return void cb({error: e}); + } + }); + // Update last knwon hash in cryptgetVal + if (lastCp) { + lastCp.hash = newHistory[0].slice(0, 64); + lastCp.index = 50; + cryptgetVal.content.hashes[1] = lastCp; + } + common.onlyoffice.execCommand({ + cmd: 'REENCRYPT', + data: { + channel: newRtChannel, + msgs: newHistory, + metadata: optsPut.metadata + } + }, waitFor(function (obj) { + if (obj && obj.error) { + waitFor.abort(); + console.warn(obj); + return void cb(obj.error); + } + })); + })); + }).nThen(function (waitFor) { + // The new rt channel is ready + // The blob uses its own encryption and doesn't need to be reencrypted + cryptgetVal.content.channel = newRtChannel; + Crypt.put(newHash, JSON.stringify(cryptgetVal), waitFor(function (err) { + if (err) { + waitFor.abort(); + return void cb({ error: err }); + } + }), optsPut); + }).nThen(function (waitFor) { + pad.leavePad({ + channel: oldSecret.channel + }, waitFor()); + pad.onDisconnectEvent.fire(true); + }).nThen(function (waitFor) { + // Set the new password to our pad data + common.setPadAttribute('password', newPassword, waitFor(function (err) { + if (err) { warning = true; } + }), href); + common.setPadAttribute('channel', newSecret.channel, waitFor(function (err) { + if (err) { warning = true; } + }), href); + common.setPadAttribute('rtChannel', newRtChannel, waitFor(function (err) { + if (err) { warning = true; } + }), href); + var viewHash = Hash.getViewHashFromKeys(newSecret); + newRoHref = '/' + parsed.type + '/#' + viewHash; + common.setPadAttribute('roHref', newRoHref, waitFor(function (err) { + if (err) { warning = true; } + }), href); + + if (parsed.hashData.password && newPassword) { return; } // same hash + common.setPadAttribute('href', newHref, waitFor(function (err) { + if (err) { warning = true; } + }), href); + }).nThen(function (waitFor) { + // delete the old pad + common.removeOwnedChannel({ + channel: oldSecret.channel, + teamId: teamId + }, waitFor(function (obj) { + if (obj && obj.error) { + waitFor.abort(); + console.info(obj); + return void cb(obj.error); + } + common.removeOwnedChannel({ + channel: oldRtChannel, + teamId: teamId + }, waitFor()); + })); + }).nThen(function () { + cb({ + warning: warning, + hash: newHash, + href: newHref, + roHref: newRoHref + }); + }); + }; + + common.changeUserPassword = function (Crypt, edPublic, data, cb) { if (!edPublic) { return void cb({ @@ -1350,6 +1586,9 @@ define([ common.getFullHistory = function (data, cb) { postMessage("GET_FULL_HISTORY", data, cb, {timeout: 180000}); }; + common.getHistory = function (data, cb) { + postMessage("GET_HISTORY", data, cb, {timeout: 180000}); + }; common.getHistoryRange = function (data, cb) { postMessage("GET_HISTORY_RANGE", data, cb); }; diff --git a/www/common/diffMarked.js b/www/common/diffMarked.js index 5817e5ac9..0f92897bc 100644 --- a/www/common/diffMarked.js +++ b/www/common/diffMarked.js @@ -197,7 +197,6 @@ define([ 'APPLET', 'VIDEO', // privacy implications of videos are the same as images 'AUDIO', // same with audio - 'SVG' ]; var unsafeTag = function (info) { /*if (info.node && $(info.node).parents('media-tag').length) { @@ -307,8 +306,39 @@ define([ var Dom = domFromHTML($('