diff --git a/.eslintignore b/.eslintignore index 8a1297856..ee60f382c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -38,3 +38,7 @@ www/debug/chainpad.dist.js www/pad/mathjax/ www/code/mermaid*.js www/code/orgmode.js + +www/common/worker.bundle.js +src/tweetnacl/ +_build/ diff --git a/move-merge-src.js b/move-merge-src.js index ed2583991..5c455efa6 100644 --- a/move-merge-src.js +++ b/move-merge-src.js @@ -6,6 +6,11 @@ const REVERSE = false; const Fs = require('node:fs'); const map = { + './src/worker/components/roster.js': './www/common/outer/roster.js', + './src/worker/components/sharedfolder.js': './www/common/outer/sharedfolder.js', + + + './src/common/cache-store.js': './www/common/outer/cache-store.js', './src/common/common-constants.js': './www/common/common-constants.js', './src/common/common-credential.js': './www/common/common-credential.js', @@ -41,5 +46,5 @@ Object.keys(map).forEach(newPath => { } const from = REVERSE ? oldPath : newPath; const to = REVERSE ? newPath : oldPath; - Fs.copySync(from, to); + Fs.cpSync(from, to); }); diff --git a/src/common/http-command.js b/src/common/http-command.js index 5f64de6d3..40b5c84f3 100644 --- a/src/common/http-command.js +++ b/src/common/http-command.js @@ -121,7 +121,7 @@ if (typeof(module) !== 'undefined' && module.exports) { '/api/config', '/components/tweetnacl/nacl-fast.min.js', ], (nThen, Util, ApiConfig) => { - factory(nThen, Util, ApiConfig, window.nacl); + return factory(nThen, Util, ApiConfig, window.nacl); }); } else { // unsupported initialization diff --git a/src/common/login-block.js b/src/common/login-block.js index dff0efb14..df3999cb7 100644 --- a/src/common/login-block.js +++ b/src/common/login-block.js @@ -236,7 +236,7 @@ if (typeof(module) !== 'undefined' && module.exports) { '/common/outer/http-command.js', '/components/tweetnacl/nacl-fast.min.js', ], (Util, ApiConfig, ServerCommand) => { - factory(Util, ApiConfig, ServerCommand, window.nacl); + return factory(Util, ApiConfig, ServerCommand, window.nacl); }); } else { // unsupported initialization diff --git a/src/common/pad-types.js b/src/common/pad-types.js index 25837944f..8c2f8014b 100644 --- a/src/common/pad-types.js +++ b/src/common/pad-types.js @@ -22,6 +22,10 @@ const factory = (AppConfig = {}, ApiConfig = {}, ); }; + // Initialize values when using in browser directly + if (Object.keys(AppConfig).length) { + setCustomize({AppConfig,ApiConfig}); + } const Types = { setCustomize }; diff --git a/src/common/recurrence.js b/src/common/recurrence.js index 24d748e8b..9fbb786ba 100644 --- a/src/common/recurrence.js +++ b/src/common/recurrence.js @@ -899,11 +899,9 @@ const factory = (Util) => { }; if (typeof(module) !== 'undefined' && module.exports) { - module.exports = factory( - require('../../common/common-util') - ); + module.exports = factory(require('./common-util')); } else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { - define(['/common/common-util'], factory); + define(['/common/common-util.js'], factory); } else { // unsupported initialization } diff --git a/www/calendar/recurrence.js b/www/calendar/recurrence.js index 015907143..9fbb786ba 100644 --- a/www/calendar/recurrence.js +++ b/www/calendar/recurrence.js @@ -6,6 +6,7 @@ const factory = (Util) => { var Rec = {}; + const window = globalThis; var debug = function () {}; // Get week number with any "WKST" (firts day of the week) @@ -898,7 +899,7 @@ const factory = (Util) => { }; if (typeof(module) !== 'undefined' && module.exports) { - module.exports = factory(require('../../common/common-util')); + module.exports = factory(require('./common-util')); } else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { define(['/common/common-util.js'], factory); } else { diff --git a/www/common/common-constants.js b/www/common/common-constants.js index 621214f71..7f224a080 100644 --- a/www/common/common-constants.js +++ b/www/common/common-constants.js @@ -2,8 +2,13 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -define(['/customize/application_config.js'], function (AppConfig) { +(() => { +const factory = function (AppConfig = {}) { return { + setCustomize: data => { + AppConfig = data.AppConfig; + }, + // localStorage userHashKey: 'User_hash', userNameKey: 'User_name', @@ -29,4 +34,14 @@ define(['/customize/application_config.js'], function (AppConfig) { criticalApps: ['profile', 'settings', 'debug', 'admin', 'support', 'notifications', 'calendar', 'moderation', 'oldadmin'], // XXX oldadmin earlyAccessApps: ['doc', 'presentation'] }; -}); +}; + +if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory(undefined); +} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define(['/customize/application_config.js'], factory); +} else { + // unsupported initialization +} +})(); + diff --git a/www/common/common-credential.js b/www/common/common-credential.js index 78686c001..151a1ced4 100644 --- a/www/common/common-credential.js +++ b/www/common/common-credential.js @@ -3,9 +3,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later (function () { -var factory = function (AppConfig, Scrypt) { +var factory = function (AppConfig = {}, Scrypt) { var Cred = {}; + Cred.setCustomize = data => { + AppConfig = data.AppConfig; + }; + Cred.MINIMUM_PASSWORD_LENGTH = typeof(AppConfig.minimumPasswordLength) === 'number'? AppConfig.minimumPasswordLength: 8; // TODO 14 or higher is a decent default for 2023 @@ -98,8 +102,8 @@ var factory = function (AppConfig, Scrypt) { if (typeof(module) !== 'undefined' && module.exports) { module.exports = factory( - {}, //require("../../customize.dist/application_config.js"), - require("../components/scrypt-async/scrypt-async.min.js") + undefined, //require("../../customize.dist/application_config.js"), + require("scrypt-async") ); } else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { define([ diff --git a/www/common/common-feedback.js b/www/common/common-feedback.js index 2c5baea5d..e4ab25a14 100644 --- a/www/common/common-feedback.js +++ b/www/common/common-feedback.js @@ -2,12 +2,15 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -define([ - '/customize/messages.js', - '/customize/application_config.js' -], function (Messages, AppConfig) { +(() => { +const factory = (AppConfig = {}, Messages= {}) => { var Feedback = {}; + Feedback.setCustomize = data => { + Messages = data.Messages; + AppConfig = data.AppConfig; + }; + Feedback.init = function (state) { Feedback.state = state; }; @@ -53,9 +56,24 @@ define([ Feedback.send('DIMENSIONS:' + h + 'x' + w); }; Feedback.reportLanguage = function () { + if (!Messages) { return; } Feedback.send('LANG_' + Messages._languageUsed); }; return Feedback; -}); +}; + +if (typeof(module) !== 'undefined' && module.exports) { + // Code from customize can't be laoded directly in the build + module.exports = factory(undefined, undefined); +} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([ + '/customize/application_config.js', + '/customize/messages.js' + ], factory); +} else { + // unsupported initialization +} + +})(); diff --git a/www/common/common-messaging.js b/www/common/common-messaging.js index 9a4b33531..f9b7fde24 100644 --- a/www/common/common-messaging.js +++ b/www/common/common-messaging.js @@ -2,15 +2,8 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -define([ - '/components/chainpad-crypto/crypto.js', - '/common/common-hash.js', - '/common/common-util.js', - '/common/common-constants.js', - '/customize/messages.js', - - '/common/common-realtime.js', -], function (Crypto, Hash, Util, Constants, Messages, Realtime) { +(() => { +const factory = (Crypto, Hash, Util, Constants, Realtime) => { var Msg = {}; var createData = Msg.createData = function (proxy, hash) { @@ -144,4 +137,25 @@ define([ }; return Msg; -}); +}; + +if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory( + require('chainpad-crypto'), + require('./common-hash'), + require('./common-util'), + require('./common-constants'), + require('./common-realtime') + ); +} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([ + '/components/chainpad-crypto/crypto.js', + '/common/common-hash.js', + '/common/common-util.js', + '/common/common-constants.js', + '/common/common-realtime.js', + ], factory); +} else { + // unsupported initialization +} +})(); diff --git a/www/common/common-realtime.js b/www/common/common-realtime.js index cad41ea2f..cdf942609 100644 --- a/www/common/common-realtime.js +++ b/www/common/common-realtime.js @@ -2,7 +2,8 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -define([], function () { +(() => { +const factory = () => { var common = {}; /* @@ -22,4 +23,14 @@ define([], function () { }; return common; -}); +}; + +if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory(); +} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([], factory); +} else { + // unsupported initialization +} + +})(); diff --git a/www/common/common-util.js b/www/common/common-util.js index 3b9b084fb..23f4809f0 100644 --- a/www/common/common-util.js +++ b/www/common/common-util.js @@ -368,6 +368,42 @@ }; + Util.fetchApi = function (origin, type, ignoreCache, cb) { + const url = new URL(origin); + url.pathname = `api/${type}`; + let href = url.href + (ignoreCache ? '?'+(+new Date()) : ''); + if (typeof(self) !== "undefined" && self.crypto) { + // Browser + fetch(url.href).then(res => { + if (!res.ok) { + throw new Error(`Fetch error: ${res.status}`); + } + return res.text(); + }).then(body => { + cb(JSON.parse(body.slice(27,-5))); + }).catch(err => { + console.error(err.message); + cb({}); + }); + } else if (typeof(require) !== "undefined") { + // NodeJS + const H = url.protocol === 'http:' ? + require('node:http') : require('node:https'); + H.get(url.href, res => { + let body = ''; + res.on('data', data => { body += data; }); + res.on('end', () => { + try { + cb(JSON.parse(body.slice(27,-5))); + } catch (e) { + console.error(e); + cb({}); + } + }); + }); + } + }; + Util.fetch = function (src, cb, progress, cache) { var CB = Util.once(Util.mkAsync(cb)); @@ -761,7 +797,6 @@ return ver || undefined; }; - /** Saferphore copied from the npm package: * https://www.npmjs.com/package/saferphore (MIT license) * because the umd definition doesn't work with rollup build @@ -797,9 +832,7 @@ } }; }; - /** - * End of code copied from saferphore - */ + /* End of code copied from saferphore */ if (typeof(module) !== 'undefined' && module.exports) { module.exports = Util; diff --git a/www/common/cryptget.js b/www/common/cryptget.js index ad86831b2..df3453225 100644 --- a/www/common/cryptget.js +++ b/www/common/cryptget.js @@ -2,19 +2,9 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -define([ - '/components/chainpad-crypto/crypto.js', - 'chainpad-netflux', - 'netflux-client', - '/common/common-util.js', - '/common/common-hash.js', - '/common/common-realtime.js', - '/common/outer/network-config.js', - '/common/outer/cache-store.js', - '/common/pinpad.js', - '/components/nthen/index.js', - '/components/chainpad/chainpad.dist.js', -], function (Crypto, CPNetflux, Netflux, Util, Hash, Realtime, NetConfig, Cache, Pinpad, nThen) { +(() => { +const factory = (Crypto, CPNetflux, Netflux, Util, + Hash, Realtime, NetConfig, Cache, Pinpad, nThen) => { var finish = function (S, err, doc) { if (S.done) { return; } S.cb((err && err.error), doc, err); @@ -211,4 +201,38 @@ define([ get: get, put: put, }; -}); +}; + +if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory( + require('chainpad-crypto'), + require('chainpad-netflux'), + require('netflux-websocket'), + require('./common-util'), + require('./common-hash'), + require('./common-realtime'), + require('./network-config'), + require('./cache-store'), + require('./pinpad'), + require('nthen'), + require('chainpad') + ); +} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([ + '/components/chainpad-crypto/crypto.js', + 'chainpad-netflux', + 'netflux-client', + '/common/common-util.js', + '/common/common-hash.js', + '/common/common-realtime.js', + '/common/outer/network-config.js', + '/common/outer/cache-store.js', + '/common/pinpad.js', + '/components/nthen/index.js', + '/components/chainpad/chainpad.dist.js', + ], factory); +} else { + // unsupported initialization +} +})(); + diff --git a/www/common/notify.js b/www/common/notify.js index 43711b0d1..35ff04d65 100644 --- a/www/common/notify.js +++ b/www/common/notify.js @@ -4,11 +4,19 @@ /* eslint compat/compat: "off" */ -define(['/api/config'], function (ApiConfig) { +(() => { +const factory = (ApiConfig = {}) => { + let window = globalThis; var Module = {}; + ApiConfig.requireConf = ApiConfig.requireConf || {}; + + Module.setCustomize = data => { + ApiConfig = data.ApiConfig; + }; + var apps = ['code', 'slide', 'pad', 'kanban', 'whiteboard', 'diagram', 'sheet', 'poll', 'teams', 'form', 'doc', 'presentation']; - var app = window.location.pathname.slice(1, -1); // remove "/" at the beginnin and the end + var app = window.location && window.location.pathname.slice(1, -1); // remove "/" at the beginnin and the end var suffix = apps.indexOf(app) !== -1 ? '-'+app : ''; var DEFAULT_MAIN = '/customize/favicon/main-favicon' + suffix + '.png?' + ApiConfig.requireConf.urlArgs; @@ -180,4 +188,14 @@ define(['/api/config'], function (ApiConfig) { }; return Module; -}); +}; + +if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory(undefined); +} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define(['/api/config'], factory); +} else { + // unsupported initialization +} + +})(); diff --git a/www/common/onlyoffice/current-version.js b/www/common/onlyoffice/current-version.js index c0ce636f1..59c468850 100644 --- a/www/common/onlyoffice/current-version.js +++ b/www/common/onlyoffice/current-version.js @@ -2,6 +2,16 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -define({ - currentVersion: 'v7' -}); +(() => { +const factory = () => { + return { + currentVersion: 'v7' + }; +}; + +if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory(); +} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([], factory); +} +})(); diff --git a/www/common/outer/cache-store.js b/www/common/outer/cache-store.js index 15b8e2d06..38ed12bb3 100644 --- a/www/common/outer/cache-store.js +++ b/www/common/outer/cache-store.js @@ -2,10 +2,10 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -define([ - '/common/common-util.js', - '/components/localforage/dist/localforage.min.js', -], function (Util, localForage) { +(() => { +const factory = (Util, localForage) => { + let window = globalThis; + let self = globalThis; var S = window.CryptPad_Cache = {}; var onReady = Util.mkEvent(true); @@ -191,4 +191,20 @@ define([ self.CryptPad_clearIndexedDB = S.clear; return S; -}); +}; + +if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory( + require('./common-util'), + require('localforage'), + ); +} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([ + '/common/common-util.js', + '/components/localforage/dist/localforage.min.js', + ], factory); +} else { + // unsupported initialization +} + +})(); diff --git a/www/common/outer/http-command.js b/www/common/outer/http-command.js index 03d59ddfa..40b5c84f3 100644 --- a/www/common/outer/http-command.js +++ b/www/common/outer/http-command.js @@ -2,14 +2,29 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -define([ - '/components/nthen/index.js', - '/common/common-util.js', - '/api/config', +(() => { +const factory = (nThen, Util, ApiConfig = {}, Nacl) => { + + const getApiOrigin = function () { + if (!Object.keys(ApiConfig).length) { return; } + var url; + var unsafeOriginURL = new URL(ApiConfig.httpUnsafeOrigin); + try { + url = new URL(ApiConfig.websocketPath, ApiConfig.httpUnsafeOrigin); + url.protocol = unsafeOriginURL.protocol; + return url.origin; + } catch (err) { + console.error(err); + return ApiConfig.httpUnsafeOrigin; + } + }; + var API_ORIGIN = getApiOrigin(); + + const setCustomize = data => { + ApiConfig = data.ApiConfig; + API_ORIGIN = getApiOrigin(); + }; - '/components/tweetnacl/nacl-fast.min.js', -], function (nThen, Util, ApiConfig) { - var Nacl = window.nacl; var clone = o => JSON.parse(JSON.stringify(o)); var randomToken = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)); var postData = function (url, data, cb) { @@ -36,18 +51,6 @@ define([ }); }; - var API_ORIGIN = (function () { - var url; - var unsafeOriginURL = new URL(ApiConfig.httpUnsafeOrigin); - try { - url = new URL(ApiConfig.websocketPath, ApiConfig.httpUnsafeOrigin); - url.protocol = unsafeOriginURL.protocol; - return url.origin; - } catch (err) { - console.error(err); - return ApiConfig.httpUnsafeOrigin; - } - }()); var serverCommand = function (keypair, my_data, cb) { var obj = clone(my_data); obj.publicKey = Nacl.util.encodeBase64(keypair.publicKey); @@ -99,5 +102,29 @@ define([ }); }; + serverCommand.setCustomize = setCustomize; + return serverCommand; -}); +}; + +if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory( + require('nthen'), + require('./common-util'), + undefined, + require('tweetnacl/nacl-fast') + ); +} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([ + '/components/nthen/index.js', + '/common/common-util.js', + '/api/config', + '/components/tweetnacl/nacl-fast.min.js', + ], (nThen, Util, ApiConfig) => { + return factory(nThen, Util, ApiConfig, window.nacl); + }); +} else { + // unsupported initialization +} + +})(); diff --git a/www/common/outer/login-block.js b/www/common/outer/login-block.js index fe0037dd8..df3999cb7 100644 --- a/www/common/outer/login-block.js +++ b/www/common/outer/login-block.js @@ -2,16 +2,16 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -define([ - '/common/common-util.js', - '/api/config', - '/common/outer/http-command.js', - '/components/tweetnacl/nacl-fast.min.js', -], function (Util, ApiConfig, ServerCommand) { - var Nacl = window.nacl; +(() => { +const factory = (Util, ApiConfig = {}, ServerCommand, Nacl) => { var Block = {}; + Block.setCustomize = data => { + ApiConfig = data.ApiConfig; + ServerCommand.setCustomize(data); + }; + Block.join = Util.uint8ArrayJoin; // publickey @@ -220,4 +220,26 @@ define([ }; return Block; -}); +}; + +if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory( + require('./common-util'), + undefined, + require('./http-command'), + require('tweetnacl/nacl-fast') + ); +} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([ + '/common/common-util.js', + '/api/config', + '/common/outer/http-command.js', + '/components/tweetnacl/nacl-fast.min.js', + ], (Util, ApiConfig, ServerCommand) => { + return factory(Util, ApiConfig, ServerCommand, window.nacl); + }); +} else { + // unsupported initialization +} + +})(); diff --git a/www/common/outer/network-config.js b/www/common/outer/network-config.js index 0371577ca..af09e70ff 100644 --- a/www/common/outer/network-config.js +++ b/www/common/outer/network-config.js @@ -2,18 +2,20 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -define([ - '/api/config' -], function (ApiConfig) { +(() => { +const factory = (ApiConfig = {}) => { var Config = {}; + Config.setCustomize = data => { + ApiConfig = data.ApiConfig; + }; + Config.getWebsocketURL = function (origin) { var path = ApiConfig.websocketPath || '/cryptpad_websocket'; if (/^ws{1,2}:\/\//.test(path)) { return path; } - var l = window.location; - if (origin && window && window.document) { - l = document.createElement("a"); + var l = new URL(origin || self?.location?.href); + if (origin) { l.href = origin; } var protocol = l.protocol.replace(/http/, 'ws'); @@ -24,4 +26,16 @@ define([ }; return Config; -}); +}; + +if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory(); +} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([ + '/api/config' + ], factory); +} else { + // unsupported initialization +} + +})(); diff --git a/www/common/outer/roster.js b/www/common/outer/roster.js new file mode 100644 index 000000000..3a0b796c5 --- /dev/null +++ b/www/common/outer/roster.js @@ -0,0 +1,958 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(function () { +var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto, Feedback) { + var Roster = {}; + + // this constant is somewhat arbitrary. + // Adjust it as you like to suit performance expectations + var CHECKPOINT_INTERVAL = 25; + var TIMEOUT_INTERVAL = 30000; // TIMEOUT after 30s + + /* + roster: { + state: { + members: { + user0CurveKey: { + notifications: "", // required + displayName: "", // required + role: "OWNER|ADMIN|MEMBER|VIEWER", // VIEWER if not specified + profile: "", + title: "" + }, + user1CurveKey: { + ... + } + }, + metadata: { + // guaranteed to be strings, but may be empty + topic: '', + name: '', + avatar: '', + // anything else you use may not be defined + } + } + } + */ + + var isMap = function (obj) { + return Boolean(obj && typeof(obj) === 'object' && !Array.isArray(obj)); + }; + + var getMessageId = function (msgString) { + return msgString.slice(0, 64); + }; + + var canCheckpoint = function (author, members) { + // if you're here then you've received a checkpoint message + // that you don't necessarily trust. + + // find the author's role from your knoweldge of the state + var role = Util.find(members, [author, 'role']); + + // and check if it is 'OWNER' or 'ADMIN' + return ['OWNER', 'ADMIN'].indexOf(role) !== -1; + }; + + var isValidRole = function (role) { + return ['OWNER', 'ADMIN', 'MEMBER', 'VIEWER'].indexOf(role) !== -1; + }; + + var isSelfDowngrade = function (author, curve, role, state) { + // Make sure you want to describe yourself + var selfDescribe = author === curve && state[curve]; + if (!selfDescribe) { return false; } + // ADMIN and OWNER can always update roles + // we only need to allow MEMBER to downgrade themselves to VIEWER + var authorRole = Util.find(state, [author, 'role']); + if (authorRole === "MEMBER") { return role === 'VIEWER'; } + }; + + var canAddRole = function (author, role, members) { + var authorRole = Util.find(members, [author, 'role']); + if (!authorRole) { return false; } + + // nobody can add an invalid role + if (!isValidRole(role)) { return false; } + + // owners can add any valid role they want + if (authorRole === 'OWNER') { return true; } + // admins can add other admins or members or viewers + if (authorRole === "ADMIN") { return ['ADMIN', 'MEMBER', 'VIEWER'].indexOf(role) !== -1; } + // (MEMBER, other) can't add anyone of any role + return false; + }; + + var isValidId = function (id) { + return typeof(id) === 'string' && id.length === 44; + }; + + var canDescribeTarget = function (author, curve, state) { + // you must be in the group to describe anyone + if (!state[curve]) { return false; } + + // anyone can describe themself + if (author === curve && state[curve]) { return true; } + + var authorRole = Util.find(state, [author, 'role']); + var targetRole = Util.find(state, [curve, 'role']); + + // something is really wrong if there's no authorRole + if (!authorRole) { return false; } + + // owners can do whatever they want + if (authorRole === 'OWNER') { return true; } + + // admins can describe anyone escept owners + if (authorRole === 'ADMIN' && targetRole !== 'OWNER') { return true; } + + // members can't describe others + return false; + }; + + var canRemoveRole = function (author, role, members) { + var authorRole = Util.find(members, [author, 'role']); + if (!authorRole) { return false; } + + // owners can remove anyone they want + if (authorRole === 'OWNER') { return true; } + // admins can remove other admins or members + if (authorRole === "ADMIN") { return ["ADMIN", "MEMBER", "VIEWER"].indexOf(role) !== -1; } + // MEMBERS and non-members cannot remove anyone of any role + return false; + }; + + var canUpdateMetadata = function (author, members) { + var authorRole = Util.find(members, [author, 'role']); + return Boolean(authorRole && ['OWNER', 'ADMIN'].indexOf(authorRole) !== -1); + }; + + var shouldCheckpoint = function (me, ref) { + // if you can't send valid checkpoints, don't try + if (!canCheckpoint(me, ref.state.members)) { return false; } + + // avoid sending checkpoints too often + // it's a balance between network constraints + // and the size of the roster's log + var since = ref.internal.sinceLastCheckpoint; + + if (!since || typeof(since) !== 'number' || since < CHECKPOINT_INTERVAL) { + return false; + } + + // if you can't think of any other reason not to... + return true; + }; + + var commands = Roster.commands = {}; + /* Commands are functions with the signature + (args_any, base46_author_string, roster_map, optional_base64_message_id) => boolean + + they: + * throw if any of their arguments are invalid + * return true if their application to previous state results in a change + * mutate the local account of the current state + + changes to the state can be simulated locally before being sent. + if the simulation throws or returns false, don't send. + + */ + + // the author is trying to add someone to the roster + // owners can add any role + commands.ADD = function (args, author, roster) { + if (!isMap(args)) { throw new Error("INVALID ARGS"); } + if (!roster.internal.initialized) { throw new Error("UNITIALIZED"); } + if (typeof(roster.state.members) === 'undefined') { + throw new Error("CANNOT_ADD_TO_UNITIALIZED_ROSTER"); + } + var members = roster.state.members; + + // iterate over everything and make sure it is valid, throw if not + Object.keys(args).forEach(function (curve) { + // FIXME only allow valid curve keys, anything else is pollution + if (!isValidId(curve)) { + console.log(curve, curve.length); + throw new Error("INVALID_CURVE_KEY"); + } + // reject commands where the members are not proper objects + if (!isMap(args[curve])) { throw new Error("INVALID_CONTENT"); } + if (members[curve]) { throw new Error("ALREADY_PRESENT"); } + + var data = args[curve]; + // if no role was provided, assume MEMBER + if (typeof(data.role) !== 'string') { data.role = 'MEMBER'; } + + if (!canAddRole(author, data.role, members)) { + throw new Error("INSUFFICIENT_PERMISSIONS"); + } + + if (typeof(data.displayName) !== 'string') { throw new Error("DISPLAYNAME_REQUIRED"); } + if (typeof(data.notifications) !== 'string') { throw new Error("NOTIFICATIONS_REQUIRED"); } + }); + + var changed = false; + // then iterate again and apply it + Object.keys(args).forEach(function (curve) { + // this will result in a change + changed = true; + members[curve] = args[curve]; + }); + + return changed; + }; + + commands.RM = function (args, author, roster) { + if (!Array.isArray(args)) { throw new Error("INVALID_ARGS"); } + + if (typeof(roster.state.members) === 'undefined') { + throw new Error("CANNOT_RM_FROM_UNITIALIZED_ROSTER"); + } + var members = roster.state.members; + + // validate first... + args.forEach(function (curve) { + if (!isValidId(curve)) { throw new Error("INVALID_CURVE_KEY"); } + + // even members can remove themselves + if (curve === author) { return; } + + // but if it concerns anyone else, validate that the author has sufficient permissions + var role = members[curve].role; + if (!canRemoveRole(author, role, members)) { throw new Error("INSUFFICIENT_PERMISSIONS"); } + }); + + var changed = false; + args.forEach(function (curve) { + // don't try to remove something that isn't there + if (!members[curve]) { return; } + changed = true; + delete members[curve]; + }); + return changed; + }; + + commands.DESCRIBE = function (args, author, roster) { + if (!args || typeof(args) !== 'object' || Array.isArray(args)) { + throw new Error("INVALID_ARGUMENTS"); + } + + if (typeof(roster.state.members) === 'undefined') { + throw new Error("NOT_READY"); + } + var members = roster.state.members; + + // iterate over all the data and make sure it is valid, throw otherwise + Object.keys(args).forEach(function (curve) { + if (!isValidId(curve)) { throw new Error("INVALID_ID"); } + if (!members[curve]) { throw new Error("NOT_PRESENT"); } + + if (!canDescribeTarget(author, curve, members)) { throw new Error("INSUFFICIENT_PERMISSIONS"); } + + var data = args[curve]; + if (!isMap(data)) { throw new Error("INVALID_ARGUMENTS"); } + + var current = Util.clone(members[curve]); + + if (typeof(data.role) === 'string') { // they're trying to change the role... + // throw if they're trying to upgrade to something greater + if (!isSelfDowngrade(author, curve, data.role, members) && + !canAddRole(author, data.role, members)) { + throw new Error("INSUFFICIENT_PERMISSIONS"); + } + } + // DESCRIBE commands must initialize a displayName if it isn't already present + if (typeof(current.displayName) !== 'string' && typeof(data.displayName) !== 'string') { + throw new Error('DISPLAYNAME_REQUIRED'); + } + + if (['undefined', 'string'].indexOf(typeof(data.displayName)) === -1) { + throw new Error("INVALID_DISPLAYNAME"); + } + + // DESCRIBE commands must initialize a mailbox channel if it isn't already present + if (typeof(current.notifications) !== 'string' && typeof(data.notifications) !== 'string') { + throw new Error('NOTIFICATIONS_REQUIRED'); + } + if (['undefined', 'string'].indexOf(typeof(data.notifications)) === -1) { + throw new Error("INVALID_NOTIFICATIONS"); + } + }); + + var changed = false; + // then do a second pass and apply it if there were changes + Object.keys(args).forEach(function (curve) { + var current = Util.clone(members[curve]); + + var data = args[curve]; + + Object.keys(data).forEach(function (key) { + // when null is passed as new data and it wasn't considered an invalid change + // remove it from the map. This is how you delete things properly + if (typeof(current[key]) !== 'undefined' && data[key] === null) { return void delete current[key]; } + current[key] = data[key]; + }); + + if (Sortify(current) !== Sortify(members[curve])) { + changed = true; + members[curve] = current; + } + }); + + return changed; + }; + + commands.CHECKPOINT = function (args, author, roster) { + // args: complete state + + // args should be a map + if (!isMap(args)) { throw new Error("INVALID_CHECKPOINT_STATE"); } + + if (!roster.internal.initialized) { + //console.log("INITIALIZING"); + // either you're connecting from the beginning of the log + // or from a trusted lastKnownHash. + // Either way, initialize the roster state + + roster.state = args; + var metadata = roster.state.metadata = roster.state.metadata || {}; + metadata.topic = metadata.topic || ''; + metadata.name = metadata.name || ''; + metadata.avatar = metadata.avatar || ''; + + roster.internal.initialized = true; + return true; + } else if (Sortify(args) !== Sortify(roster.state)) { + // a checkpoint must reinsert the previous state + throw new Error("CHECKPOINT_DOES_NOT_MATCH_PREVIOUS_STATE"); + } + + // otherwise, you're iterating over the log from a previous checkpoint + // so you should know everyone's role + + // owners and admins can checkpoint. members and non-members cannot + if (!canCheckpoint(author, roster.state.members)) { throw new Error("INSUFFICIENT_PERMISSIONS"); } + + // set the state, and indicate that a change was made + roster.state = args; + return true; + }; + + var MANDATORY_METADATA_FIELDS = [ + 'avatar', + 'name', + 'topic', + ]; + + // only admin/owner can change group metadata + commands.METADATA = function (args, author, roster) { + if (!isMap(args)) { throw new Error("INVALID_ARGS"); } + + if (!canUpdateMetadata(author, roster.state.members)) { throw new Error("INSUFFICIENT_PERMISSIONS"); } + + // validate inputs + Object.keys(args).forEach(function (k) { + if (args[k] === null) { + if (MANDATORY_METADATA_FIELDS.indexOf(k) === -1) { return; } + throw new Error('CANNOT_REMOVE_MANDATORY_METADATA'); + } + + // can't set metadata to anything other than strings + // use empty string to unset a value if you must + if (typeof(args[k]) !== 'string') { throw new Error("INVALID_ARGUMENTS"); } + }); + + var changed = false; + // {topic, name, avatar} are all strings... + Object.keys(args).forEach(function (k) { + if (typeof(roster.state.metadata[k]) !== 'undefined' && args[k] === null) { + changed = true; + delete roster.state.metadata[k]; + } + + // ignore things that won't cause changes + if (args[k] === roster.state.metadata[k]) { return; } + + changed = true; + roster.state.metadata[k] = args[k]; + }); + return changed; + }; + + commands.INVITE = function (args, author, roster) { + // an invitation is created with an ephemeral curve public key + // that key is ultimately given to the user you'd like on your team + // that user can exploit their possession of the public key to remove + // the pending invitation with their actual data. + if (!isMap(args)) { throw new Error('INVALID_ARGS'); } + if (!roster.internal.initialized) { throw new Error("UNINITIALIED"); } + if (typeof(roster.state.members) === 'undefined') { + throw new Error("CANNOT+INVITE_TO_UNINITIALIED_ROSTER"); + } + + var members = roster.state.members; + + Object.keys(args).forEach(function (curve) { + if (!isValidId(curve)) { + console.log(curve, curve.length); + throw new Error("INVALID_CURVE_KEY"); + } + // reject commandws wehere the members are not proper objects + if (!isMap(args[curve])) { throw new Error("INVALID_CONTENT"); } + if (members[curve]) { throw new Error("ARLEADY_PRESENT"); } + + var data = args[curve]; + // if no role was provided, assume VIEWER + if (typeof(data.role) !== 'string') { data.role = "VIEWER"; } + + // assume that invitations are 'pending' unless stated otherwise + if (typeof(data.pending) === 'undefined') { data.pending = true; } + + if (!canAddRole(author, data.role, members)) { + throw new Error("INSUFFICIENT_PERMISSIONS"); + } + + if (typeof(data.displayName) !== 'string' || !data.displayName) { throw new Error("DISPLAYNAME_REQUIRED"); } + //if (typeof(data.notifications) !== 'string') { throw new Error("NOTIFICATIONS_REQUIRED"); } + }); + + + /* + { + : { + role: ??? || 'VIEWER', + displayName: '', + pending: true, + } + } + */ + + var changed = false; + + Object.keys(args).forEach(function (curve) { + changed = true; + members[curve] = args[curve]; + }); + + return changed; + }; + + commands.ACCEPT = function (args, author, roster) { + if (!roster.internal.initialized) { throw new Error("UNINITIALIED"); } + if (typeof(roster.state.members) === 'undefined') { + throw new Error("CANNOT_ADD_TO_UNINITIALIED_ROSTER"); + } + + // an ACCEPT command replaces a pending invitation's curve key with a new one + // after which the invited member can use their actual curve key to describe themselves + + // the author must have been invited already... + var members = roster.state.members; + + // so you must already be in the members list + if (!isMap(members[author])) { throw new Error("INSUFFICIENT_PERMISSIONS"); } + // and your membership must indicate that you are 'pending' + if (!members[author].pending) { throw new Error("ALREADY_PRESENT"); } + + // args should be a string + if (typeof(args) !== 'string') { throw new Error("INVALID_ARGS"); } + // ...and a valid curve key + if (!isValidId(args)) { throw new Error("INVALID_CURVE_KEY"); } + + var curve = args; + + // and the curve key must not already be a member + if (typeof(members[curve]) !== 'undefined') { throw new Error("MEMBER_ALREADY_PRESENT"); } + + // copy the new profile from the old one + var clone = Util.clone(members[author]); + delete clone.remaining; + delete clone.totalUses; + delete clone.inviteChannel; + delete clone.previewChannel; + members[curve] = clone; + + var remaining = members[author].remaining || 1; + if (remaining === -1) { return true; } // Infinite uses, keep the link + if (remaining > 1) { // Remove 1 use + members[author].remaining = remaining - 1; + } else { // Disable link + delete members[author]; + } + + return true; + }; + + var handleCommand = function (content, author, roster) { + if (!(Array.isArray(content) && typeof(author) === 'string')) { + throw new Error("INVALID ARGUMENTS"); + } + + var command = content[0]; + if (typeof(commands[command]) !== 'function') { throw new Error('INVALID_COMMAND'); } + + return commands[command](content[1], author, roster); + }; + + var simulate = function (content, author, roster) { + return handleCommand(content, author, Util.clone(roster)); + }; + + Roster.create = function (config, _cb) { + if (typeof(_cb) !== 'function') { throw new Error("EXPECTED_CALLBACK"); } + var cb = Util.once(Util.mkAsync(_cb)); + + if (!config.network) { return void cb("EXPECTED_NETWORK"); } + if (!config.channel || typeof(config.channel) !== 'string' || config.channel.length !== 32) { return void cb("EXPECTED_CHANNEL"); } + if (!config.keys || typeof(config.keys) !== 'object') { return void cb("EXPECTED_CRYPTO_KEYS"); } + if (!config.store) { return void cb("EXPECTED_STORE"); } + + + var response = Util.response(function (label, info) { + console.error('ROSTER_RESPONSE__' + label, info); + }); + var store = config.store; + var keys = config.keys; + var me = keys.myCurvePublic; + var channel = config.channel; + var lastKnownHash = config.lastKnownHash || -1; + + // make sure we don't send -1 (ask for full history) when we are trying to create a new team + if (config.newTeam) { + lastKnownHash = undefined; + } + + var ref = { + state: { + members: { }, + metadata: { }, + }, + internal: { + initialized: false, + sinceLastCheckpoint: 0, + lastCheckpointHash: lastKnownHash, + }, + }; + var roster = {}; + var events = { + change: Util.mkEvent(), + checkpoint: Util.mkEvent(), + }; + + roster.on = function (key, handler) { + if (typeof(events[key]) !== 'object') { throw new Error("unsupported event"); } + events[key].reg(handler); + return roster; + }; + + roster.off = function (key, handler) { + if (typeof(events[key]) !== 'object') { throw new Error("unsupported event"); } + events[key].unreg(handler); + return roster; + }; + + roster.once = function (key, handler) { + if (typeof(events[key]) !== 'object') { throw new Error("unsupported event"); } + var f = function () { + handler.apply(null, Array.prototype.slice.call(arguments)); + events[key].unreg(f); + }; + events[key].reg(f); + return roster; + }; + + roster.getState = function () { + //if (!isMap(ref.state)) { return; } + return Util.clone(ref.state); + }; + + roster.getLastCheckpointHash = function () { + return ref.internal.lastCheckpointHash || -1; + }; + + var clearPendingCheckpoints = function () { + // clear any pending checkpoints you might have... + if (ref.internal.pendingCheckpointId) { + response.clear(ref.internal.pendingCheckpointId); + delete ref.internal.pendingCheckpointId; + } + clearTimeout(ref.internal.checkpointTimeout); + delete ref.internal.checkpointTimeout; + }; + + roster.stop = function () { + if (ref.internal.cpNetflux && typeof(ref.internal.cpNetflux.stop) === "function") { + ref.internal.cpNetflux.stop(); + clearPendingCheckpoints(); + } else { + console.log("FAILED TO LEAVE"); + } + }; + var ready = false; + var onCacheReady = function () { + if (!config.onCacheReady) { return; } + var state = ref.state; + if (!Object.keys(state.members || {}).length) { + // No member, corrupted cache + try { + ref.internal.cpNetflux.resetCache(); + } catch (e) { console.error(e); } + return void config.onCacheReady({error: "CORRUPTED"}); + } + config.onCacheReady(roster); + }; + var onReady = function () { + //console.log("READY"); + ready = true; + cb(void 0, roster); + }; + + // onError (deleted or expired) + // you won't be able to connect + + // onMetadataUpdate + // update owners? + + // deleted while you are open + // emit an event + var onChannelError = function (info) { + if (Feedback) { Feedback.send('ROSTER_CHANNEL_ERROR='+(info && info.type)); } + if (info && info.type === "EUNKNOWN") { + // chainpad-netflux should recover by itself + return; + } + if (!ready) { return void cb(info); } + console.error("CHANNEL_ERROR", info); + }; + + var onConnectionChange = function (info) { + if (info.state) { return; } + // Disconnect: don't send event anymore until ready + ready = false; + }; + + var onConnect = function (/* wc, sendMessage */) { + console.log("ROSTER CONNECTED"); + }; + + var isReady = function () { + return Boolean(ready && me); + }; + + var onMessage = function (msg, user, vKey, isCp , hash, author) { + // count messages received since the last checkpoint + // even if they fail to parse + ref.internal.sinceLastCheckpoint++; + + var parsed = Util.tryParse(msg); + + if (!parsed) { return void console.error("could not parse"); } + + var changed; + var error; + try { + changed = handleCommand(parsed, author, ref); + } catch (err) { + error = err.message; + } + + var id = getMessageId(hash); + + if (response.expected(id)) { + if (error) { return void response.handle(id, [error]); } + try { + if (!changed) { + response.handle(id, ['NO_CHANGE']); + console.log(msg); + } else { + response.handle(id, [void 0, roster.getState()]); + } + } catch (err) { + console.log('CAUGHT', err); + } + } + + // if a checkpoint was successfully applied, emit an event + if (parsed[0] === 'CHECKPOINT' && changed) { + if (isReady()) { events.checkpoint.fire(hash); } + // reset the counter for messages since the last checkpoint + ref.internal.sinceLastCheckpoint = 0; + ref.internal.lastCheckpointHash = hash; + } else if (changed) { + if (isReady()) { events.change.fire(); } + } + + // CHECKPOINT logic... + clearPendingCheckpoints(); + if (!isReady() || !shouldCheckpoint(me, ref)) { return; } + // a random number of seconds between 5 and 25 + var delay = (1000 * Math.floor(Math.random() * 20)) + 5000; + + // if you're here then you can and should send a checkpoint + // but since multiple users who can and should might be online at once + // and since they'll all trigger this process at the same time... + // we want to stagger attempts at random intervals + ref.internal.checkpointTimeout = setTimeout(function () { + ref.internal.pendingCheckpointId = roster.checkpoint(function (err) { + if (err) { console.error(err); } + }); + }, delay); + }; + + var isCacheCheckpoint = function (msg, author) { + var parsed = Util.tryParse(msg); + if (parsed[0] !== 'CHECKPOINT') { return false; } + var changed = simulate(parsed, author, ref); + return changed; + }; + + var metadata, crypto; + var send = function (msg, cb) { + if (!isReady()) { return void cb("NOT_READY"); } + var anon_rpc = store.anon_rpc; + if (!anon_rpc) { return void cb("ANON_RPC_NOT_READY"); } + + var changed = false; + try { + // simulate the command before you send it + changed = simulate(msg, keys.myCurvePublic, ref); + } catch (err) { + return void cb(err.message); + } + if (!changed) { return void cb("NO_CHANGE"); } + + var ciphertext = crypto.encrypt(Sortify(msg)); + + var id = getMessageId(ciphertext); + + //console.log("Sending with id [%s]", id, msg); + //console.log(); + + response.expect(id, function (err, state) { + if (err) { return void cb(err); } + cb(void 0, state, id); + }, TIMEOUT_INTERVAL); + anon_rpc.send('WRITE_PRIVATE_MESSAGE', [ + channel, + ciphertext + ], function (err) { + if (err) { return response.handle(id, [err.message || err]); } + }); + return id; + }; + + roster.init = function (_data, _cb) { + var cb = Util.once(Util.mkAsync(_cb)); + if (ref.internal.initialized) { return void cb("ALREADY_INITIALIZED"); } + if (!isMap(_data)) { return void cb("INVALID_ARGUMENTS"); } + var data = Util.clone(_data); + data.role = 'OWNER'; + var members = {}; + members[me] = data; + send([ 'CHECKPOINT', { members: members } ], cb); + }; + + // commands + roster.checkpoint = function (_cb) { + var cb = Util.once(Util.mkAsync(_cb)); + send([ 'CHECKPOINT', Util.clone(ref.state)], cb); + }; + + roster.add = function (_data, _cb) { + var cb = Util.once(Util.mkAsync(_cb)); + //var state = ref.state; + if (!ref.internal.initialized) { return cb("UNINITIALIZED"); } + if (!isMap(_data)) { return void cb("INVALID_ARGUMENTS"); } + var data = Util.clone(_data); + + // don't add members that are already present + // use DESCRIBE to amend + Object.keys(data).forEach(function (curve) { + if (!isValidId(curve) || isMap(ref.state.members[curve])) { return delete data[curve]; } + }); + + send([ 'ADD', data ], cb); + }; + + roster.remove = function (_data, _cb) { + var cb = Util.once(Util.mkAsync(_cb)); + var state = ref.state; + if (!state) { return cb("UNINITIALIZED"); } + + if (!Array.isArray(_data)) { return void cb("INVALID_ARGUMENTS"); } + var data = Util.clone(_data); + + var toRemove = []; + var current = Object.keys(state.members); + data.forEach(function (curve) { + // don't try to remove elements which are not in the current state + if (current.indexOf(curve) === -1) { return; } + toRemove.push(curve); + }); + + send([ 'RM', toRemove ], cb); + }; + + roster.describe = function (_data, _cb) { + var cb = Util.once(Util.mkAsync(_cb)); + var state = ref.state; + + if (!state) { return cb("UNINITIALIZED"); } + if (!isMap(_data)) { return void cb("INVALID_ARGUMENTS"); } + var data = Util.clone(_data); + + if (Object.keys(data).some(function (curve) { + var member = data[curve]; + if (!isMap(member)) { delete data[curve]; } + // validate that you're trying to describe a user that is present + if (!isMap(state.members[curve])) { return true; } + // don't send fields that won't result in a change + Object.keys(member).forEach(function (k) { + if (member[k] === state.members[curve][k]) { delete member[k]; } + }); + })) { + // returning true in the above loop indicates that something was invalid + return void cb("INVALID_ARGUMENTS"); + } + + send(['DESCRIBE', data], cb); + }; + + roster.metadata = function (_data, _cb) { + var cb = Util.once(Util.mkAsync(_cb)); + var metadata = ref.state.metadata; + if (!isMap(_data)) { return void cb("INVALID_ARGUMENTS"); } + var data = Util.clone(_data); + + Object.keys(data).forEach(function (k) { + if (data[k] === metadata[k]) { delete data[k]; } + }); + send(['METADATA', data], cb); + }; + + // supports multiple invite + roster.invite = function (_data, _cb) { + var cb = Util.once(Util.mkAsync(_cb)); + var state = ref.state; + if (!state) { return cb("UNINITIALIZED"); } + if (!ref.internal.initialized) { return cb("UNINITIALIZED"); } + if (!isMap(_data)) { return void cb("INVALID_ARGUMENTS"); } + var data = Util.clone(_data); + + Object.keys(data).forEach(function (curve) { + if (!isValidId(curve) || isMap(ref.state.members[curve])) { return delete data[curve]; } + }); + + send(['INVITE', data], cb); + }; + + roster.accept = function (_data, _cb) { + var cb = Util.once(Util.mkAsync(_cb)); + if (typeof(_data) !== 'string' || !isValidId(_data)) { + return void cb("INVALID_ARGUMENTS"); + } + + send([ 'ACCEPT', _data ], cb); + }; + + nThen(function (w) { + // get metadata so we know the owners and validateKey + if (!store.anon_rpc) { return; } + store.anon_rpc.send('GET_METADATA', channel, function (err, data) { + if (err) { + w.abort(); + return void console.error(err); + } + metadata = ref.internal.metadata = (data && data[0]) || undefined; + }); + }).nThen(function (w) { + if (!config.keys.teamEdPublic && metadata && metadata.validateKey) { + config.keys.teamEdPublic = metadata.validateKey; + } + if (!config.keys.teamEdPublic) { + w.abort(); + return void cb("NO_VALIDATE_KEY"); + } + + try { + crypto = Crypto.Team.createEncryptor(config.keys); + } catch (err) { + w.abort(); + return void cb(err); + } + }).nThen(function () { + if (typeof(lastKnownHash) === 'string') { + console.log("Synchronizing from checkpoint"); + } + + ref.internal.cpNetflux = CPNetflux.start({ + // if you don't have a lastKnownHash you will need the full history + // passing -1 forces the server to send all messages, otherwise + // malicious users with the signing key could send cp| messages + // and fool new users into initializing their session incorrectly + lastKnownHash: lastKnownHash, + + network: config.network, + channel: config.channel, + + crypto: crypto, + validateKey: config.keys.teamEdPublic, + + owners: config.owners, + + Cache: config.Cache, + isCacheCheckpoint: isCacheCheckpoint, + onCacheReady: onCacheReady, + + onChannelError: onChannelError, + onReady: onReady, + onConnect: onConnect, + onConnectionChange: onConnectionChange, + onMessage: onMessage, + + noChainPad: true, + }); + }); + }; + + return Roster; +}; + + if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory( + require("../../common/common-util"), + require("../../common/common-hash"), + require('chainpad-netflux'), + require('json.sortify'), + require("nthen"), + require("chainpad-crypto"), + null // no feedback here + ); + } else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + require.config({ paths: { 'json.sortify': '/components/json.sortify/dist/JSON.sortify' } }); + define([ + '/common/common-util.js', + '/common/common-hash.js', + 'chainpad-netflux', + 'json.sortify', + '/components/nthen/index.js', + '/components/chainpad-crypto/crypto.js', + '/common/common-feedback.js', + //'/components/tweetnacl/nacl-fast.min.js', + ], function (Util, Hash, CPNF, Sortify, nThen, Crypto, Feedback) { + return factory.apply(null, [ + Util, + Hash, + CPNF, + Sortify, + nThen, + Crypto, + Feedback + ]); + }); + } else { + // I'm not gonna bother supporting any other kind of instanciation + } +}()); diff --git a/www/common/outer/sharedfolder.js b/www/common/outer/sharedfolder.js new file mode 100644 index 000000000..705de8c88 --- /dev/null +++ b/www/common/outer/sharedfolder.js @@ -0,0 +1,417 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(() => { +const factory = (Hash, Util, UserObject, Cache, + nThen, Crypto, Listmap, ChainPad) => { + var SF = {}; + + /* load + create and load a proxy using listmap for a given shared folder + - config: network and "manager" (either the user one or a team manager) + - id: shared folder id + */ + + var allSharedFolders = {}; + + // No version: visible edit + // Version 2: encrypted edit links + SF.checkMigration = function (secondaryKey, proxy, uo, _cb) { + var cb = Util.once(Util.mkAsync(_cb)); + var drive = proxy.drive || proxy; + // View access: can't migrate + if (!secondaryKey) { return void cb(); } + // Already migrated: nothing to do + if (drive.version >= 2) { return void cb(); } + // Not yet migrating: migrate + if (!drive.migrateRo) { return void uo.migrateReadOnly(cb); } + // Already migrating: wait for the end... + var done = false; + var to; + var it = setInterval(function () { + if (drive.version >= 2) { + done = true; + clearTimeout(to); + clearInterval(it); + return void cb(); + } + }, 100); + to = setTimeout(function () { + clearInterval(it); + uo.migrateReadOnly(function () { + done = true; + cb(); + }); + }, 20000); + var path = proxy.drive ? ['drive', 'version'] : ['version']; + proxy.on('change', path, function () { + if (done) { return; } + if (drive.version >= 2) { + done = true; + clearTimeout(to); + clearInterval(it); + cb(); + } + }); + }; + + // SFMIGRATION: only needed if we want a manual migration from the share modal... + SF.migrate = function (channel) { + var sf = allSharedFolders[channel]; + if (!sf) { return; } + var clients = sf.teams; + if (!Array.isArray(clients) || !clients.length) { return; } + var c = clients[0]; + // No secondaryKey? ==> already migrated ==> abort + if (!c.secondaryKey) { return; } + var f = Util.find(c, ['store', 'manager', 'folders', c.id]); + // Can't find the folder: abort + if (!f) { return; } + // Already migrated: abort + if (!f.proxy || f.proxy.version) { return; } + f.userObject.migrateReadOnly(function () { + clients.forEach(function (obj) { + var uo = Util.find(obj, ['store', 'manager', 'folders', obj.id, 'userObject']); + uo.setReadOnly(false, obj.secondarykey); + }); + }); + }; + + SF.load = function (config, id, data, _cb) { + var cb = Util.once(Util.mkAsync(_cb)); + var network = config.network; + var store = config.store; + var isNew = config.isNew; + var isNewChannel = config.isNewChannel; + var teamId = store.id; + var handler = store.handleSharedFolder; + + var href = store.manager.user.userObject.getHref(data); + + var parsed = Hash.parsePadUrl(href); + var secret = Hash.getSecrets('drive', parsed.hash, data.password); + // If we don't have valid keys, abort and remove the proxy to make sure + // we don't block the drive permanently + if (!secret.keys) { + store.manager.deprecateProxy(id); + return void cb(null); + } + var secondaryKey = secret.keys.secondaryKey; + + // If we try to load an existing shared folder (isNew === false) but this folder + // doesn't exist in the database, abort and cb + nThen(function (waitFor) { + // If we're in onCacheReady, make sure we have a cache for this shared folder + if (config.cache) { + Cache.getChannelCache(secret.channel, waitFor(function (err) { + if (err === "EINVAL") { // Cache not found + waitFor.abort(); + store.manager.restrictedProxy(id, secret.channel); + return void cb(null); + } + })); + } + }).nThen(function (waitFor) { + isNewChannel(null, { channel: secret.channel }, waitFor(function (obj) { + if (obj.isNew && !isNew) { + store.manager.deprecateProxy(id, secret.channel, obj.reason); + waitFor.abort(); + return void cb(null); + } + })); + }).nThen(function () { + var sf = allSharedFolders[secret.channel]; + if (sf && sf.readOnly && secondaryKey) { + // We were in readOnly mode and now we know the edit keys! + SF.upgrade(secret.channel, secret); + } + if (sf && sf.ready && sf.rt) { + // The shared folder is already loaded, return its data + setTimeout(function () { + var leave = function () { SF.leave(secret.channel, teamId); }; + /* + var uo = store.manager.addProxy(id, sf.rt, leave, secondaryKey); + // NOTE: Shared folder migration, disable for now + SF.checkMigration(secondaryKey, sf.rt.proxy, uo, function () { + cb(sf.rt); + }); + */ + store.manager.addProxy(id, sf.rt, leave, secondaryKey); + cb(sf.rt); + }); + sf.teams.push({ + cb: cb, + store: store, + id: id + }); + if (handler) { handler(id, sf.rt); } + return; + } + if (sf && !sf.ready && sf.rt) { + // The shared folder is loading, add our callbacks to the queue + sf.teams.push({ + cb: cb, + store: store, + secondaryKey: secondaryKey, + id: id + }); + if (handler) { handler(id, sf.rt); } + return; + } + + sf = allSharedFolders[secret.channel] = { + teams: [{ + cb: cb, + store: store, + secondaryKey: secondaryKey, + id: id + }], + readOnly: !Boolean(secondaryKey) + }; + + var owners = data.owners; + var listmapConfig = { + data: {}, + channel: secret.channel, + readOnly: !Boolean(secondaryKey), + crypto: Crypto.createEncryptor(secret.keys), + userName: 'sharedFolder', + logLevel: 1, + ChainPad: ChainPad, + classic: true, + network: network, + Cache: Cache, // shared-folder cache + metadata: { + validateKey: secret.keys.validateKey || undefined, + owners: owners + }, + onRejected: config.Store && config.Store.onRejected + }; + var rt = sf.rt = Listmap.create(listmapConfig); + rt.proxy.on('cacheready', function () { + if (!sf.teams) { + return; + } + sf.teams.forEach(function (obj) { + var leave = function () { SF.leave(secret.channel, obj.store.id); }; + + // We can safely call addProxy and obj.cb here because + // 1. addProxy won't re-add the same folder twice on 'ready' + // 2. obj.cb is using Util.once + rt.cache = true; + + // If we're updating the password of an existing folder, force the creation + // of a new userobject in proxy-manager. Once it's done, remove this flag + // to make sure we won't create a second new userobject on 'ready' + obj.store.manager.addProxy(obj.id, rt, leave, obj.secondaryKey, config.updatePassword); + config.updatePassword = false; + obj.cb(sf.rt); + }); + sf.ready = true; + }); + rt.proxy.on('ready', function () { + if (isNew && !Object.keys(rt.proxy).length) { + // New Shared folder: no migration required + rt.proxy.version = 2; + } + if (!sf.teams) { + return; + } + sf.teams.forEach(function (obj) { + var leave = function () { SF.leave(secret.channel, obj.store.id); }; + /* + var uo = obj.store.manager.addProxy(obj.id, rt, leave, obj.secondaryKey); + // NOTE: Shared folder migration, disable for now + SF.checkMigration(secondaryKey, rt.proxy, uo, function () { + obj.cb(sf.rt); + }); + */ + rt.cache = false; + obj.store.manager.addProxy(obj.id, rt, leave, obj.secondaryKey, config.updatePassword); + obj.cb(sf.rt); + }); + sf.ready = true; + }); + rt.proxy.on('error', function (info) { + if (info && info.error) { + if (info.error === "EDELETED" ) { + try { + // Deprecate the shared folder from each team + // We can only hide it + sf.teams.forEach(function (obj) { + obj.store.manager.deprecateProxy(obj.id, secret.channel, info.message); + if (obj.store.handleSharedFolder) { + obj.store.handleSharedFolder(obj.id, null); + } + obj.cb(); + }); + } catch (e) {} + delete allSharedFolders[secret.channel]; + // This shouldn't be called on init because we're calling "isNewChannel" first, + // but we can still call "cb" just in case. This wait we make sure we won't block + // the initial "waitFor" + return void cb(); + } + if (info.error === "ERESTRICTED" ) { + sf.teams.forEach(function (obj) { + obj.store.manager.restrictedProxy(obj.id, secret.channel); + obj.cb(); + }); + delete allSharedFolders[secret.channel]; + return void cb(); + } + } + }); + + if (handler) { handler(id, rt); } + }); + }; + + + SF.upgrade = function (channel, secret) { + var sf = allSharedFolders[channel]; + if (!sf || !sf.readOnly) { return; } + if (!sf.rt.setReadOnly) { return; } + + if (!secret.keys || !secret.keys.editKeyStr) { return; } + var crypto = Crypto.createEncryptor(secret.keys); + sf.readOnly = false; + sf.rt.setReadOnly(false, crypto); + }; + + SF.leave = function (channel, teamId) { + var sf = allSharedFolders[channel]; + if (!sf) { return; } + var clients = sf.teams; + if (!Array.isArray(clients)) { return; } + // Remove the shared folder from the client's store and + // remove the client/team from our list + var idx; + clients.some(function (obj, i) { + if (obj.store.id === teamId) { + if (obj.store.handleSharedFolder) { + obj.store.handleSharedFolder(obj.id, null); + } + idx = i; + return true; + } + }); + if (typeof (idx) === "undefined") { return; } + // Remove the selected team + clients.splice(idx, 1); + + //If all the teams have closed this shared folder, stop it + if (clients.length) { return; } + if (sf.rt && sf.rt.stop) { + sf.rt.stop(); + } + }; + + // Update the password locally + SF.updatePassword = function (Store, data, network, cb) { + var oldChannel = data.oldChannel; + var href = data.href; + var password = data.password; + var parsed = Hash.parsePadUrl(href); + var secret = Hash.getSecrets(parsed.type, parsed.hash, password); + var sf = allSharedFolders[oldChannel]; + if (!sf) { return void cb({ error: 'ENOTFOUND' }); } + if (sf.rt && sf.rt.stop) { + try { sf.rt.stop(); } catch (e) {} + } + var nt = nThen; + sf.teams.forEach(function (obj) { + nt = nt(function (waitFor) { + var s = obj.store; + var sfId = obj.id; + var shared = Util.find(s.proxy, ['drive', UserObject.SHARED_FOLDERS]) || {}; + if (!sfId || !shared[sfId]) { return; } + var sf = JSON.parse(JSON.stringify(shared[sfId])); + sf.password = password; + SF.load({ + network: network, + store: s, + updatePassword: true, + Store: Store, + isNewChannel: Store.isNewChannel + }, sfId, sf, waitFor()); + if (!s.rpc) { return; } + s.rpc.unpin([oldChannel], waitFor()); + s.rpc.pin([secret.channel], waitFor()); + }).nThen; + }); + nt(function () { + cb(); + }); + }; + + /* loadSharedFolders + load all shared folder stored in a given drive + - store: user or team main store + - userObject: userObject associated to the main drive + - handler: a function (sfid, rt) called for each shared folder loaded + */ + SF.loadSharedFolders = function (Store, network, store, userObject, waitFor, progress, cache) { + var shared = Util.find(store.proxy, ['drive', UserObject.SHARED_FOLDERS]) || {}; + var steps = Object.keys(shared).length; + var i = 1; + var w = waitFor(); + progress = progress || function () {}; + nThen(function (waitFor) { + Object.keys(shared).forEach(function (id) { + var sf = shared[id]; + SF.load({ + network: network, + store: store, + Store: Store, + cache: cache, + isNewChannel: Store.isNewChannel + }, id, sf, waitFor(function () { + progress({ + progress: i, + max: steps + }); + i++; + })); + }); + }).nThen(function () { + setTimeout(w); + }); + }; + + SF.isSharedFolderChannel = function (chanId) { + return Object.keys(allSharedFolders).includes(chanId); + }; + + return SF; +}; + +if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory( + require('../../common/common-hash'), + require('../../common/common-util'), + require('../../common/user-object'), + require('../../common/cache-store'), + require('nthen'), + require('chainpad-crypto'), + require('chainpad-listmap'), + require('chainpad') + ); +} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([ + '/common/common-hash.js', + '/common/common-util.js', + '/common/user-object.js', + '/common/outer/cache-store.js', + + '/components/nthen/index.js', + '/components/chainpad-crypto/crypto.js', + 'chainpad-listmap', + '/components/chainpad/chainpad.dist.js', + ], factory); +} else { + // unsupported initialization +} + +})(); diff --git a/www/common/outer/worker-channel.js b/www/common/outer/worker-channel.js index 584708924..1562871a7 100644 --- a/www/common/outer/worker-channel.js +++ b/www/common/outer/worker-channel.js @@ -3,12 +3,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // This file provides the API for the channel for talking to and from the sandbox iframe. -define([ - //'/common/sframe-protocol.js', - '/common/common-util.js', - '/api/config', -], function (/*SFrameProtocol,*/ Util, ApiConfig) { - +(() => { +const factory = (Util, ApiConfig = {}) => { var mkTxid = function () { return Math.random().toString(16).replace('0.', '') + Math.random().toString(16).replace('0.', ''); }; @@ -161,11 +157,13 @@ define([ }); }; - var trusted = [ - ApiConfig.httpUnsafeOrigin, - ApiConfig.httpSafeOrigin, - '', // sharedworkers - ]; + var trusted = ['']; + if (ApiConfig.httpUnsafeOrigin) { + trusted.push(ApiConfig.httpUnsafeOrigin); + trusted.push(ApiConfig.httpSafeOrigin); + } else if (globalThis.location) { + trusted.push(globalThis.location.origin); + } onMsg.reg(function (msg) { if (!chanLoaded) { return; } @@ -220,4 +218,18 @@ define([ }; return { create: create }; -}); +}; + +if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory( + require('./common-util') + ); +} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([ + '/common/common-util.js', + '/api/config', + ], factory); +} else { + // unsupported initialization +} +})(); diff --git a/www/common/pad-types.js b/www/common/pad-types.js index 78adb56df..8c2f8014b 100644 --- a/www/common/pad-types.js +++ b/www/common/pad-types.js @@ -2,35 +2,67 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -define([ - "/customize/application_config.js", - "/api/config", - "/common/onlyoffice/current-version.js", -], function (AppConfig, ApiConfig, OOCurrentVersion) { +(() => { +const factory = (AppConfig = {}, ApiConfig = {}, + OOCurrentVersion) => { + + let availablePadTypes = []; const OO_APPS = ["sheet", "doc", "presentation"]; - const ooEnabled = ApiConfig.onlyOffice && ApiConfig.onlyOffice.availableVersions.includes( - OOCurrentVersion.currentVersion, - ); - let availablePadTypes = AppConfig.availablePadTypes.filter( - (t) => ooEnabled || !OO_APPS.includes(t) - ); + const setCustomize = data => { + AppConfig = data.AppConfig; + ApiConfig = data.ApiConfig; - let availableTypes; - if (ApiConfig.appsToDisable) { - availableTypes = availablePadTypes.filter(value => !ApiConfig.appsToDisable.includes(value)); - } else { - availableTypes = availablePadTypes; - } - - var appsToSelect = availablePadTypes.filter(value => !['drive', 'teams', 'file', 'contacts', 'convert'].includes(value)); - - return { - availableTypes, - appsToSelect, - - isAvailable: function (type) { - return availableTypes.includes(type); - }, + const ooEnabled = ApiConfig.onlyOffice && + ApiConfig.onlyOffice.availableVersions.includes( + OOCurrentVersion.currentVersion + ); + availablePadTypes = AppConfig.availablePadTypes.filter( + (t) => ooEnabled || !OO_APPS.includes(t) + ); }; -}); + + // Initialize values when using in browser directly + if (Object.keys(AppConfig).length) { + setCustomize({AppConfig,ApiConfig}); + } + + const Types = { setCustomize }; + + Types.__defineGetter__("availableTypes", function () { + if (ApiConfig.appsToDisable) { + return availablePadTypes.filter(value => { + return !ApiConfig.appsToDisable.includes(value); + }); + } + return availablePadTypes; + }); + Types.__defineGetter__("appsToSelect", function () { + return availablePadTypes.filter(value => !['drive', 'teams', 'file', 'contacts', 'convert'].includes(value)); + }); + Types.isAvailable = type => { + return Array.isArray(Types.availableTypes) && + Types.availableTypes.includes(type); + }; + + return Types; +}; + +if (typeof(module) !== 'undefined' && module.exports) { + // Code from customize can't be laoded directly in the build + module.exports = factory( + undefined, + undefined, + require('./onlyoffice/current-version') + ); +} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([ + '/customize/application_config.js', + "/api/config", + "/common/onlyoffice/current-version.js", + ], factory); +} else { + // unsupported initialization +} + +})(); diff --git a/www/common/proxy-manager.js b/www/common/proxy-manager.js index 16fc28300..fee14db0d 100644 --- a/www/common/proxy-manager.js +++ b/www/common/proxy-manager.js @@ -8,7 +8,7 @@ const factory = (UserObject, Util, Hash, let setCustomize = data => { Messages = data.Messages; - UO.setCustomize(data); + UserObject.setCustomize(data); }; var getConfig = function (Env) { @@ -1788,17 +1788,17 @@ const factory = (UserObject, Util, Hash, if (typeof(module) !== 'undefined' && module.exports) { // We don't need Messages in worker or node module.exports = factory( - require('./userObject'), + require('./user-object'), require('./common-util'), require('./common-hash'), - require('../worker/modules/sharedfolder'), + require('../worker/components/sharedfolder'), undefined, require('./common-feedback'), require('nthen') ); } else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { define([ - '/common/userObject.js', + '/common/user-object.js', '/common/common-util.js', '/common/common-hash.js', '/common/outer/sharedfolder.js',