diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e8379bd0..9002860d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ * websockets * sandbox CSP * login block + * recommend against trailing slashes for configured domains + * remove slashes in server.js anyway * admin page * support responses to closed tickets * collapse very long messages @@ -24,7 +26,9 @@ * display survey URL * support 'KB' in Util.magnitudeOfBytes * degraded mode - * decide on a number + * decide on a number: 8 + * provide an easy way to change it (application_config.js) + * inform users what the limit is (when degraded mode "kicks in") * sheets * fix naming collisions between images in spreadsheets * degraded mode not supported @@ -32,14 +36,29 @@ * pinning? * oo rebuild * OnlyOffice v6.2 + * some buttons that we were hiding have new ids and needed to be hidden again * translations * updated catch-phrase (Collaboration suite\nend-to-end-encrypted and open-source * CKEditor * cursor jump when clicking on a comment bubble * keybindings for common styles + * test if this affects scroll position (it shouldn't) + * check that CTRL-space doesn't mess with anything and that it is what Google uses + * test on Mac * nodrive * load anonymous accounts without creating a drive * faster load time, less junk on the server + * `AppConfig.allowDrivelessMode` + * cursor color is randomly generated each time and doesn't persist after creating a drive + * only affects framework apps for now +* secure iframe now always knows the channel of the related document + * more consistent API with other APPs +* debug app doesn't create a drive +* implement/fix ability to destroy pads whether they exist in your drive or not + + +* Known issues + * change password for documents in your drive when you don't have the most recent password (multi-owner pads) # 4.2.1 diff --git a/config/config.example.js b/config/config.example.js index 855a848e3..37fd5ada9 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -54,7 +54,7 @@ module.exports = { * and it may have unintended consequences in practice. * */ - httpUnsafeOrigin: 'http://localhost:3000/', + httpUnsafeOrigin: 'http://localhost:3000', /* httpSafeOrigin is the URL that is used for the 'sandbox' described above. * If you're testing or developing with CryptPad on your local machine then diff --git a/customize.dist/src/less2/include/forms.less b/customize.dist/src/less2/include/forms.less index ae1096946..e74b67cb0 100644 --- a/customize.dist/src/less2/include/forms.less +++ b/customize.dist/src/less2/include/forms.less @@ -184,6 +184,7 @@ } &.btn-register { margin-top: 10px !important; + white-space: normal; } diff --git a/lib/hk-util.js b/lib/hk-util.js index 8780addcd..860435c86 100644 --- a/lib/hk-util.js +++ b/lib/hk-util.js @@ -661,6 +661,8 @@ const handleGetHistory = function (Env, Server, seq, userId, parsed) { if (txid) { msg[0] = txid; } Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(msg)], readMore); }, (err) => { + // Any error but ENOENT: abort + // ENOENT is allowed in case we want to create a new pad if (err && err.code !== 'ENOENT') { if (err.message === "EUNKNOWN") { Log.error("HK_GET_HISTORY", { @@ -675,11 +677,28 @@ const handleGetHistory = function (Env, Server, seq, userId, parsed) { err: err && err.message || err, stack: err && err.stack, }); } + // FIXME err.message isn't useful for users const parsedMsg = {error:err.message, channel: channelName, txid: txid}; Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(parsedMsg)]); return; } + // If we're asking for a specific version (lastKnownHash) but we receive an + // ENOENT, this is not a pad creation so we need to abort. + if (err && err.code === 'ENOENT' && lastKnownHash) { +/* + This informs clients that the pad they're trying to load was deleted by its owner. + The user in question might be reconnecting or might have loaded the document from their cache. + The owner that deleted it could be another user or the same user from a different device. + Either way, the respectful thing to do is display an error screen informing them that the content + is no longer on the server so they don't abuse the data and so that they don't unintentionally continue + to edit it in a broken state. +*/ + const parsedMsg2 = {error:'EDELETED', channel: channelName, txid: txid}; + Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(parsedMsg2)]); + return; + } + if (msgCount === 0 && !metadata_cache[channelName] && Server.channelContainsUser(channelName, userId)) { handleFirstMessage(Env, channelName, metadata); Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(metadata)]); diff --git a/server.js b/server.js index f3ee71d0b..d2df3bba6 100644 --- a/server.js +++ b/server.js @@ -16,15 +16,19 @@ var Env = require("./lib/env").create(config); var app = Express(); +var canonicalizeOrigin = function (s) { + return (s || '').trim().replace(/\/+$/, ''); +}; + (function () { // you absolutely must provide an 'httpUnsafeOrigin' if (typeof(config.httpUnsafeOrigin) !== 'string') { throw new Error("No 'httpUnsafeOrigin' provided"); } - config.httpUnsafeOrigin = config.httpUnsafeOrigin.trim(); + config.httpUnsafeOrigin = canonicalizeOrigin(config.httpUnsafeOrigin); if (typeof(config.httpSafeOrigin) === 'string') { - config.httpSafeOrigin = config.httpSafeOrigin.trim().replace(/\/$/, ''); + config.httpSafeOrigin = canonicalizeOrigin(config.httpSafeOrigin); } // fall back to listening on a local address diff --git a/www/checkup/main.js b/www/checkup/main.js index bcbdf0d7b..5dcd1e8cf 100644 --- a/www/checkup/main.js +++ b/www/checkup/main.js @@ -42,12 +42,13 @@ define([ return void cb(trimmedSafe !== trimmedUnsafe); }, _alert('Sandbox configuration: httpUnsafeOrigin !== httpSafeOrigin')); + assert(function (cb) { + cb(trimmedSafe === ApiConfig.httpSafeOrigin); + }, "httpSafeOrigin must not have a trailing slash"); + assert(function (cb) { var origin = window.location.origin; - return void cb([ - origin, - origin + '/' - ].indexOf(ApiConfig.httpUnsafeOrigin) !== -1); + return void cb(ApiConfig.httpUnsafeOrigin === origin); }, _alert('Sandbox configuration: loading via httpUnsafeOrigin')); diff --git a/www/common/application_config_internal.js b/www/common/application_config_internal.js index 496e65f1f..0f91ea447 100644 --- a/www/common/application_config_internal.js +++ b/www/common/application_config_internal.js @@ -162,7 +162,7 @@ define(function() { // making it much faster to open new tabs. config.disableWorkers = false; - config.surveyURL = "https://survey.cryptpad.fr/index.php/672782"; + //config.surveyURL = ""; // Teams are always loaded during the initial loading screen (for the first tab only if // SharedWorkers are available). Allowing users to be members of multiple teams can @@ -179,5 +179,23 @@ define(function() { // You can change the value here. // config.maxOwnedTeams = 5; + // The userlist displayed in collaborative documents is stored alongside the document data. + // Everytime someone with edit rights joins a document or modify their user data (display + // name, avatar, color, etc.), they update the "userlist" part of the document. When too many + // editors are in the same document, all these changes increase the risks of conflicts which + // require CPU time to solve. A "degraded" mode can now be set when a certain number of editors + // are in a document at the same time. This mode disables the userlist, the chat and the + // position of other users' cursor. You can configure the number of user from which the session + // will enter into degraded mode. A big number may result in collaborative edition being broken, + // but this number depends on the network and CPU performances of each user's device. + config.degradedLimit = 8; + + // In "legacy" mode, one-time users were always creating an "anonymous" drive when visiting CryptPad + // in which they could store their pads. The new "driveless" mode allow users to open an existing + // pad without creating a drive in the background. The drive will only be created if they visit + // a different page (Drive, Settings, etc.) or try to create a new pad themselves. You can disable + // the driveless mode by changing the following value to "false" + config.allowDrivelessMode = true; + return config; }); diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index 562c3757c..a02accadf 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -1552,6 +1552,8 @@ define([ faqLine, ]); + $(content).find('a').attr('target', '_blank'); + var buttons = [ { className: 'primary', @@ -2538,6 +2540,7 @@ define([ UIElements.onServerError = function (common, err, toolbar, cb) { //if (["EDELETED", "EEXPIRED", "ERESTRICTED"].indexOf(err.type) === -1) { return; } var priv = common.getMetadataMgr().getPrivateData(); + var sframeChan = common.getSframeChannel(); var msg = err.type; if (err.type === 'EEXPIRED') { msg = Messages.expiredError; @@ -2547,10 +2550,29 @@ define([ if (toolbar && typeof toolbar.deleted === "function") { toolbar.deleted(); } } else if (err.type === 'EDELETED') { if (priv.burnAfterReading) { return void cb(); } + + if (autoStoreModal[priv.channel]) { + autoStoreModal[priv.channel].delete(); + delete autoStoreModal[priv.channel]; + } + // View users have the wrong seed, thay can't retireve access directly + // Version 1 hashes don't support passwords + if (!priv.readOnly && !priv.oldVersionHash) { + sframeChan.event('EV_SHARE_OPEN', {hidden: true}); // Close share modal + UIElements.displayPasswordPrompt(common, { + fromServerError: true, + loaded: err.loaded, + }); + if (toolbar && typeof toolbar.deleted === "function") { toolbar.deleted(); } + (cb || function () {})(); + return; + } + msg = Messages.deletedError; if (err.loaded) { msg += Messages.errorCopy; } + if (toolbar && typeof toolbar.deleted === "function") { toolbar.deleted(); } } else if (err.type === 'ERESTRICTED') { msg = Messages.restrictedError; @@ -2559,7 +2581,6 @@ define([ msg = Messages.oo_deletedVersion; if (toolbar && typeof toolbar.failed === "function") { toolbar.failed(true); } } - var sframeChan = common.getSframeChannel(); sframeChan.event('EV_SHARE_OPEN', {hidden: true}); UI.errorLoadingScreen(msg, Boolean(err.loaded), Boolean(err.loaded)); (cb || function () {})(); @@ -2568,7 +2589,10 @@ define([ UIElements.displayPasswordPrompt = function (common, cfg, isError) { var error; if (isError) { error = setHTML(h('p.cp-password-error'), Messages.password_error); } + var info = h('p.cp-password-info', Messages.password_info); + var info_loaded = setHTML(h('p.cp-password-info'), Messages.errorCopy); + var password = UI.passwordInput({placeholder: Messages.password_placeholder}); var $password = $(password); var button = h('button.btn.btn-primary', Messages.password_submit); @@ -2580,6 +2604,21 @@ define([ var submit = function () { var value = $password.find('.cp-password-input').val(); + + // Password-prompt called from UIElements.onServerError + if (cfg.fromServerError) { + common.getSframeChannel().query('Q_PASSWORD_CHECK', value, function (err, obj) { + if (obj && obj.error) { + console.error(obj.error); + return void UI.warn(Messages.error); + } + // On success, outer will reload the page: this is a wrong password + UIElements.displayPasswordPrompt(common, cfg, true); + }); + return; + } + + // Initial load UI.addLoadingScreen({newProgress: true}); if (window.CryptPad_updateLoadingProgress) { window.CryptPad_updateLoadingProgress({ @@ -2593,6 +2632,8 @@ define([ } }); }; + + $password.find('.cp-password-input').on('keydown', function (e) { if (e.which === 13) { submit(); } }); $(button).on('click', function () { submit(); }); @@ -2600,12 +2641,13 @@ define([ var block = h('div#cp-loading-password-prompt', [ error, info, + cfg.loaded ? info_loaded : undefined, h('p.cp-password-form', [ password, button - ]) + ]), ]); - UI.errorLoadingScreen(block); + UI.errorLoadingScreen(block, Boolean(cfg.loaded), Boolean(cfg.loaded)); $password.find('.cp-password-input').focus(); }; diff --git a/www/common/cryptpad-common.js b/www/common/cryptpad-common.js index 31c65bb86..d653bd72e 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -481,10 +481,20 @@ define([ }); }; + common.isNewChannel = function (href, password, _cb) { + var cb = Util.once(Util.mkAsync(_cb)); + var channel = Hash.hrefToHexChannelId(href, password); + postMessage('IS_NEW_CHANNEL', {channel: channel}, function (obj) { + var error = obj && obj.error; + if (error) { return void cb(error); } + if (!obj) { return void cb('ERROR'); } + cb (null, obj.isNew); + }, {timeout: -1}); + }; // This function is used when we want to open a pad. We first need // to check if it exists. With the cached drive, we need to wait for // the network to be available before we can continue. - common.isNewChannel = function (href, password, _cb) { + common.hasChannelHistory = function (href, password, _cb) { var cb = Util.once(Util.mkAsync(_cb)); var channel = Hash.hrefToHexChannelId(href, password); var error; @@ -1095,6 +1105,7 @@ define([ common.changePadPassword = function (Crypt, Crypto, data, cb) { var href = data.href; + var oldPassword = data.oldPassword; var newPassword = data.password; var teamId = data.teamId; if (!href) { return void cb({ error: 'EINVAL_HREF' }); } @@ -1123,7 +1134,9 @@ define([ var isSharedFolder = parsed.type === 'drive'; - var optsGet = {}; + var optsGet = { + password: oldPassword + }; var optsPut = { password: newPassword, metadata: {}, @@ -1133,7 +1146,7 @@ define([ var cryptgetVal; Nthen(function (waitFor) { - if (parsed.hashData && parsed.hashData.password) { + if (parsed.hashData && parsed.hashData.password && !oldPassword) { common.getPadAttribute('password', waitFor(function (err, password) { optsGet.password = password; }), href); @@ -1179,7 +1192,6 @@ define([ } else if (mailbox && typeof(mailbox) === "object") { m = {}; Object.keys(mailbox).forEach(function (ed) { - console.log(mailbox[ed]); try { m[ed] = newCrypto.encrypt(oldCrypto.decrypt(mailbox[ed], true, true)); } catch (e) { @@ -1214,6 +1226,7 @@ define([ cryptgetVal = JSON.stringify(parsed); } }), optsGet); + Cache.clearChannel(newSecret.channel, waitFor()); }).nThen(function (waitFor) { optsPut.metadata.restricted = oldMetadata.restricted; optsPut.metadata.allowed = oldMetadata.allowed; @@ -1418,6 +1431,7 @@ define([ common.changeOOPassword = function (data, _cb) { var cb = Util.once(Util.mkAsync(_cb)); var href = data.href; + var oldPassword = data.oldPassword; var newPassword = data.password; var teamId = data.teamId; if (!href) { return void cb({ error: 'EINVAL_HREF' }); } @@ -1431,7 +1445,6 @@ define([ var oldMetadata; var oldRtChannel; var privateData; - var padData; var newSecret; if (parsed.hashData.version >= 2) { @@ -1452,19 +1465,22 @@ define([ validateKey: newSecret.keys.validateKey }, }; - var optsGet = {}; + var optsGet = { + password: oldPassword + }; Nthen(function (waitFor) { common.getPadAttribute('', waitFor(function (err, _data) { - padData = _data; - optsGet.password = padData.password; + if (!oldPassword && _data) { + optsGet.password = _data.password; + } }), href); common.getAccessKeys(waitFor(function (keys) { optsGet.accessKeys = keys; optsPut.accessKeys = keys; })); }).nThen(function (waitFor) { - oldSecret = Hash.getSecrets(parsed.type, parsed.hash, padData.password); + oldSecret = Hash.getSecrets(parsed.type, parsed.hash, optsGet.password); require([ '/common/cryptget.js', @@ -1592,6 +1608,7 @@ define([ } })); })); + Cache.clearChannel(newSecret.channel, waitFor()); }).nThen(function (waitFor) { // The new rt channel is ready // The blob uses its own encryption and doesn't need to be reencrypted @@ -2500,7 +2517,25 @@ define([ } if (parsedNew.hashData) { oldHref = newHref; } }; + // XXX if you're in noDrive mode, check if an FS_hash is added and reload if that's the case // Listen for login/logout in other tabs + if (rdyCfg.noDrive && !localStorage[Constants.fileHashKey]) { + window.addEventListener('storage', function (e) { + if (e.key !== Constants.fileHashKey) { return; } + // New entry added to FS_hash: drive created in another tab, reload + var o = e.oldValue; + var n = e.newValue; + if (!o && n) { + postMessage('HAS_DRIVE', null, function(obj) { + // If we're still in noDrive mode, reload + if (!obj.state) { + LocalStore.loginReload(); + } + // Otherwise this worker is connected, nothing to do + }); + } + }); + } window.addEventListener('storage', function (e) { if (e.key !== Constants.userHashKey) { return; } var o = e.oldValue; diff --git a/www/common/inner/access.js b/www/common/inner/access.js index 3bbef91e9..cb3fc57c9 100644 --- a/www/common/inner/access.js +++ b/www/common/inner/access.js @@ -25,10 +25,12 @@ define([ var sframeChan = common.getSframeChannel(); var metadataMgr = common.getMetadataMgr(); - var channel = data.channel; + var priv = metadataMgr.getPrivateData(); + var channel = data.channel || priv.channel; var owners = data.owners || []; var pending_owners = data.pending_owners || []; var teamOwner = data.teamId; + var title = opts.title; opts = opts || {}; var redrawAll = function () {}; @@ -115,7 +117,7 @@ define([ if (!friend) { return; } common.mailbox.sendTo("RM_OWNER", { channel: channel, - title: data.title, + title: data.title || title, pending: pending }, { channel: friend.notifications, @@ -271,7 +273,7 @@ define([ href: data.href || data.rohref, password: data.password, path: isTemplate ? ['template'] : undefined, - title: data.title || '', + title: data.title || title || "", teamId: obj.id }, waitFor(function (err) { if (err) { return void console.error(err); } @@ -320,6 +322,12 @@ define([ })); } }).nThen(function (waitFor) { + var href = data.href; + var hashes = priv.hashes || {}; + var bestHash = hashes.editHash || hashes.viewHash || hashes.fileHash; + if (data.fakeHref) { + href = Hash.hashToHref(bestHash, priv.app); + } sel.forEach(function (el) { var curve = $(el).attr('data-curve'); if (curve === user.curvePublic) { return; } @@ -327,9 +335,9 @@ define([ if (!friend) { return; } common.mailbox.sendTo("ADD_OWNER", { channel: channel, - href: data.href, - password: data.password, - title: data.title + href: href, + password: data.password || priv.password, + title: data.title || title }, { channel: friend.notifications, curvePublic: friend.curvePublic @@ -398,7 +406,8 @@ define([ var sframeChan = common.getSframeChannel(); var metadataMgr = common.getMetadataMgr(); - var channel = data.channel; + var priv = metadataMgr.getPrivateData(); + var channel = data.channel || priv.channel; var owners = data.owners || []; var restricted = data.restricted || false; var allowed = data.allowed || []; @@ -888,9 +897,17 @@ define([ }); } + var href = data.href; + var hashes = priv.hashes || {}; + var bestHash = hashes.editHash || hashes.viewHash || hashes.fileHash; + if (data.fakeHref) { + href = Hash.hashToHref(bestHash, priv.app); + } + var isNotStored = Boolean(data.fakeHref); sframeChan.query(q, { teamId: typeof(owned) !== "boolean" ? owned : undefined, - href: data.href, + href: href, + oldPassword: priv.password, password: newPass }, function (err, data) { $(passwordOk).text(Messages.properties_changePasswordButton); @@ -924,22 +941,26 @@ define([ // Pad password changed: update the href // Use hidden hash if needed (we're an owner of this pad so we know it is stored) var useUnsafe = Util.find(priv, ['settings', 'security', 'unsafeLinks']); - var href = (priv.readOnly && data.roHref) ? data.roHref : data.href; + if (isNotStored) { useUnsafe = true; } + var _href = (priv.readOnly && data.roHref) ? data.roHref : data.href; if (useUnsafe !== true) { - var newParsed = Hash.parsePadUrl(href); + var newParsed = Hash.parsePadUrl(_href); var newSecret = Hash.getSecrets(newParsed.type, newParsed.hash, newPass); var newHash = Hash.getHiddenHashFromKeys(parsed.type, newSecret, {}); - href = Hash.hashToHref(newHash, parsed.type); + _href = Hash.hashToHref(newHash, parsed.type); } + // Trigger a page reload if the href didn't change + if (_href === href) { _href = undefined; } + if (data.warning) { return void UI.alert(Messages.properties_passwordWarning, function () { - common.gotoURL(href); + common.gotoURL(_href); }, {force: true}); } return void UI.alert(Messages.properties_passwordSuccess, function () { if (!isSharedFolder) { - common.gotoURL(href); + common.gotoURL(_href); } }, {force: true}); }); @@ -956,7 +977,7 @@ define([ spinner.spin(); sframeChan.query('Q_DELETE_OWNED', { teamId: typeof(owned) !== "boolean" ? owned : undefined, - channel: data.channel + channel: data.channel || priv.channel }, function (err, obj) { spinner.done(); UI.findCancelButton().click(); diff --git a/www/common/onlyoffice/inner.js b/www/common/onlyoffice/inner.js index 84ad474b0..d06e9a534 100644 --- a/www/common/onlyoffice/inner.js +++ b/www/common/onlyoffice/inner.js @@ -1281,13 +1281,15 @@ define([ '#title-user-name { display: none !important; }' + (supportsXLSX() ? '' : '#slot-btn-dt-print { display: none !important; }') + // New OO: - '#asc-gen566 { display: none !important; }' + // Insert image from url + '#asc-gen257 { display: none !important; }' + // Insert image from url 'section[data-tab="ins"] .separator:nth-last-child(2) { display: none !important; }' + // separator '#slot-btn-insequation { display: none !important; }' + // Insert equation //'.toolbar .tabs .ribtab:not(.canedit) { display: none !important; }' + // Switch collaborative mode '#fm-btn-info { display: none !important; }' + // Author name, doc title, etc. in "File" (menu entry) '#panel-info { display: none !important; }' + // Same but content '#image-button-from-url { display: none !important; }' + // Inline image settings: replace with url + '#asc-gen1839 { display: none !important; }' + // Image context menu: replace with url + '#asc-gen5883 { display: none !important; }' + // Rightside image menu: replace with url '#file-menu-panel .devider { display: none !important; }' + // separator in the "File" menu '#left-btn-spellcheck, #left-btn-about { display: none !important; }'+ 'div.btn-users.dropdown-toggle { display: none; !important }'; diff --git a/www/common/outer/async-store.js b/www/common/outer/async-store.js index 4db6a6142..f20a545ec 100644 --- a/www/common/outer/async-store.js +++ b/www/common/outer/async-store.js @@ -331,6 +331,8 @@ define([ teamId = data.teamId; } + // XXX CLEAR CACHE + if (channel === store.driveChannel && !force) { return void cb({error: 'User drive removal blocked!'}); } @@ -450,7 +452,7 @@ define([ account.note = obj.note; cb(obj); }); - }); + }, Cache); }; ////////////////////////////////////////////////////////////////// @@ -586,11 +588,14 @@ define([ var proxy = store.proxy || {}; var disableThumbnails = Util.find(proxy, ['settings', 'general', 'disableThumbnails']); var teams = (store.modules['team'] && store.modules['team'].getTeamsData(app)) || {}; + if (!proxy.uid) { + store.noDriveUid = store.noDriveUid || Hash.createChannelId(); + } var metadata = { // "user" is shared with everybody via the userlist user: { name: proxy[Constants.displayNameKey] || store.noDriveName || "", - uid: proxy.uid || Hash.createChannelId(), // Random uid in nodrive mode + uid: proxy.uid || store.noDriveUid, // Random uid in nodrive mode avatar: Util.find(proxy, ['profile', 'avatar']), profile: Util.find(proxy, ['profile', 'view']), color: getUserColor(), @@ -858,6 +863,7 @@ define([ Store.setDisplayName = function (clientId, value, cb) { if (!store.proxy) { store.noDriveName = value; + broadcast([clientId], "UPDATE_METADATA"); return void cb(); } if (store.modules['profile']) { @@ -1704,6 +1710,10 @@ define([ var onError = function (err) { channel.bcast("PAD_ERROR", err); + if (err && err.type === "EDELETED" && Cache && Cache.clearChannel) { + Cache.clearChannel(data.channel); + } + // If this is a DELETED, EXPIRED or RESTRICTED pad, leave the channel if (["EDELETED", "EEXPIRED", "ERESTRICTED"].indexOf(err.type) === -1) { return; } Store.leavePad(null, data, function () {}); @@ -1714,11 +1724,13 @@ define([ postMessage(clientId, "PAD_CACHE"); }, onCacheReady: function () { + channel.hasCache = true; postMessage(clientId, "PAD_CACHE_READY"); }, onReady: function (pad) { var padData = pad.metadata || {}; channel.data = padData; + channel.ready = true; if (padData && padData.validateKey && store.messenger) { store.messenger.storeValidateKey(data.channel, padData.validateKey); } @@ -2835,6 +2847,12 @@ define([ if (store.ready) { return; } // the store is already ready, it is a reconnection store.driveMetadata = info.metadata; if (!rt.proxy.drive || typeof(rt.proxy.drive) !== 'object') { rt.proxy.drive = {}; } + if (!rt.proxy[Constants.displayNameKey] && store.noDriveName) { + rt.proxy[Constants.displayNameKey] = store.noDriveName; + } + if (!rt.proxy.uid && store.noDriveUid) { + rt.proxy.uid = store.noDriveUid; + } /* // deprecating localStorage migration as of 4.2.0 var drive = rt.proxy.drive; @@ -2929,6 +2947,13 @@ define([ */ var initialized = false; + // Are we still in noDrive mode? + Store.hasDrive = function (clientId, data, cb) { + cb({ + state: Boolean(store.proxy) + }); + }; + // If we load CryptPad for the first time from an existing pad, don't create a // drive automatically. var onNoDrive = function (clientId, cb) { @@ -2950,7 +2975,13 @@ define([ if (!store.network) { var wsUrl = NetConfig.getWebsocketURL(); return void Netflux.connect(wsUrl).then(function (network) { - store.network = network; + // If we already haave a network (race condition), use the + // existing one and forget this one + if (!store.network) { store.network = network; } + else { + network.disconnect(); + network = store.network; + } // We need to know the HistoryKeeper ID to initialize the anon RPC // Join a basic ephemeral channel, get the ID and leave it instantly network.join('0000000000000000000000000000000000').then(function (wc) { diff --git a/www/common/outer/cursor.js b/www/common/outer/cursor.js index b6662b75f..95b2b34c6 100644 --- a/www/common/outer/cursor.js +++ b/www/common/outer/cursor.js @@ -2,11 +2,12 @@ define([ '/common/common-util.js', '/common/common-constants.js', '/customize/messages.js', + '/customize/application_config.js', '/bower_components/chainpad-crypto/crypto.js', -], function (Util, Constants, Messages, Crypto) { +], function (Util, Constants, Messages, AppConfig, Crypto) { var Cursor = {}; - var DEGRADED = 3; // XXX Number of users before switching to degraded mode + var DEGRADED = AppConfig.degradedLimit || 8; var convertToUint8 = function (obj) { var l = Object.keys(obj).length; @@ -50,6 +51,12 @@ define([ }); }; + var updateDegraded = function (ctx, wc, chan) { + var m = wc.members; + chan.degraded = (m.length-1) >= DEGRADED; + ctx.emit('DEGRADED', { degraded: chan.degraded }, chan.clients); + }; + var initCursor = function (ctx, obj, client, cb) { var channel = obj.channel; var secret = obj.secret; @@ -92,14 +99,10 @@ define([ // ==> And push the new tab to the list chan.clients.push(client); + updateDegraded(ctx, chan.wc, chan); return void cb(); } - var updateDegraded = function (ctx, wc, chan) { - var m = wc.members; - chan.degraded = (m.length-1) >= DEGRADED; - ctx.emit('DEGRADED', { degraded: chan.degraded }, chan.clients); - }; var onOpen = function (wc) { ctx.channels[channel] = ctx.channels[channel] || {}; diff --git a/www/common/outer/messenger.js b/www/common/outer/messenger.js index 1ac7a6e17..981d9f8de 100644 --- a/www/common/outer/messenger.js +++ b/www/common/outer/messenger.js @@ -537,10 +537,9 @@ define([ if (peer === hk) { return; } if (channel.readOnly) { return; } - // XXX review // Sending myData is used to build a "mapId" object which links // netflux IDs to a curvePublic/uid. We use this map in friend chat - // to detect is the other user is online and we also use it in team chat + // to detect if the other user is online and we also use it in team chat // to show if other team members are online (in the roster section). // It is not needed in the pad chat for now and only causes useless // network usage. diff --git a/www/common/outer/store-rpc.js b/www/common/outer/store-rpc.js index e444f47f5..c4d1d1301 100644 --- a/www/common/outer/store-rpc.js +++ b/www/common/outer/store-rpc.js @@ -15,6 +15,7 @@ define([ MIGRATE_ANON_DRIVE: Store.migrateAnonDrive, PING: function (cId, data, cb) { cb(); }, CACHE_DISABLE: Store.disableCache, + HAS_DRIVE: Store.hasDrive, // RPC UPDATE_PIN_LIMIT: Store.updatePinLimit, GET_PIN_LIMIT: Store.getPinLimit, diff --git a/www/common/proxy-manager.js b/www/common/proxy-manager.js index 7093f0ec5..011ec4f36 100644 --- a/www/common/proxy-manager.js +++ b/www/common/proxy-manager.js @@ -868,7 +868,6 @@ define([ if (fId && Env.folders[fId] && Env.folders[fId].deleting) { delete Env.folders[fId].deleting; } - console.error(obj.error, chan); Feedback.send('ERROR_DELETING_OWNED_PAD=' + chan + '|' + obj.error, true); return void cb(); } @@ -881,6 +880,11 @@ define([ ids.push(fId); } + if (!ids.length) { + toDelete = undefined; + return void cb(); + } + ids.forEach(function (id) { var paths = findFile(Env, id); var _resolved = _resolvePaths(Env, paths); @@ -912,8 +916,13 @@ define([ }); }); }).nThen(function () { - // Remove deleted pads from the drive - _delete(Env, { resolved: toDelete }, cb); + if (!toDelete) { + // Nothing to delete + cb(); + } else { + // Remove deleted pads from the drive + _delete(Env, { resolved: toDelete }, cb); + } // If we were using the access modal, send a refresh command if (data.channel) { Env.Store.refreshDriveUI(); diff --git a/www/common/sframe-common-outer.js b/www/common/sframe-common-outer.js index df49ee6a3..131958d83 100644 --- a/www/common/sframe-common-outer.js +++ b/www/common/sframe-common-outer.js @@ -78,7 +78,7 @@ define([ }; var AppConfig; var Test; - var password, newPadPassword; + var password, newPadPassword, newPadPasswordForce; var initialPathInDrive; var burnAfterReading; @@ -221,8 +221,11 @@ define([ } } catch (e) { console.error(e); } + // NOTE: Driveless mode should only work for existing pads, but we can't check that + // before creating the worker because we need the anon RPC to do so. + // We're only going to check if a hash exists in the URL or not. Cryptpad.ready(waitFor(), { - noDrive: cfg.noDrive, + noDrive: cfg.noDrive && AppConfig.allowDrivelessMode && currentPad.hash, driveEvents: cfg.driveEvents, cache: Boolean(cfg.cache), currentPad: currentPad @@ -309,6 +312,7 @@ define([ newPadPassword = Crypto.decrypt(newPad.pw, uKey); } catch (e) { console.error(e); } } + if (newPad.f) { newPadPasswordForce = 1; } if (newPad.d) { Cryptpad.fromFileData = newPad.d; var _parsed1 = Utils.Hash.parsePadUrl(Cryptpad.fromFileData.href); @@ -316,6 +320,7 @@ define([ delete Cryptpad.fromFileData; } } + } catch (e) { console.error(e, parsed.hashData.newPadOpts); } @@ -346,7 +351,7 @@ define([ } // We now need to check if there is a password and if we know the correct password. - // We'll use getFileSize and isNewChannel to detect incorrect passwords. + // We'll use getFileSize and hasChannelHistory to detect incorrect passwords. // First we'll get the password value from our drive (getPadAttribute), and we'll check // if the channel is valid. If the pad is not stored in our drive, we'll test with an @@ -394,15 +399,15 @@ define([ } }; if (parsed.type === "file") { - // `isNewChannel` doesn't work for files (not a channel) + // `hasChannelHistory` doesn't work for files (not a channel) // `getFileSize` is not adapted to channels because of metadata Cryptpad.getFileSize(currentPad.href, password, function (e, size) { next(e, size === 0); }); return; } - // Not a file, so we can use `isNewChannel` - Cryptpad.isNewChannel(currentPad.href, password, next); + // Not a file, so we can use `hasChannelHistory` + Cryptpad.hasChannelHistory(currentPad.href, password, next); }); sframeChan.event("EV_PAD_PASSWORD", cfg); }; @@ -464,19 +469,32 @@ define([ currentPad.href = parsed.getUrl(opts); currentPad.hash = parsed.hashData && parsed.hashData.getHash(opts); } - Cryptpad.getPadAttribute('title', w(function (err, data) { + Cryptpad.getPadAttribute('channel', w(function (err, data) { stored = (!err && typeof (data) === "string"); })); Cryptpad.getPadAttribute('password', w(function (err, val) { password = val; }), parsed.getUrl()); }).nThen(function (w) { + // If we've already tested this password and this is a redirect, force + if (typeof(newPadPassword) !== "undefined" && newPadPasswordForce) { + password = newPadPassword; + return void todo(); + } + + // If the pad is not stored and we have a newPadPassword, it probably + // comes from a notification: password prompt pre-filled if (!password && !stored && newPadPassword) { passwordCfg.value = newPadPassword; } + // Pad not stored && password required: always ask for the password + if (!stored && parsed.hashData.password && !newPadPasswordForce) { + return void askPassword(true, passwordCfg); + } + if (parsed.type === "file") { - // `isNewChannel` doesn't work for files (not a channel) + // `hasChannelHistory` doesn't work for files (not a channel) // `getFileSize` is not adapted to channels because of metadata Cryptpad.getFileSize(currentPad.href, password, w(function (e, size) { if (size !== 0) { return void todo(); } @@ -485,8 +503,8 @@ define([ })); return; } - // Not a file, so we can use `isNewChannel` - Cryptpad.isNewChannel(currentPad.href, password, w(function(e, isNew) { + // Not a file, so we can use `hasChannelHistory` + Cryptpad.hasChannelHistory(currentPad.href, password, w(function(e, isNew) { if (isNew && expire && expire < (+new Date())) { sframeChan.event("EV_EXPIRED_ERROR"); waitFor.abort(); @@ -500,10 +518,6 @@ define([ waitFor.abort(); return; } - if (!stored && !parsed.hashData.password) { - // We've received a link without /p/ and it doesn't work without a password: abort - return void todo(); - } // Wrong password or deleted file? askPassword(true, passwordCfg); })); @@ -537,8 +551,7 @@ define([ if (realtime) { // TODO we probably don't need to check again for password-protected pads - // (we use isNewChannel to test the password...) - Cryptpad.isNewChannel(currentPad.href, password, waitFor(function (e, isNew) { + Cryptpad.hasChannelHistory(currentPad.href, password, waitFor(function (e, isNew) { if (e) { return console.error(e); } isNewFile = Boolean(isNew); })); @@ -604,6 +617,7 @@ define([ feedbackAllowed: Utils.Feedback.state, isPresent: parsed.hashData && parsed.hashData.present, isEmbed: parsed.hashData && parsed.hashData.embed, + oldVersionHash: parsed.hashData && parsed.hashData.version < 2, // password isHistoryVersion: parsed.hashData && parsed.hashData.versionHash, notifications: notifs, accounts: { @@ -1404,6 +1418,7 @@ define([ }; config.data = { app: parsed.type, + channel: secret.channel, hashes: hashes, password: password, isTemplate: isTemplate, @@ -1669,6 +1684,45 @@ define([ }); }); + sframeChan.on('Q_PASSWORD_CHECK', function (pw, cb) { + Cryptpad.isNewChannel(currentPad.href, pw, function (e, isNew) { + if (isNew === false) { + var channel = Utils.Hash.hrefToHexChannelId(currentPad.href, pw); + + nThen(function (w) { + // If the pad is stored, update its data + var _secret = Utils.Hash.getSecrets(parsed.type, parsed.hash, pw); + var chan = _secret.channel; + var editH = Utils.Hash.getEditHashFromKeys(_secret); + var viewH = Utils.Hash.getViewHashFromKeys(_secret); + var href = Utils.Hash.hashToHref(editH, parsed.type); + var roHref = Utils.Hash.hashToHref(viewH, parsed.type); + Cryptpad.setPadAttribute('password', password, w(), parsed.getUrl()); + Cryptpad.setPadAttribute('channel', chan, w(), parsed.getUrl()); + Cryptpad.setPadAttribute('href', href, w(), parsed.getUrl()); + Cryptpad.setPadAttribute('roHref', roHref, w(), parsed.getUrl()); + }).nThen(function () { + // Get redirect URL + var uHash = Utils.LocalStore.getUserHash(); + var uSecret = Utils.Hash.getSecrets('drive', uHash); + var uKey = uSecret.keys.cryptKey; + var url = Utils.Hash.getNewPadURL(currentPad.href, { + pw: Crypto.encrypt(pw, uKey), + f: 1 + }); + // redirect + window.location.href = url; + document.location.reload(); + }); + + return; + } + cb({ + error: e + }); + }); + }); + if (cfg.messaging) { sframeChan.on('Q_CHAT_OPENPADCHAT', function (data, cb) { Cryptpad.universal.execCommand({ diff --git a/www/common/toolbar.js b/www/common/toolbar.js index c42aa36cb..2c208c1c9 100644 --- a/www/common/toolbar.js +++ b/www/common/toolbar.js @@ -212,6 +212,7 @@ MessengerUI, Messages) { var $editUsersList = $('