diff --git a/rollup.config.mjs b/rollup.config.mjs index fa3c42e9d..4ddfa30d4 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -9,7 +9,7 @@ import json from '@rollup/plugin-json'; export default { //input: "./_src/worker/index.ts", - input: "./_src/worker/store.ts", + input: "./src/worker/store.ts", output: { name: 'cryptpad-worker', file: "./_build/worker.bundle.js", diff --git a/scripts/tests/roster.js b/scripts/tests/roster.js index b751d5c5e..6e00bcdc9 100644 --- a/scripts/tests/roster.js +++ b/scripts/tests/roster.js @@ -2,6 +2,6 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -module.exports = require("../../www/common/outer/roster.js"); +module.exports = require("../../src/worker/components/roster.js"); diff --git a/src/common/cache-store.js b/src/common/cache-store.js new file mode 100644 index 000000000..38ed12bb3 --- /dev/null +++ b/src/common/cache-store.js @@ -0,0 +1,210 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(() => { +const factory = (Util, localForage) => { + let window = globalThis; + let self = globalThis; + var S = window.CryptPad_Cache = {}; + var onReady = Util.mkEvent(true); + + // Check if indexedDB is allowed + var allowed = false; + var disabled = false; + var supported = false; + + try { + var request = window.indexedDB.open('test_db', 1); + request.onsuccess = function () { + supported = true; + allowed = supported && !disabled; + onReady.fire(); + }; + request.onerror = function () { + onReady.fire(); + }; + } catch (e) { + onReady.fire(); + } + + S.enable = function () { + disabled = false; + allowed = supported && !disabled; + }; + S.disable = function () { + disabled = true; + allowed = supported && !disabled; + }; + + var cache = localForage.createInstance({ + driver: localForage.INDEXEDDB, + name: "cp_cache" + }); + + S.getBlobCache = function (id, cb) { + cb = Util.once(Util.mkAsync(cb || function () {})); + + onReady.reg(function () { + if (!allowed) { return void cb('NOCACHE'); } + cache.getItem(id, function (err, obj) { + if (err || !obj || !obj.c) { + return void cb(Util.serializeError(err || 'EINVAL')); + } + cb(null, obj.c); + obj.t = +new Date(); + cache.setItem(id, obj, function (err) { + if (!err) { return; } + console.error(err); + }); + }); + }); + }; + S.setBlobCache = function (id, u8, cb) { + cb = Util.once(Util.mkAsync(cb || function () {})); + + onReady.reg(function () { + if (!allowed) { return void cb('NOCACHE'); } + if (!u8) { return void cb('EINVAL'); } + cache.setItem(id, { + c: u8, + t: (+new Date()) // 't' represent the "lastAccess" of this cache (get or set) + }, function (err) { + cb(Util.serializeError(err)); + }); + }); + }; + + // id: channel ID or blob ID + // returns array of messages + S.getChannelCache = function (id, cb) { + cb = Util.once(Util.mkAsync(cb || function () {})); + + onReady.reg(function () { + if (!allowed) { return void cb('NOCACHE'); } + cache.getItem(id, function (err, obj) { + if (err || !obj || !Array.isArray(obj.c)) { + return void cb(Util.serializeError(err || 'EINVAL')); + } + cb(null, obj); + obj.t = +new Date(); + cache.setItem(id, obj, function (err) { + if (!err) { return; } + console.error(err); + }); + }); + }); + }; + + // Keep the last two checkpoint + any checkpoint that may exist in the last 100 messages + // FIXME: duplicate system with sliceCpIndex from lib/hk-util.js + var checkCheckpoints = function (array) { + if (!Array.isArray(array)) { return; } + // Keep the last 100 messages + if (array.length > 100) { // FIXME this behaviour is only valid for chainpad-style documents + array.splice(0, array.length - 100); + } + // Remove every message before the first checkpoint + var firstCpIdx; + array.some(function (el, i) { + if (!el.isCheckpoint) { return; } + firstCpIdx = i; + return true; + }); + array.splice(0, firstCpIdx); + }; + + var t = {}; + S.storeCache = function (id, validateKey, val, onError) { + onError = Util.once(Util.mkAsync(onError || function () {})); + + onReady.reg(function () { + + // Make a throttle or use the existing one to avoid calling + // storeCache with the same array multiple times + t[id] = t[id] || Util.throttle(function (validateKey, val, onError) { + if (!allowed) { return void onError('NOCACHE'); } + if (!Array.isArray(val) || !validateKey) { return void onError('EINVAL'); } + checkCheckpoints(val); + cache.setItem(id, { + k: validateKey, + c: val, + t: (+new Date()) // 't' represent the "lastAccess" of this cache (get or set) + }, function (err) { + if (err) { onError(Util.serializeError(err)); } + }); + + }, 50); + t[id](validateKey, val, onError); + + }); + }; + + S.leaveChannel = function (id) { + delete t[id]; + }; + + S.clearChannel = function (id, cb) { + cb = Util.once(Util.mkAsync(cb || function () {})); + + onReady.reg(function () { + if (!allowed) { return void cb('NOCACHE'); } + cache.removeItem(id, function () { + cb(); + }); + }); + }; + + S.clear = function (cb) { + cb = Util.once(Util.mkAsync(cb || function () {})); + + onReady.reg(function () { + if (!allowed) { return void cb('NOCACHE'); } + cache.clear(cb); + }); + }; + + S.getKeys = function (cb) { + cb = Util.once(Util.mkAsync(cb || function () {})); + onReady.reg(function () { + if (!allowed) { return void cb('NOCACHE'); } + cache.keys().then(function (keys) { + cb(null, keys); + }).catch(function (err) { + cb(err); + }); + }); + }; + S.getTime = function (id, cb) { + cb = Util.once(Util.mkAsync(cb || function () {})); + onReady.reg(function () { + if (!allowed) { return void cb('NOCACHE'); } + cache.getItem(id, function (err, obj) { + if (err || !obj || !obj.c) { + return void cb(Util.serializeError(err || 'EINVAL')); + } + cb(null, obj.t); + }); + }); + }; + + 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/src/common/common-constants.js b/src/common/common-constants.js new file mode 100644 index 000000000..7f224a080 --- /dev/null +++ b/src/common/common-constants.js @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(() => { +const factory = function (AppConfig = {}) { + return { + setCustomize: data => { + AppConfig = data.AppConfig; + }, + + // localStorage + userHashKey: 'User_hash', + userNameKey: 'User_name', + blockHashKey: 'Block_hash', + fileHashKey: 'FS_hash', + sessionJWT: 'Session_JWT', + ssoSeed: 'SSO_seed', + + // Store + displayNameKey: 'cryptpad.username', + oldStorageKey: 'CryptPad_RECENTPADS', + storageKey: 'filesData', + tokenKey: 'loginToken', + prefersDriveRedirectKey: 'prefersDriveRedirect', + isPremiumKey: 'isPremiumUser', + displayPadCreationScreen: 'displayPadCreationScreen', + deprecatedKey: 'deprecated', + MAX_TEAMS_SLOTS: AppConfig.maxTeamsSlots || 5, + MAX_TEAMS_OWNED: AppConfig.maxOwnedTeams || 5, + MAX_PREMIUM_TEAMS_SLOTS: Math.max(AppConfig.maxTeamsSlots || 0, AppConfig.maxPremiumTeamsSlots || 0) || 5, + MAX_PREMIUM_TEAMS_OWNED: Math.max(AppConfig.maxTeamsOwned || 0, AppConfig.maxPremiumTeamsOwned || 0) || 5, + // Apps + 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/src/common/common-credential.js b/src/common/common-credential.js new file mode 100644 index 000000000..151a1ced4 --- /dev/null +++ b/src/common/common-credential.js @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(function () { +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 + + Cred.MINIMUM_NAME_LENGTH = 1; + Cred.MAXIMUM_NAME_LENGTH = 64; + + // https://stackoverflow.com/questions/46155/how-to-validate-an-email-address-in-javascript + Cred.isEmail = function (email) { + var re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + return re.test(String(email).toLowerCase()); + }; + + Cred.isLongEnoughPassword = function (passwd) { + return passwd.length >= Cred.MINIMUM_PASSWORD_LENGTH; + }; + + var isString = Cred.isString = function (x) { + return typeof(x) === 'string'; + }; + + // Maximum username length is enforced at registration time + // rather than in this function + // in order to maintain backwards compatibility with accounts + // that might have already registered with a longer name. + Cred.isValidUsername = function (name) { + return !!(isString(name) && name.length >= Cred.MINIMUM_NAME_LENGTH); + }; + + Cred.isValidPassword = function (passwd) { + return !!(passwd && isString(passwd)); + }; + + Cred.passwordsMatch = function (a, b) { + return isString(a) && isString(b) && a === b; + }; + + Cred.customSalt = function () { + return typeof(AppConfig.loginSalt) === 'string'? + AppConfig.loginSalt: ''; + }; + + Cred.deriveFromPassphrase = function (username, password, len, cb) { + Scrypt(password, + username + Cred.customSalt(), // salt + 8, // memoryCost (n) + 1024, // block size parameter (r) + len || 128, // dkLen + 200, // interruptStep + cb, + undefined); // format, could be 'base64' + }; + + Cred.dispenser = function (bytes) { + var entropy = { + used: 0, + }; + + // crypto hygeine + var consume = function (n) { + // explode if you run out of bytes + if (entropy.used + n > bytes.length) { + throw new Error('exceeded available entropy'); + } + if (typeof(n) !== 'number') { throw new Error('expected a number'); } + if (n <= 0) { + throw new Error('expected to consume a positive number of bytes'); + } + + // grab an unused slice of the entropy + // Note: Internet Explorer doesn't support .slice on Uint8Array + var A; + if (bytes.slice) { + A = bytes.slice(entropy.used, entropy.used + n); + } else { + A = bytes.subarray(entropy.used, entropy.used + n); + } + + // account for the bytes you used so you don't reuse bytes + entropy.used += n; + + //console.info("%s bytes of entropy remaining", bytes.length - entropy.used); + return A; + }; + + return consume; + }; + + return Cred; +}; + + if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory( + undefined, //require("../../customize.dist/application_config.js"), + require("scrypt-async") + ); + } else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([ + '/customize/application_config.js', + '/components/scrypt-async/scrypt-async.min.js', + ], function (AppConfig) { + return factory(AppConfig, window.scrypt); + }); + } +}()); diff --git a/src/common/common-feedback.js b/src/common/common-feedback.js new file mode 100644 index 000000000..e4ab25a14 --- /dev/null +++ b/src/common/common-feedback.js @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(() => { +const factory = (AppConfig = {}, Messages= {}) => { + var Feedback = {}; + + Feedback.setCustomize = data => { + Messages = data.Messages; + AppConfig = data.AppConfig; + }; + + Feedback.init = function (state) { + Feedback.state = state; + }; + + var randomToken = function () { + return Math.random().toString(16).replace(/0./, ''); + }; + var ajax = function (url, cb) { + var http = new XMLHttpRequest(); + http.open('HEAD', url); + http.onreadystatechange = function() { + if (this.readyState === this.DONE) { + if (cb) { cb(); } + } + }; + http.send(); + }; + Feedback.send = function (action, force, cb) { + if (typeof(cb) !== 'function') { cb = function () {}; } + if (AppConfig.disableFeedback) { return void cb(); } + if (!action) { return void cb(); } + if (force !== true) { + if (!Feedback.state) { return void cb(); } + } + + var href = '/common/feedback.html?' + action + '=' + randomToken(); + ajax(href, cb); + }; + + Feedback.reportAppUsage = function () { + var pattern = window.location.pathname.split('/') + .filter(function (x) { return x; }).join('.'); + if (/^#\/1\/view\//.test(window.location.hash)) { + Feedback.send(pattern + '_VIEW'); + } else { + Feedback.send(pattern); + } + }; + + Feedback.reportScreenDimensions = function () { + var h = window.innerHeight; + var w = window.innerWidth; + 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/src/common/common-hash.js b/src/common/common-hash.js new file mode 100644 index 000000000..f36faec82 --- /dev/null +++ b/src/common/common-hash.js @@ -0,0 +1,766 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(function (window) { +var factory = function (Util, Crypto, Keys, Nacl) { + var Hash = window.CryptPad_Hash = {}; + + var uint8ArrayToHex = Util.uint8ArrayToHex; + var hexToBase64 = Util.hexToBase64; + var base64ToHex = Util.base64ToHex; + Hash.encodeBase64 = Nacl.util.encodeBase64; + Hash.decodeBase64 = Nacl.util.decodeBase64; + + // This implementation must match that on the server + // it's used for a checksum + Hash.hashChannelList = function (list) { + return Nacl.util.encodeBase64(Nacl.hash(Nacl.util + .decodeUTF8(JSON.stringify(list)))); + }; + + Hash.generateSignPair = function () { + var ed = Nacl.sign.keyPair(); + var makeSafe = function (key) { + return Crypto.b64RemoveSlashes(key).replace(/=+$/g, ''); + }; + return { + validateKey: Hash.encodeBase64(ed.publicKey), + signKey: Hash.encodeBase64(ed.secretKey), + safeValidateKey: makeSafe(Hash.encodeBase64(ed.publicKey)), + safeSignKey: makeSafe(Hash.encodeBase64(ed.secretKey)), + }; + }; + + Hash.getSignPublicFromPrivate = function (edPrivateSafeStr) { + var edPrivateStr = Crypto.b64AddSlashes(edPrivateSafeStr); + var privateKey = Nacl.util.decodeBase64(edPrivateStr); + var keyPair = Nacl.sign.keyPair.fromSecretKey(privateKey); + return Nacl.util.encodeBase64(keyPair.publicKey); + }; + Hash.getCurvePublicFromPrivate = function (curvePrivateSafeStr) { + var curvePrivateStr = Crypto.b64AddSlashes(curvePrivateSafeStr); + var privateKey = Nacl.util.decodeBase64(curvePrivateStr); + var keyPair = Nacl.box.keyPair.fromSecretKey(privateKey); + return Nacl.util.encodeBase64(keyPair.publicKey); + }; + + var getEditHashFromKeys = Hash.getEditHashFromKeys = function (secret) { + var version = secret.version; + var data = secret.keys; + if (version === 0) { + return secret.channel + secret.key; + } + if (version === 1) { + if (!data.editKeyStr) { return; } + return '/1/edit/' + hexToBase64(secret.channel) + + '/' + Crypto.b64RemoveSlashes(data.editKeyStr) + '/'; + } + if (version === 2) { + if (!data.editKeyStr) { return; } + var pass = secret.password ? 'p/' : ''; + return '/2/' + secret.type + '/edit/' + Crypto.b64RemoveSlashes(data.editKeyStr) + '/' + pass; + } + }; + var getViewHashFromKeys = Hash.getViewHashFromKeys = function (secret) { + var version = secret.version; + var data = secret.keys; + if (version === 0) { return; } + if (version === 1) { + if (!data.viewKeyStr) { return; } + return '/1/view/' + hexToBase64(secret.channel) + + '/'+Crypto.b64RemoveSlashes(data.viewKeyStr)+'/'; + } + if (version === 2) { + if (!data.viewKeyStr) { return; } + var pass = secret.password ? 'p/' : ''; + return '/2/' + secret.type + '/view/' + Crypto.b64RemoveSlashes(data.viewKeyStr) + '/' + pass; + } + }; + + Hash.getHiddenHashFromKeys = function (type, secret, opts) { + opts = opts || {}; + var canEdit = (secret.keys && secret.keys.editKeyStr) || secret.key; + var mode = (!opts.view && canEdit) ? 'edit/' : 'view/'; + var pass = secret.password ? 'p/' : ''; + + if (secret.keys && secret.keys.fileKeyStr) { mode = ''; } + + var hash = '/3/' + type + '/' + mode + secret.channel + '/' + pass; + var hashData = Hash.parseTypeHash(type, hash); + if (hashData && hashData.getHash) { + return hashData.getHash(opts || {}); + } + return hash; + }; + + var getFileHashFromKeys = Hash.getFileHashFromKeys = function (secret) { + var version = secret.version; + var data = secret.keys; + if (version === 0) { return; } + if (version === 1) { + return '/1/' + hexToBase64(secret.channel) + '/' + + Crypto.b64RemoveSlashes(data.fileKeyStr) + '/'; + } + if (version === 2) { + if (!data.fileKeyStr) { return; } + var pass = secret.password ? 'p/' : ''; + return '/2/' + secret.type + '/' + Crypto.b64RemoveSlashes(data.fileKeyStr) + '/' + pass; + } + }; + + Hash.getPublicSigningKeyString = Keys.serialize; + + var fixDuplicateSlashes = function (s) { + return s.replace(/\/+/g, '/'); + }; + + Hash.ephemeralChannelLength = 34; + Hash.createChannelId = function (ephemeral) { + var id = uint8ArrayToHex(Crypto.Nacl.randomBytes(ephemeral? 17: 16)); + if ([32, 34].indexOf(id.length) === -1 || /[^a-f0-9]/.test(id)) { + throw new Error('channel ids must consist of 32 hex characters'); + } + return id; + }; + + /* Given a base64-encoded public key, deterministically derive a channel id + Used for support mailboxes + */ + Hash.getChannelIdFromKey = function (publicKey) { + if (!publicKey) { return; } + return uint8ArrayToHex(Hash.decodeBase64(publicKey).subarray(0,16)); + }; + + /* Given a base64-encoded asymmetric private key + derive the corresponding public key + */ + Hash.getBoxPublicFromSecret = function (priv) { + if (!priv) { return; } + var u8_priv = Hash.decodeBase64(priv); + var pair = Nacl.box.keyPair.fromSecretKey(u8_priv); + return Hash.encodeBase64(pair.publicKey); + }; + + /* Given a base64-encoded private key and public key + check that the keys are part of a valid keypair + */ + Hash.checkBoxKeyPair = function (priv, pub) { + if (!pub || !priv) { return false; } + var u8_priv = Hash.decodeBase64(priv); + var pair = Nacl.box.keyPair.fromSecretKey(u8_priv); + return pub === Hash.encodeBase64(pair.publicKey); + }; + + Hash.createRandomHash = function (type, password) { + var cryptor; + if (type === 'file') { + cryptor = Crypto.createFileCryptor2(void 0, password); + return getFileHashFromKeys({ + password: Boolean(password), + version: 2, + type: type, + keys: cryptor + }); + } + cryptor = Crypto.createEditCryptor2(void 0, void 0, password); + return getEditHashFromKeys({ + password: Boolean(password), + version: 2, + type: type, + keys: cryptor + }); + }; + +/* +Version 0 + /pad/#67b8385b07352be53e40746d2be6ccd7XAYSuJYYqa9NfmInyHci7LNy +Version 1: Add support for read-only access + /code/#/1/edit/3Ujt4F2Sjnjbis6CoYWpoQ/usn4+9CqVja8Q7RZOGTfRgqI +Version 2: Add support for password-protection + /code/#/2/code/edit/u5ACvxAYmhvG0FtrNn9FJQcf/p/ +Version 3: Safe links + /code/#/3/code/edit/f0d8055aa640a97e7fd25020ca4e93b3/ +Version 4: Data URL when not a realtime link yet (new pad or "static" app) + /login/#/4/login/newpad=eyJocmVmIjoiaHR0cDovL2xvY2FsaG9zdDozMDAwL2NvZGUvIy8yL2NvZGUvZWRpdC91NUFDdnhBWW1odkcwRnRyTm45RklRY2YvIn0%3D/ + /drive/#/4/drive/login=e30%3D/ +*/ + + var getLoginOpts = function (hashArr) { + var k; + // Check if we have a ownerKey for this pad + hashArr.some(function (data) { + if (/^login=/.test(data)) { + k = data.slice(6); + return true; + } + }); + return k || ''; + }; + var getNewPadOpts = function (hashArr) { + var k; + // Check if we have a ownerKey for this pad + hashArr.some(function (data) { + if (/^newpad=/.test(data)) { + k = data.slice(7); + return true; + } + }); + return k || ''; + }; + var getVersionHash = function (hashArr) { + var k; + // Check if we have a ownerKey for this pad + hashArr.some(function (data) { + if (/^hash=/.test(data)) { + k = data.slice(5); + return true; + } + }); + return k ? Crypto.b64AddSlashes(k) : ''; + }; + var getAuditorKey = function (hashArr) { + var k; + // Check if we have a ownerKey for this pad + hashArr.some(function (data) { + if (/^auditor=/.test(data)) { + k = data.slice(8); + return true; + } + }); + return k ? Crypto.b64AddSlashes(k) : ''; + }; + var getOwnerKey = function (hashArr) { + var k; + // Check if we have a ownerKey for this pad + hashArr.some(function (data) { + if (data.length === 86) { + k = data; + return true; + } + }); + return k; + }; + var parseTypeHash = Hash.parseTypeHash = function (type, hash) { + if (!hash) { return; } + var options = []; + var parsed = {}; + var hashArr = fixDuplicateSlashes(hash).split('/'); + + var addOptions = function () { + parsed.password = options.indexOf('p') !== -1; + parsed.present = options.indexOf('present') !== -1; + parsed.embed = options.indexOf('embed') !== -1; + parsed.versionHash = getVersionHash(options); + parsed.auditorKey = getAuditorKey(options); + parsed.newPadOpts = getNewPadOpts(options); + parsed.loginOpts = getLoginOpts(options); + parsed.ownerKey = getOwnerKey(options); + }; + + // Version 4: only login or newpad options, same for all the apps + if (hashArr[1] && hashArr[1] === '4') { + parsed.getHash = function (opts) { + if (!opts || !Object.keys(opts).length) { return ''; } + var hash = '/4/' + type + '/'; + if (opts.newPadOpts) { hash += 'newpad=' + opts.newPadOpts + '/'; } + if (opts.loginOpts) { hash += 'login=' + opts.loginOpts + '/'; } + return hash; + }; + parsed.getOptions = function () { + var options = {}; + if (parsed.newPadOpts) { options.newPadOpts = parsed.newPadOpts; } + if (parsed.loginOpts) { options.loginOpts = parsed.loginOpts; } + return options; + }; + + parsed.version = 4; + parsed.app = hashArr[2]; + options = hashArr.slice(3); + addOptions(); + + return parsed; + } + + // The other versions depends on the type + if (['media', 'file', 'user', 'invite'].indexOf(type) === -1) { + parsed.type = 'pad'; + parsed.getHash = function () { + return hash; + }; + parsed.getOptions = function () { + return { + embed: parsed.embed, + present: parsed.present, + ownerKey: parsed.ownerKey, + versionHash: parsed.versionHash, + auditorKey: parsed.auditorKey, + newPadOpts: parsed.newPadOpts, + loginOpts: parsed.loginOpts, + password: parsed.password + }; + }; + + if (hash.slice(0,1) !== '/' && hash.length >= 56) { // Version 0 + // Old hash + parsed.channel = hash.slice(0, 32); + parsed.key = hash.slice(32, 56); + parsed.version = 0; + return parsed; + } + + // Version >= 1: more hash options + parsed.getHash = function (opts) { + var hash = hashArr.slice(0, 5).join('/') + '/'; + var owner = typeof(opts.ownerKey) !== "undefined" ? opts.ownerKey : parsed.ownerKey; + if (owner) { hash += owner + '/'; } + if (parsed.password || opts.password) { hash += 'p/'; } + if (opts.embed) { hash += 'embed/'; } + if (opts.present) { hash += 'present/'; } + var versionHash = typeof(opts.versionHash) !== "undefined" ? opts.versionHash : parsed.versionHash; + if (versionHash) { + hash += 'hash=' + Crypto.b64RemoveSlashes(versionHash) + '/'; + } + var auditorKey = typeof(opts.auditorKey) !== "undefined" ? opts.auditorKey : parsed.auditorKey; + if (auditorKey) { + hash += 'auditor=' + Crypto.b64RemoveSlashes(auditorKey) + '/'; + } + if (opts.newPadOpts) { hash += 'newpad=' + opts.newPadOpts + '/'; } + if (opts.loginOpts) { hash += 'login=' + opts.loginOpts + '/'; } + return hash; + }; + + if (hashArr[1] && hashArr[1] === '1') { // Version 1 + parsed.version = 1; + parsed.mode = hashArr[2]; + parsed.channel = hashArr[3]; + parsed.key = Crypto.b64AddSlashes(hashArr[4]); + + options = hashArr.slice(5); + addOptions(); + + return parsed; + } + if (hashArr[1] && hashArr[1] === '2') { // Version 2 + parsed.version = 2; + parsed.app = hashArr[2]; + parsed.mode = hashArr[3]; + parsed.key = hashArr[4]; + + options = hashArr.slice(5); + addOptions(); + + return parsed; + } + if (hashArr[1] && hashArr[1] === '3') { // Version 3: hidden hash + parsed.version = 3; + parsed.app = hashArr[2]; + parsed.mode = hashArr[3]; + parsed.channel = hashArr[4]; + + options = hashArr.slice(5); + addOptions(); + + return parsed; + } + return parsed; + } + parsed.getHash = function () { return hashArr.join('/'); }; + if (['media', 'file'].indexOf(type) !== -1) { + parsed.type = 'file'; + + parsed.getOptions = function () { + return { + embed: parsed.embed, + present: parsed.present, + ownerKey: parsed.ownerKey, + newPadOpts: parsed.newPadOpts, + loginOpts: parsed.loginOpts, + password: parsed.password + }; + }; + + parsed.getHash = function (opts) { + var hash = hashArr.slice(0, 4).join('/') + '/'; + var owner = typeof(opts.ownerKey) !== "undefined" ? opts.ownerKey : parsed.ownerKey; + if (owner) { hash += owner + '/'; } + if (parsed.password || opts.password) { hash += 'p/'; } + if (opts.embed) { hash += 'embed/'; } + if (opts.present) { hash += 'present/'; } + if (opts.newPadOpts) { hash += 'newpad=' + opts.newPadOpts + '/'; } + if (opts.loginOpts) { hash += 'login=' + opts.loginOpts + '/'; } + return hash; + }; + + if (hashArr[1] && hashArr[1] === '1') { + parsed.version = 1; + parsed.channel = hashArr[2].replace(/-/g, '/'); + parsed.key = hashArr[3].replace(/-/g, '/'); + options = hashArr.slice(4); + addOptions(); + return parsed; + } + + if (hashArr[1] && hashArr[1] === '2') { // Version 2 + parsed.version = 2; + parsed.app = hashArr[2]; + parsed.key = hashArr[3]; + + options = hashArr.slice(4); + addOptions(); + + return parsed; + } + + if (hashArr[1] && hashArr[1] === '3') { // Version 3: hidden hash + parsed.version = 3; + parsed.app = hashArr[2]; + parsed.channel = hashArr[3]; + + options = hashArr.slice(4); + addOptions(); + + return parsed; + } + return parsed; + } + if (['user'].indexOf(type) !== -1) { + parsed.type = 'user'; + if (hashArr[1] && hashArr[1] === '1') { + parsed.version = 1; + parsed.user = hashArr[2]; + parsed.pubkey = hashArr[3].replace(/-/g, '/'); + return parsed; + } + return parsed; + } + if (['invite'].indexOf(type) !== -1) { + parsed.type = 'invite'; + if (hashArr[1] && hashArr[1] === '2') { + parsed.version = 2; + parsed.app = hashArr[2]; + parsed.mode = hashArr[3]; + parsed.key = hashArr[4]; + + options = hashArr.slice(5); + parsed.password = options.indexOf('p') !== -1; + return parsed; + } + return parsed; + } + return; + }; + var parsePadUrl = Hash.parsePadUrl = function (href) { + var patt = /^https*:\/\/([^\/]*)\/(.*?)\//i; + + var ret = {}; + + if (!href) { return ret; } + if (href.slice(-1) !== '/' && href.slice(-1) !== '#') { href += '/'; } + href = href.replace(/\/\?[^#]+#/, '/#'); + + var idx; + + // When we start without a hash, use version 4 links to add login or newpad options + var getHash = function (opts) { + if (!opts || !Object.keys(opts).length) { return ''; } + var hash = '/4/' + ret.type + '/'; + if (opts.newPadOpts) { hash += 'newpad=' + opts.newPadOpts + '/'; } + if (opts.loginOpts) { hash += 'login=' + opts.loginOpts + '/'; } + return hash; + }; + ret.getUrl = function (options) { + options = options || {}; + var url = '/'; + if (!ret.type) { return url; } + url += ret.type + '/'; + // New pad with options: append the options to the hash + if (!ret.hashData && options && Object.keys(options).length) { + return url + '#' + getHash(options); + } + if (!ret.hashData) { return url; } + //if (ret.hashData.version === 0) { return url + '#' + ret.hash; } + //if (ret.hashData.type !== 'pad') { return url + '#' + ret.hash; } + var hash = ret.hashData.getHash(options); + url += '#' + hash; + return url; + }; + ret.getOptions = function () { + if (!ret.hashData || !ret.hashData.getOptions) { return {}; } + return ret.hashData.getOptions(); + }; + + if (!/^https*:\/\//.test(href)) { + // If it doesn't start with http(s), it should be a relative href + if (!/^\/($|[^\/])/.test(href)) { return ret; } + idx = href.indexOf('/#'); + ret.type = href.slice(1, idx); + if (idx === -1) { return ret; } + ret.hash = href.slice(idx + 2); + ret.hashData = parseTypeHash(ret.type, ret.hash); + return ret; + } + + href.replace(patt, function (a, domain, type) { + ret.domain = domain; + ret.type = type; + return ''; + }); + idx = href.indexOf('/#'); + if (idx === -1) { return ret; } + ret.hash = href.slice(idx + 2); + ret.hashData = parseTypeHash(ret.type, ret.hash); + return ret; + }; + + Hash.hashToHref = function (hash, type) { + return '/' + type + '/#' + hash; + }; + Hash.hrefToHash = function (href) { + var parsed = Hash.parsePadUrl(href); + return parsed.hash; + }; + + Hash.getRelativeHref = function (href) { + if (!href) { return; } + if (href.indexOf('#') === -1) { return; } + var parsed = parsePadUrl(href); + return '/' + parsed.type + '/#' + parsed.hash; + }; + + /* + * Returns all needed keys for a realtime channel + * - no argument: use the URL hash or create one if it doesn't exist + * - secretHash provided: use secretHash to find the keys + */ + Hash.getSecrets = function (type, secretHash, password) { + var secret = {}; + var generate = function () { + secret.keys = Crypto.createEditCryptor2(void 0, void 0, password); + secret.channel = base64ToHex(secret.keys.chanId); + secret.version = 2; + secret.type = type; + }; + if (!secretHash) { + generate(); + return secret; + } else { + var parsed; + var hash; + if (secretHash) { + if (!type) { throw new Error("getSecrets with a hash requires a type parameter"); } + parsed = parseTypeHash(type, secretHash); + hash = secretHash; + } + if (hash.length === 0) { + generate(); + return secret; + } + // old hash system : #{hexChanKey}{cryptKey} + // new hash system : #/{hashVersion}/{b64ChanKey}/{cryptKey} + if (parsed.version === 0) { + // Old hash + secret.channel = parsed.channel; + secret.key = parsed.key; + secret.version = 0; + } else if (parsed.version === 1) { + // New hash + secret.version = 1; + if (parsed.type === "pad") { + secret.channel = base64ToHex(parsed.channel); + if (parsed.mode === 'edit') { + secret.keys = Crypto.createEditCryptor(parsed.key); + secret.key = secret.keys.editKeyStr; + if (secret.channel.length !== 32 || secret.key.length !== 24) { + throw new Error("The channel key and/or the encryption key is invalid"); + } + } + else if (parsed.mode === 'view') { + secret.keys = Crypto.createViewCryptor(parsed.key); + if (secret.channel.length !== 32) { + throw new Error("The channel key is invalid"); + } + } + } else if (parsed.type === "file") { + secret.channel = base64ToHex(parsed.channel); + secret.keys = { + fileKeyStr: parsed.key, + cryptKey: Nacl.util.decodeBase64(parsed.key) + }; + } else if (parsed.type === "user") { + throw new Error("User hashes can't be opened (yet)"); + } + } else if (parsed.version === 2) { + // New hash + secret.version = 2; + secret.type = type; + secret.password = password; + if (parsed.type === "pad") { + if (parsed.mode === 'edit') { + secret.keys = Crypto.createEditCryptor2(parsed.key, void 0, password); + secret.channel = base64ToHex(secret.keys.chanId); + secret.key = secret.keys.editKeyStr; + if (secret.channel.length !== 32 || secret.key.length !== 24) { + throw new Error("The channel key and/or the encryption key is invalid"); + } + } + else if (parsed.mode === 'view') { + secret.keys = Crypto.createViewCryptor2(parsed.key, password); + secret.channel = base64ToHex(secret.keys.chanId); + if (secret.channel.length !== 32) { + throw new Error("The channel key is invalid"); + } + } + } else if (parsed.type === "file") { + secret.keys = Crypto.createFileCryptor2(parsed.key, password); + secret.channel = base64ToHex(secret.keys.chanId); + secret.key = secret.keys.fileKeyStr; + if (secret.channel.length !== 48 || secret.key.length !== 24) { + throw new Error("The channel key and/or the encryption key is invalid"); + } + } else if (parsed.type === "user") { + throw new Error("User hashes can't be opened (yet)"); + } + } + } + return secret; + }; + + Hash.getHashes = function (secret) { + var hashes = {}; + secret = JSON.parse(JSON.stringify(secret)); + + if (!secret.keys && !secret.key) { + return hashes; + } else if (!secret.keys) { + secret.keys = {}; + } + + if (secret.keys.editKeyStr || (secret.version === 0 && secret.key)) { + hashes.editHash = getEditHashFromKeys(secret); + } + if (secret.keys.viewKeyStr) { + hashes.viewHash = getViewHashFromKeys(secret); + } + if (secret.keys.fileKeyStr) { + hashes.fileHash = getFileHashFromKeys(secret); + } + return hashes; + }; + + Hash.getFormData = function (secret, hash, password) { + secret = secret || Hash.getSecrets('form', hash, password); + var keys = secret && secret.keys; + var secondary = keys && keys.secondaryKey; + if (!secondary) { return; } + var curvePair = Nacl.box.keyPair.fromSecretKey(Nacl.util.decodeUTF8(secondary).slice(0,32)); + var ret = {}; + ret.form_public = Nacl.util.encodeBase64(curvePair.publicKey); + var privateKey = ret.form_private = Nacl.util.encodeBase64(curvePair.secretKey); + + var auditorHash = Hash.getViewHashFromKeys({ + version: 1, + channel: secret.channel, + keys: { viewKeyStr: Nacl.util.encodeBase64(keys.cryptKey) } + }); + var _parsed = Hash.parseTypeHash('pad', auditorHash); + ret.form_auditorHash = _parsed.getHash({auditorKey: privateKey}); + + return ret; + }; + + // STORAGE + Hash.hrefToHexChannelId = function (href, password) { + var parsed = Hash.parsePadUrl(href); + if (!parsed || !parsed.hash) { return; } + var secret = Hash.getSecrets(parsed.type, parsed.hash, password); + return secret.channel; + }; + + Hash.getBlobPathFromHex = function (id) { + return '/blob/' + id.slice(0,2) + '/' + id; + }; + + Hash.serializeHash = function (hash) { + if (hash && hash.slice(-1) !== "/") { hash += "/"; } + return hash; + }; + + Hash.createInviteUrl = function (curvePublic, channel) { + channel = channel || Hash.createChannelId(); + return window.location.origin + '/invite/#/1/' + channel + + '/' + curvePublic.replace(/\//g, '-') + '/'; + }; + + Hash.isValidChannel = function (channelId) { + return /^[a-zA-Z0-9]{32,48}$/.test(channelId); + }; + + Hash.isValidHref = function (href) { + // Non-empty href? + if (!href) { return; } + var parsed = Hash.parsePadUrl(href); + // Can be parsed? + if (!parsed) { return; } + // Link to a CryptPad app? + if (!parsed.type) { return; } + // Valid hash? + if (parsed.hash) { + if (!parsed.hashData) { return; } + // Version should be a number + if (typeof(parsed.hashData.version) === "undefined") { return; } + // pads and files should have a base64 (or hex) key + if (parsed.hashData.type === 'pad' || parsed.hashData.type === 'file') { + if (!parsed.hashData.key && !parsed.hashData.channel) { return; } + if (parsed.hashData.key && !/^[a-zA-Z0-9+-/=]+$/.test(parsed.hashData.key)) { return; } + } + } + return parsed; + }; + + Hash.decodeDataOptions = function (opts) { + var b64 = decodeURIComponent(opts); + var str = Nacl.util.encodeUTF8(Nacl.util.decodeBase64(b64)); + return Util.tryParse(str) || {}; + }; + Hash.encodeDataOptions = function (opts) { + var str = JSON.stringify(opts); + var b64 = Nacl.util.encodeBase64(Nacl.util.decodeUTF8(str)); + return encodeURIComponent(b64); + }; + Hash.getNewPadURL = function (href, opts) { + var parsed = Hash.parsePadUrl(href); + var options = parsed.getOptions(); + options.newPadOpts = Hash.encodeDataOptions(opts); + return parsed.getUrl(options); + }; + Hash.getLoginURL = function (href, opts) { + var parsed = Hash.parsePadUrl(href); + var options = parsed.getOptions(); + options.loginOpts = Hash.encodeDataOptions(opts); + return parsed.getUrl(options); + }; + + return Hash; +}; + + if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory( + require("./common-util"), + require("chainpad-crypto"), + require("./common-signing-keys"), + require("tweetnacl/nacl-fast") + ); + } else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([ + '/common/common-util.js', + '/components/chainpad-crypto/crypto.js', + '/common/common-signing-keys.js', + '/components/tweetnacl/nacl-fast.min.js' + ], function (Util, Crypto, Keys) { + return factory(Util, Crypto, Keys, window.nacl); + }); + } else { + // unsupported initialization + } +}(typeof(window) !== 'undefined'? window : {})); diff --git a/src/common/common-messaging.js b/src/common/common-messaging.js new file mode 100644 index 000000000..f9b7fde24 --- /dev/null +++ b/src/common/common-messaging.js @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(() => { +const factory = (Crypto, Hash, Util, Constants, Realtime) => { + var Msg = {}; + + var createData = Msg.createData = function (proxy, hash) { + var data = { + channel: hash || Hash.createChannelId(), + displayName: proxy['cryptpad.username'], + profile: proxy.profile && proxy.profile.view, + edPublic: proxy.edPublic, + curvePublic: proxy.curvePublic, + notifications: Util.find(proxy, ['mailboxes', 'notifications', 'channel']), + avatar: proxy.profile && proxy.profile.avatar, + uid: proxy.uid, + }; + if (hash === false) { delete data.channel; } + return data; + }; + + var getFriend = Msg.getFriend = function (proxy, pubkey) { + if (!pubkey) { return; } + if (pubkey === proxy.curvePublic) { + var data = createData(proxy); + delete data.channel; + return data; + } + return proxy.friends ? proxy.friends[pubkey] : undefined; + }; + + var getFriendList = Msg.getFriendList = function (proxy) { + if (!proxy.friends) { proxy.friends = {}; } + return proxy.friends; + }; + + var eachFriend = function (friends, cb) { + Object.keys(friends).forEach(function (id) { + if (id === 'me') { return; } + cb(friends[id], id, friends); + }); + }; + + Msg.getFriendChannelsList = function (proxy) { + var list = []; + eachFriend(proxy.friends, function (friend) { + list.push(friend.channel); + }); + return list; + }; + + Msg.declineFriendRequest = function (store, data, cb) { + store.mailbox.sendTo('DECLINE_FRIEND_REQUEST', {}, { + channel: data.notifications, + curvePublic: data.curvePublic + }, function (obj) { + cb(obj); + }); + }; + Msg.acceptFriendRequest = function (store, data, cb) { + var friend = getFriend(store.proxy, data.curvePublic) || {}; + var myData = createData(store.proxy, friend.channel || data.channel); + store.mailbox.sendTo('ACCEPT_FRIEND_REQUEST', { user: myData }, { + channel: data.notifications, + curvePublic: data.curvePublic + }, function (obj) { + cb(obj); + }); + }; + Msg.addToFriendList = function (cfg, data, cb) { + var proxy = cfg.proxy; + var friends = getFriendList(proxy); + var pubKey = data.curvePublic; // todo validata data + + if (pubKey === proxy.curvePublic) { return void cb("E_MYKEY"); } + + friends[pubKey] = data; + + Realtime.whenRealtimeSyncs(cfg.realtime, function () { + cb(); + cfg.pinPads([data.channel], function (res) { + if (res.error) { console.error(res.error); } + }); + }); + }; + + Msg.updateMyData = function (store, curve) { + var myData = createData(store.proxy, false); + if (store.proxy.friends) { + store.proxy.friends.me = Util.clone(myData); + delete store.proxy.friends.me.channel; + } + if (store.modules['team']) { + store.modules['team'].updateMyData(myData); + } + var todo = function (friend) { + if (!friend || !friend.notifications) { return; } + delete friend.user; + myData.channel = friend.channel; + store.mailbox.sendTo('UPDATE_DATA', myData, { + channel: friend.notifications, + curvePublic: friend.curvePublic + }, function (obj) { + if (obj && obj.error) { console.error(obj); } + }); + }; + if (curve) { + var friend = getFriend(store.proxy, curve); + return void todo(friend); + } + eachFriend(store.proxy.friends || {}, todo); + }; + + Msg.removeFriend = function (store, curvePublic, cb) { + var proxy = store.proxy; + var friend = proxy.friends[curvePublic]; + if (!friend) { return void cb({error: 'ENOENT'}); } + if (!friend.notifications) { return void cb({error: 'EINVAL'}); } + + store.mailbox.sendTo('UNFRIEND', { + curvePublic: proxy.curvePublic + }, { + channel: friend.notifications, + curvePublic: friend.curvePublic + }, function (obj) { + if (obj && obj.error) { + return void cb(obj); + } + store.messenger.onFriendRemoved(curvePublic, friend.channel); + delete proxy.friends[curvePublic]; + Realtime.whenRealtimeSyncs(store.realtime, function () { + cb(obj); + }); + }); + }; + + 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/src/common/common-realtime.js b/src/common/common-realtime.js new file mode 100644 index 000000000..cdf942609 --- /dev/null +++ b/src/common/common-realtime.js @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(() => { +const factory = () => { + var common = {}; + + /* + TODO make this not blow up when disconnected or lagging... + */ + common.whenRealtimeSyncs = function (realtime, cb) { + if (typeof(realtime.getAuthDoc) !== 'function') { + return void console.error('improper use of this function'); + } + setTimeout(function () { + if (realtime.getAuthDoc() === realtime.getUserDoc()) { + return void cb(); + } else { + realtime.onSettle(cb); + } + }, 0); + }; + + 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/src/common/common-signing-keys.js b/src/common/common-signing-keys.js new file mode 100644 index 000000000..1d1132c34 --- /dev/null +++ b/src/common/common-signing-keys.js @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(function () { +var factory = function () { + var Keys = {}; + + var unescape = function (s) { + return s.replace(/-/g, '/'); + }; + +/* Parse the new format of "Signing Public Keys". + If anything about the input is found to be invalid, return; + this will fall back to the old parsing method + + +*/ + var parseNewUser = function (userString) { + if (!/^\[.*?@.*\]$/.test(userString)) { return; } + var temp = userString.slice(1, -1); + var domain, username, pubkey; + + temp = temp + .replace(/\/([a-zA-Z0-9+-]{43}=)$/, function (all, k) { + pubkey = unescape(k); + return ''; + }); + if (!pubkey) { return; } + + var index = temp.lastIndexOf('@'); + if (index < 1) { return; } + + domain = temp.slice(index + 1); + username = temp.slice(0, index); + + return { + domain: domain, + user: username, + pubkey: pubkey + }; + }; + + var isValidUser = function (parsed) { + if (!parsed) { return; } + if (!(parsed.domain && parsed.user && parsed.pubkey)) { return; } + return true; + }; + + Keys.parseUser = function (user) { + var parsed = parseNewUser(user); + if (isValidUser(parsed)) { return parsed; } + + var domain, username, pubkey; + user.replace(/^https*:\/\/([^\/]+)\/user\/#\/1\/([^\/]+)\/([a-zA-Z0-9+-]{43}=)$/, + function (a, d, u, k) { + domain = d; + username = u; + pubkey = unescape(k); + return ''; + }); + if (!domain) { throw new Error("Could not parse user id [" + user + "]"); } + return { + domain: domain, + user: username, + pubkey: pubkey + }; + }; + +/* + +0. usernames may contain spaces or many other wacky characters, so enclose the whole thing in square braces so we know its boundaries. If the formatted string does not include these we know it is either a _v1 public key string_ or _an incomplete string_. Start parsing by removing them. +1. public keys should have a fixed length, so slice them off of the end of the string. +2. domains cannot include `@`, so find the last occurence of it in the signing key and slice everything thereafter. +3. the username is everything before the `@`. + +*/ + Keys.serialize = function (origin, username, pubkey) { + return '[' + + username + + '@' + + origin.replace(/https*:\/\//, '') + + '/' + + pubkey.replace(/\//g, '-') + + ']'; + // return origin + '/user/#/1/' + username + '/' + pubkey.replace(/\//g, '-'); + }; + + Keys.canonicalize = function (input) { + if (typeof(input) !== 'string') { return; } + // key is already in simple form. ensure that it is an 'unsafeKey' + if (input.length === 44) { + return unescape(input); + } + try { + return Keys.parseUser(input).pubkey; + } catch (err) { + return; + } + }; + + return Keys; +}; + + if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory(); + } else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([], factory); + } +}()); diff --git a/src/common/common-util.js b/src/common/common-util.js new file mode 100644 index 000000000..5faa9171e --- /dev/null +++ b/src/common/common-util.js @@ -0,0 +1,775 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(function (window) { + var Util = {}; + + // polyfill for atob in case you're using this from node... + window.atob = window.atob || function (str) { return Buffer.from(str, 'base64').toString('binary'); }; + window.btoa = window.btoa || function (str) { return Buffer.from(str, 'binary').toString('base64'); }; + + Util.slice = function (A, start, end) { + return Array.prototype.slice.call(A, start, end); + }; + + Util.shuffleArray = function (a) { + for (var i = a.length - 1; i > 0; i--) { + var j = Math.floor(Math.random() * (i + 1)); + var tmp = a[i]; + a[i] = a[j]; + a[j] = tmp; + } + }; + + Util.bake = function (f, args) { + if (typeof(args) === 'undefined') { args = []; } + if (!Array.isArray(args)) { args = [args]; } + return function () { + return f.apply(null, args); + }; + }; + + Util.both = function (pre, post) { + if (typeof(pre) !== 'function') { throw new Error('INVALID_USAGE'); } + if (typeof(post) !== 'function') { post = function (x) { return x; }; } + return function () { + pre.apply(null, arguments); + return post.apply(null, arguments); + }; + }; + + Util.clone = function (o) { + if (o === undefined || o === null) { return o; } + return JSON.parse(JSON.stringify(o)); + }; + + Util.serializeError = function (err) { + if (!(err instanceof Error)) { return err; } + var ser = {}; + Object.getOwnPropertyNames(err).forEach(function (key) { + ser[key] = err[key]; + }); + return ser; + }; + + Util.tryParse = function (s) { + try { return JSON.parse(s); } catch (e) { return;} + }; + + Util.mkAsync = function (f, ms) { + if (typeof(f) !== 'function') { + throw new Error('EXPECTED_FUNCTION'); + } + return function () { + var args = Array.prototype.slice.call(arguments); + setTimeout(function () { + f.apply(null, args); + }, ms); + }; + }; + + // If once is true, after the event has been fired, any further handlers which are + // registered will fire immediately, and this type of event cannot be fired twice. + Util.mkEvent = function (once) { + var handlers = []; + var fired = false; + return { + reg: function (cb) { + if (once && fired) { return void setTimeout(cb); } + handlers.push(cb); + }, + unreg: function (cb) { + if (handlers.indexOf(cb) === -1) { + return void console.log("event handler was already unregistered"); + } + handlers.splice(handlers.indexOf(cb), 1); + }, + fire: function () { + if (once && fired) { return; } + fired = true; + var args = Array.prototype.slice.call(arguments); + handlers.forEach(function (h) { h.apply(null, args); }); + } + }; + }; + + Util.mkTimeout = function (_f, ms) { + ms = ms || 0; + var f = Util.once(_f); + + var timeout = setTimeout(function () { + f('TIMEOUT'); + }, ms); + + return Util.both(f, function () { + clearTimeout(timeout); + }); + }; + + Util.onClickEnter = function ($element, handler, cfg) { + $element.on('click keydown', function (e) { + var isClick = e.type === 'click'; + var isEnter = e.type === 'keydown' && e.which === 13; + var isSpace = e.type === 'keydown' && e.which === 32 && cfg && cfg.space; + if (!isClick && !isEnter && !isSpace) { return; } + + // "enter" on a button triggers a click, disable it + if (e.type === 'keydown') { e.preventDefault(); } + + handler(e); + }); + }; + + Util.response = function (errorHandler) { + var pending = {}; + var timeouts = {}; + + if (typeof(errorHandler) !== 'function') { + errorHandler = function (label) { + throw new Error(label); + }; + } + + var clear = function (id) { + clearTimeout(timeouts[id]); + delete timeouts[id]; + delete pending[id]; + }; + + var expect = function (id, fn, ms) { + if (typeof(id) !== 'string') { errorHandler('EXPECTED_STRING'); } + if (typeof(fn) !== 'function') { errorHandler('EXPECTED_CALLBACK'); } + pending[id] = fn; + if (typeof(ms) === 'number' && ms) { + timeouts[id] = setTimeout(function () { + if (typeof(pending[id]) === 'function') { pending[id]('TIMEOUT'); } + clear(id); + }, ms); + } + }; + + var handle = function (id, args) { + var fn = pending[id]; + if (typeof(fn) !== 'function') { + return void errorHandler("MISSING_CALLBACK", { + id: id, + args: args, + }); + } + try { + fn.apply(null, Array.isArray(args)? args : [args]); + } catch (err) { + errorHandler('HANDLER_ERROR', { + error: err, + id: id, + args: args, + }); + } + clear(id); + }; + + return { + clear: clear, + expected: function (id) { + return Boolean(pending[id]); + }, + expectation: function (id) { + return pending[id]; + }, + expect: expect, + handle: handle, + _pending: pending, + }; + }; + + Util.inc = function (map, key, val) { + map[key] = (map[key] || 0) + (typeof(val) === 'number'? val: 1); + }; + + Util.values = function (obj) { + return Object.keys(obj).map(function (k) { + return obj[k]; + }); + }; + + Util.find = function (map, path) { + var l = path.length; + for (var i = 0; i < l; i++) { + if (typeof(map[path[i]]) === 'undefined') { return; } + map = map[path[i]]; + } + return map; + }; + + Util.uid = function () { + return Number(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)) + .toString(32).replace(/\./g, ''); + }; + + Util.guid = function (map) { + var id = Util.uid(); + // the guid (globally unique id) is valid if it does already exist in the map + if (typeof(map[id]) === 'undefined') { return id; } + // otherwise try again + return Util.guid(map); + }; + + Util.fixHTML = function (str) { + if (!str) { return ''; } + return str.replace(/[<>&"']/g, function (x) { + return ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[x]; + }); + }; + + Util.hexToBase64 = function (hex) { + var hexArray = hex + .replace(/\r|\n/g, "") + .replace(/([\da-fA-F]{2}) ?/g, "0x$1 ") + .replace(/ +$/, "") + .split(" "); + var byteString = String.fromCharCode.apply(null, hexArray); + return window.btoa(byteString).replace(/\//g, '-').replace(/=+$/, ''); + }; + + Util.base64ToHex = function (b64String) { + var hexArray = []; + window.atob(b64String.replace(/-/g, '/')).split("").forEach(function(e){ + var h = e.charCodeAt(0).toString(16); + if (h.length === 1) { h = "0"+h; } + hexArray.push(h); + }); + return hexArray.join(""); + }; + + Util.uint8ArrayToHex = function (bytes) { + var hexString = ''; + for (var i = 0; i < bytes.length; i++) { + if (bytes[i] < 16) { hexString += '0'; } + hexString += bytes[i].toString(16); + } + return hexString; + }; + + Util.hexToUint8Array = function (hexString) { + var bytes = new Uint8Array(Math.ceil(hexString.length / 2)); + for (var i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hexString.substr(i * 2, 2), 16); + } + return bytes; + }; + + // given an array of Uint8Arrays, return a new Array with all their values + Util.uint8ArrayJoin = function (AA) { + var l = 0; + var i = 0; + for (; i < AA.length; i++) { l += AA[i].length; } + var C = new Uint8Array(l); + + i = 0; + for (var offset = 0; i < AA.length; i++) { + C.set(AA[i], offset); + offset += AA[i].length; + } + return C; + }; + + Util.escapeKeyCharacters = function (key) { + return key && key.replace && key.replace(/\//g, '-'); + }; + + Util.unescapeKeyCharacters = function (key) { + return key.replace(/\-/g, '/'); + }; + + Util.deduplicateString = function (array) { + var a = array.slice(); + for(var i=0; i= oneGigabyte) { return 'GB'; } + else if (bytes >= oneMegabyte) { return 'MB'; } + else { return 'KB'; } + }; + + // given a path, asynchronously return an arraybuffer + var getCacheKey = function (src) { + var _src = src.replace(/(\/)*$/, ''); // Remove trailing slashes + var idx = _src.lastIndexOf('/'); + var cacheKey = _src.slice(idx+1); + if (!/^[a-f0-9]{48}$/.test(cacheKey)) { cacheKey = undefined; } + return cacheKey; + }; + + + Util.getBlock = function (src, opt, cb) { + var CB = Util.once(Util.mkAsync(cb)); + + var headers = {}; + + if (typeof(opt.bearer) === 'string' && opt.bearer) { + headers.authorization = `Bearer ${opt.bearer}`; + } + + + fetch(src, { + method: 'GET', + credentials: 'include', + headers: headers, + }).then(response => { + if (response.ok) { + // TODO this should probably be returned as an arraybuffer or something rather than a promise + // this is resulting in some code duplication + return void CB(void 0, response); + } + if (response.status === 401 || response.status === 404) { + response.json().then((data) => { + CB(response.status, data); + }).catch(() => { + CB(response.status); + }); + + return; + } + CB(response.status, response); + }).catch(error => { + CB(error); + }); + }; + + + Util.fetch = function (src, cb, progress, cache) { + var CB = Util.once(Util.mkAsync(cb)); + + var cacheKey = getCacheKey(src); + var getBlobCache = function (id, cb) { + if (!cache || typeof(cache.getBlobCache) !== "function") { return void cb('EINVAL'); } + cache.getBlobCache(id, cb); + }; + var setBlobCache = function (id, u8, cb) { + if (!cache || typeof(cache.setBlobCache) !== "function") { return void cb('EINVAL'); } + cache.setBlobCache(id, u8, cb); + }; + + var xhr; + + var fetch = function () { + xhr = new XMLHttpRequest(); + xhr.open("GET", src, true); + if (progress) { + xhr.addEventListener("progress", function (evt) { + if (evt.lengthComputable) { + var percentComplete = evt.loaded / evt.total; + progress(percentComplete); + } + }, false); + } + xhr.responseType = "arraybuffer"; + xhr.onerror = function (err) { CB(err); }; + xhr.onload = function () { + if (/^4/.test(''+this.status)) { + return CB('XHR_ERROR'); + } + + var arrayBuffer = xhr.response; + if (arrayBuffer) { + var u8 = new Uint8Array(arrayBuffer); + if (cacheKey) { + return void setBlobCache(cacheKey, u8, function () { + CB(null, u8); + }); + } + return void CB(void 0, u8); + } + CB('ENOENT'); + }; + xhr.send(null); + }; + + if (!cacheKey) { return void fetch(); } + + getBlobCache(cacheKey, function (err, u8) { + if (err || !u8) { return void fetch(); } + CB(void 0, u8); + }); + + return { + cancel: function () { + if (xhr && xhr.abort) { xhr.abort(); } + } + }; + }; + + Util.dataURIToBlob = function (dataURI) { + var byteString = atob(dataURI.split(',')[1]); + var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]; + + // write the bytes of the string to an ArrayBuffer + var ab = new ArrayBuffer(byteString.length); + var ia = new Uint8Array(ab); + for (var i = 0; i < byteString.length; i++) { + ia[i] = byteString.charCodeAt(i); + } + + // write the ArrayBuffer to a blob, and you're done + var bb = new Blob([ab], {type: mimeString}); + return bb; + }; + + Util.throttle = function (f, ms) { + var last = 0; + var to; + var args; + + var defer = function (delay) { + // no timeout: run function `f` in `ms` milliseconds + // unless `g` is called again in the meantime + to = setTimeout(function () { + // wipe the current timeout handler + to = undefined; + + // take the current time + var now = +new Date(); + // compute time passed since `last` + var diff = now - last; + if (diff < ms) { + // don't run `f` if `g` was called since this timeout was set + // instead calculate how much further in the future your next + // timeout should be scheduled + return void defer(ms - diff); + } + + // else run `f` with the most recently supplied arguments + f.apply(null, args); + }, delay); + }; + + var g = function () { + // every time you call this function store the time + last = +new Date(); + // remember what arguments were passed + args = Util.slice(arguments); + // if there is a pending timeout then do nothing + if (to) { return; } + defer(ms); + }; + + g.clear = function () { + clearTimeout(to); + to = undefined; + }; + return g; + }; + + /* takes a function (f) and a time (t) in ms. returns a function wrapper + which prevents the internal function from being called more than once + every t ms. if the function is prevented, returns time til next valid + execution, else null. + */ + Util.notAgainForAnother = function (f, t) { + if (typeof(f) !== 'function' || typeof(t) !== 'number') { + throw new Error("invalid inputs"); + } + var last = null; + return function () { + var now = +new Date(); + if (last && now <= last + t) { return t - (now - last); } + last = now; + f.apply(null, Util.slice(arguments)); + return null; + }; + }; + + Util.createRandomInteger = function () { + return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); + }; + + Util.noop = function () {}; + + /* for wrapping async functions such that they can only be called once */ + Util.once = function (f, g) { + return function () { + if (!f) { return; } + f.apply(this, Array.prototype.slice.call(arguments)); + f = g; + }; + }; + + Util.blobToImage = function (blob, cb) { + var reader = new FileReader(); + reader.onloadend = function() { + cb(reader.result); + }; + reader.readAsDataURL(blob); + }; + Util.blobURLToImage = function (url, cb) { + var xhr = new XMLHttpRequest(); + xhr.onload = function() { + var reader = new FileReader(); + reader.onloadend = function() { + cb(reader.result); + }; + reader.readAsDataURL(xhr.response); + }; + xhr.open('GET', url); + xhr.responseType = 'blob'; + xhr.send(); + }; + + // Check if an element is a plain object + Util.isObject = function (o) { + return typeof (o) === "object" && + Object.prototype.toString.call(o) === '[object Object]'; + }; + + Util.isCircular = function (o) { + try { + JSON.stringify(o); + return false; + } catch (e) { return true; } + }; + + /* recursively adds the properties of an object 'b' to 'a' + arrays are only shallow copies, so references to the original + might still be present. Be mindful if you will modify 'a' in the future */ + Util.extend = function (a, b) { + if (!Util.isObject(a) || !Util.isObject(b)) { + return void console.log("Extend only works with 2 objects"); + } + if (Util.isCircular(b)) { + return void console.log("Extend doesn't accept circular objects"); + } + for (var k in b) { + if (Util.isObject(b[k])) { + a[k] = Util.isObject(a[k]) ? a[k] : {}; + Util.extend(a[k], b[k]); + continue; + } + if (Array.isArray(b[k])) { + a[k] = b[k].slice(); + continue; + } + a[k] = b[k]; + } + }; + + Util.isChecked = function (el) { + // could be nothing... + if (!el) { return false; } + // check if it's a dom element + if (typeof(el.tagName) !== 'undefined') { + return Boolean(el.checked); + } + // sketchy test to see if it's jquery + if (typeof(el.prop) === 'function') { + return Boolean(el.prop('checked')); + } + // else just say it's not checked + return false; + }; + + Util.hexToRGB = function (hex) { + var h = hex.replace(/^#/, ''); + return [ + parseInt(h.slice(0,2), 16), + parseInt(h.slice(2,4), 16), + parseInt(h.slice(4,6), 16), + ]; + }; + Util.rgbToHex = function (rgb) { + return `#${rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/).slice(1).map(n => parseInt(n, 10).toString(16).padStart(2, '0')).join('')}`; + }; + + Util.isSmallScreen = function () { + return window.innerHeight < 800 || window.innerWidth < 800; + }; + + Util.stripTags = function (text) { + var div = document.createElement("div"); + div.innerHTML = text; + return div.innerText; + }; + + // return an object containing {name, ext} + // or {} if the name could not be parsed + Util.parseFilename = function (filename) { + if (!filename || !filename.trim()) { return {}; } + var parsedName = /^(\.?.+?)(\.[^.]+)?$/.exec(filename) || []; + return { + name: parsedName[1], + ext: parsedName[2], + }; + }; + + // Tell if a file is plain text from its metadata={title, fileType} + Util.isPlainTextFile = function (type, name) { + // does its type begins with "text/" + if (type && type.indexOf("text/") === 0) { return true; } + // no type and no file extension -> let's guess it's plain text + var parsedName = Util.parseFilename(name); + if (!type && name && !parsedName.ext) { return true; } + // other exceptions + if (type === 'application/x-javascript') { return true; } + if (type === 'application/xml') { return true; } + return false; + }; + + // Tell if a file is spreadsheet from its metadata={title, fileType} + Util.isSpreadsheet = function (type, name) { + return (type && + (type === 'application/vnd.oasis.opendocument.spreadsheet' || + type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')) + || (name && (name.endsWith('.xlsx') || name.endsWith('.ods'))); + }; + Util.isOfficeDoc = function (type, name) { + return (type && + (type === 'application/vnd.oasis.opendocument.text' || + type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')) + || (name && (name.endsWith('.docx') || name.endsWith('.odt'))); + }; + Util.isPresentation = function (type, name) { + return (type && + (type === 'application/vnd.oasis.opendocument.presentation' || + type === 'application/vnd.openxmlformats-officedocument.presentationml.presentation')) + || (name && (name.endsWith('.pptx') || name.endsWith('.odp'))); + }; + + Util.isValidURL = function (str) { + var pattern = new RegExp('^(https?:\\/\\/)'+ // protocol + '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // domain name + '((\\d{1,3}\\.){3}\\d{1,3}))'+ // OR ip (v4) address + '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // port and path + '(\\?[;&a-z\\d%_.~+=-]*)?'); // query string + //'(\\#[-a-z\\d_]*)?$','i'); // fragment locator + return !!pattern.test(str); + }; + + var emoji_patt = /([\uD800-\uDBFF][\uDC00-\uDFFF])/; + var isEmoji = function (str) { + return emoji_patt.test(str); + }; + var emojiStringToArray = function (str) { + var split = str.split(emoji_patt); + var arr = []; + for (var i=0; i { + let arr = /ver=([0-9.]+)(-[0-9]*)?/.exec(urlArgs); + let ver = Array.isArray(arr) && arr[1]; + return ver || undefined; + }; + + + if (typeof(module) !== 'undefined' && module.exports) { + module.exports = Util; + } else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([], function () { + window.CryptPad_Util = Util; + return Util; + }); + } else { + window.CryptPad_Util = Util; + } +}(typeof(self) !== 'undefined'? self: this)); diff --git a/src/common/cryptget.js b/src/common/cryptget.js new file mode 100644 index 000000000..df3453225 --- /dev/null +++ b/src/common/cryptget.js @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(() => { +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); + S.done = true; + + if (!S.hasNetwork) { + var disconnect = Util.find(S, ['network', 'disconnect']); + if (typeof(disconnect) === 'function') { disconnect(); } + } + if (S.realtime && S.realtime.stop) { + try { + S.realtime.stop(); + } catch (e) { console.error(e); } + } + var abort = Util.find(S, ['session', 'realtime', 'abort']); + if (typeof(abort) === 'function') { + S.session.realtime.sync(); + abort(); + } + }; + + var makeNetwork = function (cb) { + var wsUrl = NetConfig.getWebsocketURL(); + Netflux.connect(wsUrl).then(function (network) { + cb(null, network); + }, function (err) { + cb(err); + }); + }; + + var start = function (Session, config) { + // Create a network and authenticate with all our keys if necessary, + // then start chainpad-netflux + nThen(function (waitFor) { + if (Session.hasNetwork) { return; } + makeNetwork(waitFor(function (err, network) { + if (err) { return; } + config.network = network; + })); + }).nThen(function () { + Session.realtime = CPNetflux.start(config); + }); + }; + + var onRejected = function (config, Session, data, cb) { + // Check if we can authenticate + if (!Array.isArray(data) || !data.length || data[0].length !== 16) { + return void cb(true); + } + if (!Array.isArray(Session.accessKeys)) { return void cb(true); } + + // Authenticate + config.network.historyKeeper = data[0]; + nThen(function (waitFor) { + Session.accessKeys.forEach(function (obj) { + Pinpad.create(config.network, obj, waitFor(function (e) { + console.log('done', obj); + if (e) { console.error(e); } + })); + }); + }).nThen(function () { + cb(); + }); + }; + + var makeConfig = function (hash, opt) { + var secret; + if (typeof(hash) === 'string') { + // We can't use cryptget with a file or a user so we can use 'pad' as hash type + secret = Hash.getSecrets('pad', hash, opt.password); + } else if (typeof(hash) === 'object') { + // we may want to just supply options directly + // and this is the easiest place to do it + secret = hash; + } + if (!secret.keys) { secret.keys = secret.key; } // support old hashses + var config = { + websocketURL: NetConfig.getWebsocketURL(opt.origin), + channel: secret.channel, + validateKey: secret.keys.validateKey || undefined, + crypto: Crypto.createEncryptor(secret.keys), + logLevel: 0, + initialState: opt.initialState, + Cache: Cache + }; + return config; + }; + + var isObject = function (o) { + return typeof(o) === 'object'; + }; + + var overwrite = function (a, b) { + if (!(isObject(a) && isObject(b))) { return; } + Object.keys(b).forEach(function (k) { a[k] = b[k]; }); + }; + + var get = function (hash, cb, opt, progress) { + if (typeof(cb) !== 'function') { + throw new Error('Cryptget expects a callback'); + } + opt = opt || {}; + progress = progress || function () {}; + + var config = makeConfig(hash, opt); + var Session = { + cb: cb, + accessKeys: opt.accessKeys, + hasNetwork: Boolean(opt.network) + }; + + config.onRejected = function (data, cb) { + onRejected(config, Session, data, cb); + }; + + config.onReady = function (info) { + var rt = Session.session = info.realtime; + Session.network = info.network; + progress(1); + finish(Session, void 0, rt.getUserDoc()); + }; + + config.onError = function (info) { + console.warn(info); + finish(Session, info); + }; + config.onChannelError = function (info) { + console.error(info); + finish(Session, info); + }; + + config.onCacheReady = opt.onCacheReady; + + // We use the new onMessage handler to compute the progress: + // we should receive 2 checkpoints max, so 100 messages max + // We're going to consider that 1 message = 1%, and we'll send 100% + // at the end + var i = 0; + config.onMessage = function () { + i++; + progress(Math.min(0.99, i/100)); + }; + + overwrite(config, opt); + + start(Session, config); + }; + + var put = function (hash, doc, cb, opt) { + if (typeof(cb) !== 'function') { + throw new Error('Cryptput expects a callback'); + } + opt = opt || {}; + + var config = makeConfig(hash, opt); + var Session = { + cb: cb, + accessKeys: opt.accessKeys, + hasNetwork: Boolean(opt.network) + }; + + config.onRejected = function (data, cb) { + onRejected(config, Session, data, cb); + }; + + config.onReady = function (info) { + var realtime = Session.session = info.realtime; + Session.network = info.network; + + realtime.contentUpdate(doc); + + var to = setTimeout(function () { + cb(new Error("Timeout")); + }, 15000); + + Realtime.whenRealtimeSyncs(realtime, function () { + clearTimeout(to); + var doc = realtime.getAuthDoc(); + realtime.abort(); + finish(Session, void 0, doc); + }); + }; + + config.onChannelError = function (info) { + finish(Session, info); + }; + + overwrite(config, opt); + + start(Session, config); + }; + + return { + 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/src/common/http-command.js b/src/common/http-command.js new file mode 100644 index 000000000..5f64de6d3 --- /dev/null +++ b/src/common/http-command.js @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(() => { +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(); + }; + + var clone = o => JSON.parse(JSON.stringify(o)); + var randomToken = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)); + var postData = function (url, data, cb) { + var CB = Util.once(Util.mkAsync(cb)); + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }).then(response => { + if (response.ok) { + + return void response.text().then(result => { CB(void 0, Util.tryParse(result)); }); // checkup error when using .json() + //return void response.json().then(result => { CB(void 0, result); }); + } + + response.json().then().then(result => { + CB(response.status, result); + }); + //CB(response.status, response); + }).catch(error => { + CB(error); + }); + }; + + var serverCommand = function (keypair, my_data, cb) { + var obj = clone(my_data); + obj.publicKey = Nacl.util.encodeBase64(keypair.publicKey); + obj.nonce = randomToken(); + var href = new URL('/api/auth/', API_ORIGIN); + var txid, date; + nThen(function (w) { + // Tell the server we want to do some action + postData(href, obj, w((err, data) => { + if (err) { + w.abort(); + console.error(err); + // there might be more info here + if (data) { console.error(data); } + return void cb(err); + } + + // if the requested action is valid, it responds with a txid and a nonce + // bundle all that up into an object, stringify it, and sign it. + // respond with an object: {sig, txid} + if (!data.date || !data.txid) { + w.abort(); + return void cb('REQUEST_REJECTED'); + } + txid = data.txid; + date = data.date; + })); + }).nThen(function (w) { + var copy = clone(obj); + copy.txid = txid; + copy.date = date; + var toSign = Nacl.util.decodeUTF8(JSON.stringify(copy)); + var sig = Nacl.sign.detached(toSign, keypair.secretKey); + var encoded = Nacl.util.encodeBase64(sig); + var obj2 = { + sig: encoded, + txid: txid, + }; + postData(href, obj2, w((err, data) => { + if (err) { + w.abort(); + console.error(err); + // there might be more info here + if (data) { console.error(data); } + return void cb("RESPONSE_REJECTED", data); + } + cb(void 0, data); + })); + }); + }; + + 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) => { + factory(nThen, Util, ApiConfig, window.nacl); + }); +} else { + // unsupported initialization +} + +})(); diff --git a/src/common/login-block.js b/src/common/login-block.js new file mode 100644 index 000000000..dff0efb14 --- /dev/null +++ b/src/common/login-block.js @@ -0,0 +1,245 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(() => { +const factory = (Util, ApiConfig = {}, ServerCommand, Nacl) => { + + var Block = {}; + + Block.setCustomize = data => { + ApiConfig = data.ApiConfig; + ServerCommand.setCustomize(data); + }; + + Block.join = Util.uint8ArrayJoin; + + // publickey + + // signature + + // block + + // [b64_public, b64_sig, b64_block [version, nonce, content]] + + Block.seed = function () { + return Nacl.hash(Nacl.util.decodeUTF8('pewpewpew')); + }; + + // should be deterministic from a seed... + Block.genkeys = function (seed) { + if (!(seed instanceof Uint8Array)) { + throw new Error('INVALID_SEED_FORMAT'); + } + if (!seed || typeof(seed.length) !== 'number' || seed.length < 64) { + throw new Error('INVALID_SEED_LENGTH'); + } + + var signSeed = seed.subarray(0, Nacl.sign.seedLength); + var symmetric = seed.subarray(Nacl.sign.seedLength, + Nacl.sign.seedLength + Nacl.secretbox.keyLength); + + return { + sign: Nacl.sign.keyPair.fromSeed(signSeed), // 32 bytes + symmetric: symmetric, // 32 bytes ... + }; + }; + + Block.keysToRPCFormat = function (keys) { + try { + var sign = keys.sign; + return { + edPrivate: Nacl.util.encodeBase64(sign.secretKey), + edPublic: Nacl.util.encodeBase64(sign.publicKey), + }; + } catch (err) { + console.error(err); + return; + } + }; + + // (UTF8 content, keys object) => Uint8Array block + Block.encrypt = function (version, content, keys) { + var u8 = Nacl.util.decodeUTF8(content); + var nonce = Nacl.randomBytes(Nacl.secretbox.nonceLength); + return Block.join([ + [0], + nonce, + Nacl.secretbox(u8, nonce, keys.symmetric) + ]); + }; + + // (uint8Array block) => payload object + Block.decrypt = function (u8_content, keys) { + // version is currently ignored since there is only one + var nonce = u8_content.subarray(1, 1 + Nacl.secretbox.nonceLength); + var box = u8_content.subarray(1 + Nacl.secretbox.nonceLength); + + var plaintext = Nacl.secretbox.open(box, nonce, keys.symmetric); + try { + return JSON.parse(Nacl.util.encodeUTF8(plaintext)); + } catch (e) { + console.error(e); + return; + } + }; + + // (Uint8Array block) => signature + Block.sign = function (ciphertext, keys) { + return Nacl.sign.detached(Nacl.hash(ciphertext), keys.sign.secretKey); + }; + + Block.serialize = function (content, keys) { + // encrypt the content + var ciphertext = Block.encrypt(0, content, keys); + + // generate a detached signature + var sig = Block.sign(ciphertext, keys); + + // serialize {publickey, sig, ciphertext} + return { + publicKey: Nacl.util.encodeBase64(keys.sign.publicKey), + signature: Nacl.util.encodeBase64(sig), + ciphertext: Nacl.util.encodeBase64(ciphertext), + }; + }; + + Block.proveAncestor = function (O /* oldBlockKeys, N, newBlockKeys */) { + var u8_pub = Util.find(O, ['sign', 'publicKey']); + var u8_secret = Util.find(O, ['sign', 'secretKey']); + try { + // sign your old publicKey with your old privateKey + var u8_sig = Nacl.sign.detached(u8_pub, u8_secret); + // return an array with the sig and the pubkey + return JSON.stringify([u8_pub, u8_sig].map(Nacl.util.encodeBase64)); + } catch (err) { + return void console.error(err); + } + }; + + var urlSafeB64 = function (u8) { + return Nacl.util.encodeBase64(u8).replace(/\//g, '-'); + }; + + Block.getBlockUrl = function (keys) { + var publicKey = urlSafeB64(keys.sign.publicKey); + // 'block/' here is hardcoded because it's hardcoded on the server + // if we want to make CryptPad work in server subfolders, we'll need + // to update this path derivation + return (ApiConfig.fileHost || ApiConfig.httpUnsafeOrigin || window.location.origin) + + '/block/' + publicKey.slice(0, 2) + '/' + publicKey; + }; + + Block.getBlockHash = function (keys) { + var absolute = Block.getBlockUrl(keys); + var symmetric = urlSafeB64(keys.symmetric); + return absolute + '#' + symmetric; + }; + + var decodeSafeB64 = function (b64) { + try { + return Nacl.util.decodeBase64(b64.replace(/\-/g, '/')); + } catch (e) { + console.error(e); + return; + } + }; + + Block.parseBlockHash = function (hash) { + if (typeof(hash) !== 'string') { return; } + var parts = hash.split('#'); + if (parts.length !== 2) { return; } + + try { + return { + href: parts[0], + keys: { + symmetric: decodeSafeB64(parts[1]), + } + }; + } catch (e) { + console.error(e); + return; + } + }; + + Block.checkRights = function (data, _cb) { + const cb = Util.mkAsync(_cb); + const { blockKeys, auth } = data; + + var command = 'MFA_CHECK'; + if (auth && auth.type) { command = `${auth.type.toUpperCase()}_` + command; } + + ServerCommand(blockKeys.sign, { + command: command, + auth: auth && auth.data + }, cb); + }; + Block.writeLoginBlock = function (data, cb) { + const { content, blockKeys, oldBlockKeys, auth, pw, session, token, userData } = data; + + var command = 'WRITE_BLOCK'; + if (auth && auth.type) { command = `${auth.type.toUpperCase()}_` + command; } + + var block = Block.serialize(JSON.stringify(content), blockKeys); + block.auth = auth && auth.data; + block.hasPassword = pw; + block.registrationProof = oldBlockKeys && Block.proveAncestor(oldBlockKeys); + if (token) { block.inviteToken = token; } + if (userData) { block.userData = userData; } + + ServerCommand(blockKeys.sign, { + command: command, + content: block, + session: session // sso session + }, cb); + }; + Block.removeLoginBlock = function (data, cb) { + const { reason, blockKeys, auth, edPublic } = data; + + var command = 'REMOVE_BLOCK'; + if (auth && auth.type) { command = `${auth.type.toUpperCase()}_` + command; } + + ServerCommand(blockKeys.sign, { + command: command, + auth: auth && auth.data, + edPublic: edPublic, + reason: reason + }, cb); + }; + + Block.updateSSOBlock = function (data, cb) { + const { blockKeys, oldBlockKeys } = data; + var oldProof = oldBlockKeys && Block.proveAncestor(oldBlockKeys); + + ServerCommand(blockKeys.sign, { + command: 'SSO_UPDATE_BLOCK', + ancestorProof: oldProof + }, cb); + + }; + + 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) => { + factory(Util, ApiConfig, ServerCommand, window.nacl); + }); +} else { + // unsupported initialization +} + +})(); diff --git a/src/common/network-config.js b/src/common/network-config.js new file mode 100644 index 000000000..f74697170 --- /dev/null +++ b/src/common/network-config.js @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(() => { +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"); + l.href = origin; + } + var protocol = l.protocol.replace(/http/, 'ws'); + var host = l.host; + var url = protocol + '//' + host + path; + + return url; + }; + + 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/src/common/notify.js b/src/common/notify.js new file mode 100644 index 000000000..35ff04d65 --- /dev/null +++ b/src/common/notify.js @@ -0,0 +1,201 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +/* eslint compat/compat: "off" */ + +(() => { +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 && 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; + var DEFAULT_ALT = '/customize/favicon/alt-favicon' + suffix + '.png?' + ApiConfig.requireConf.urlArgs; + var DEFAULT_MAIN_ICO = '/customize/favicon/main-favicon' + suffix + '.ico?' + ApiConfig.requireConf.urlArgs; + var DEFAULT_ALT_ICO = '/customize/favicon/alt-favicon' + suffix + '.ico?' + ApiConfig.requireConf.urlArgs; + + var document = window.document; + + var isSupported = Module.isSupported = function () { + return typeof(window.Notification) === 'function' && window.isSecureContext; + }; + + var hasPermission = Module.hasPermission = function () { + return Notification.permission === 'granted'; + }; + + var getPermission = Module.getPermission = function (f) { + f = f || function () {}; + // "Notification.requestPermission is not a function" on Firefox 68.11.0esr + if (!Notification || typeof(Notification.requestPermission) !== 'function') { return void f(false); } + Notification.requestPermission(function (permission) { + if (permission === "granted") { f(true); } + else { f(false); } + }); + }; + + var create = Module.create = function (msg, title, icon) { + if (document && !icon) { + var favicon = document.getElementById('favicon'); + icon = favicon.getAttribute('data-main-favicon') || DEFAULT_ALT; + } else if (!icon) { + icon = DEFAULT_ALT; + } + + var n = new Notification(title,{ + icon: icon, + body: msg, + }); + n.onclick = function () { + if (!document) { return; } + try { + parent.focus(); + window.focus(); //just in case, older browsers + this.close(); + } catch (e) {} + }; + return n; + }; + + Module.system = function (msg, title, icon) { + // Let's check if the browser supports notifications + if (!isSupported()) { return; /*console.log("Notifications are not supported");*/ } + + // Let's check whether notification permissions have already been granted + else if (hasPermission()) { + // If it's okay let's create a notification + return create(msg, title, icon); + } + + // Otherwise, we need to ask the user for permission + else if (Notification.permission !== 'denied') { + getPermission(function (state) { + if (state) { create(msg, title, icon); } + }); + } + }; + + var createFavicon = function () { + if (!document) { + return void console.error('document is not available in this context'); + } + console.debug("creating favicon"); + var attrs = { + id: 'favicon', + type: 'image/png', + rel: 'icon', + 'data-main-favicon': DEFAULT_MAIN, + 'data-alt-favicon': DEFAULT_ALT, + href: DEFAULT_MAIN, + }; + if(!document.getElementById("favicon")) { + var fav = document.createElement('link'); + Object.keys(attrs).forEach(function (k) { + fav.setAttribute(k, attrs[k]); + }); + document.head.appendChild(fav); + } + + if(!document.getElementById("favicon-ico")) { + var faviconLink = document.createElement('link'); + attrs.href = attrs.href.replace(/\.png/g, ".ico"); + attrs.id = 'favicon-ico'; + attrs.type = 'image/x-icon'; + + Object.keys(attrs).forEach(function (k) { + faviconLink.setAttribute(k, attrs[k]); + }); + + document.head.appendChild(faviconLink); + } + }; + + if (document && !document.getElementById('favicon')) { createFavicon(); } + + Module.tab = function (frequency, count) { + if (!document) { + return void console.error('document is not available in this context'); + } + var key = '_pendingTabNotification'; + + var favicon = document.getElementById('favicon'); + var faviconIco = document.getElementById('favicon-ico'); + + var main = DEFAULT_MAIN; + var alt = DEFAULT_ALT; + var mainIco = DEFAULT_MAIN_ICO; + var altIco = DEFAULT_ALT_ICO; + + if (favicon) { + main = favicon.getAttribute('data-main-favicon') || DEFAULT_MAIN; + alt = favicon.getAttribute('data-alt-favicon') || DEFAULT_ALT; + favicon.setAttribute('href', main); + } + if (faviconIco) { + mainIco = faviconIco.getAttribute('data-main-favicon') || DEFAULT_MAIN_ICO; + altIco = faviconIco.getAttribute('data-alt-favicon') || DEFAULT_ALT_ICO; + faviconIco.setAttribute('href', mainIco); + } + + var cancel = function (pending) { + // only run one tab notification at a time + if (Module[key]) { + window.clearInterval(Module[key]); + if (favicon) { + favicon.setAttribute('href', pending? alt : main); + } + if (faviconIco) { + faviconIco.setAttribute('href', pending? altIco : mainIco); + } + + return true; + } + return false; + }; + + cancel(); + + var step = function () { + if (favicon) { + favicon.setAttribute('href', favicon.getAttribute('href') === main? alt : main); + } + if (faviconIco) { + faviconIco.setAttribute('href', faviconIco.getAttribute('href') === mainIco? altIco : mainIco); + } + --count; + }; + + Module[key] = window.setInterval(function () { + if (count > 0) { return step(); } + cancel(true); + + }, frequency); + step(); + + return { + cancel: cancel, + }; + }; + + 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/src/common/onlyoffice/current-version.js b/src/common/onlyoffice/current-version.js new file mode 100644 index 000000000..59c468850 --- /dev/null +++ b/src/common/onlyoffice/current-version.js @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(() => { +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/src/common/pad-types.js b/src/common/pad-types.js new file mode 100644 index 000000000..25837944f --- /dev/null +++ b/src/common/pad-types.js @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(() => { +const factory = (AppConfig = {}, ApiConfig = {}, + OOCurrentVersion) => { + + let availablePadTypes = []; + const OO_APPS = ["sheet", "doc", "presentation"]; + + const setCustomize = data => { + AppConfig = data.AppConfig; + ApiConfig = data.ApiConfig; + + const ooEnabled = ApiConfig.onlyOffice && + ApiConfig.onlyOffice.availableVersions.includes( + OOCurrentVersion.currentVersion + ); + availablePadTypes = AppConfig.availablePadTypes.filter( + (t) => ooEnabled || !OO_APPS.includes(t) + ); + }; + + + 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/src/common/pinpad.js b/src/common/pinpad.js new file mode 100644 index 000000000..c7b6f74e2 --- /dev/null +++ b/src/common/pinpad.js @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(function () { +var factory = function (Util, Rpc) { + var create = function (network, proxy, _cb, Cache) { + if (typeof(_cb) !== 'function') { throw new Error("Expected callback"); } + var cb = Util.once(Util.mkAsync(_cb)); + + if (!network) { return void cb('INVALID_NETWORK'); } + if (!proxy) { return void cb('INVALID_PROXY'); } + + var edPrivate = proxy.edPrivate; + var edPublic = proxy.edPublic; + + if (!(edPrivate && edPublic)) { return void cb('INVALID_KEYS'); } + + Rpc.create(network, edPrivate, edPublic, function (e, rpc) { + if (e) { return void cb(e); } + + var exp = {}; + + exp.destroy = rpc.destroy; + + // expose the supplied publicKey as an identifier + exp.publicKey = edPublic; + + // expose the RPC module's raw 'send' command + exp.send = rpc.send; + + // you can ask the server to pin a particular channel for you + exp.pin = function (channels, _cb) { + var cb = Util.once(Util.mkAsync(_cb)); + if (!Array.isArray(channels)) { + return void cb('[TypeError] pin expects an array'); + } + rpc.send('PIN', channels, cb); + }; + + // you can also ask to unpin a particular channel + exp.unpin = function (channels, _cb) { + var cb = Util.once(Util.mkAsync(_cb)); + if (!Array.isArray(channels)) { + return void cb('[TypeError] pin expects an array'); + } + rpc.send('UNPIN', channels, cb); + }; + + // Get data for the admin panel + exp.adminRpc = function (obj, cb) { + if (!obj.cmd) { + setTimeout(function () { + cb('[TypeError] admin rpc expects a command'); + }); + return; + } + var params = [obj.cmd, obj.data]; + rpc.send('ADMIN', params, cb); + }; + + // ask the server what it thinks your hash is + exp.getServerHash = function (cb) { + rpc.send('GET_HASH', edPublic, function (e, hash) { + if (!(hash && hash[0])) { + return void cb('NO_HASH_RETURNED'); + } + cb(e, Array.isArray(hash) && hash[0] || undefined); + }); + }; + + // if local and remote hashes don't match, send a reset + exp.reset = function (channels, _cb) { + var cb = Util.once(Util.mkAsync(_cb)); + if (!Array.isArray(channels)) { + return void cb('[TypeError] pin expects an array'); + } + rpc.send('RESET', channels, cb); + }; + + // get the combined size of all channels (in bytes) for all the + // channels which the server has pinned for your publicKey + exp.getFileListSize = function (cb) { + rpc.send('GET_TOTAL_SIZE', undefined, function (e, response) { + if (e) { return void cb(e); } + if (response && response.length && typeof(response[0]) === 'number') { + cb(void 0, response[0]); + } else { + cb('INVALID_RESPONSE'); + } + }); + }; + + // Update the limit value for all the users and return the limit for your publicKey + exp.updatePinLimits = function (cb) { + rpc.send('UPDATE_LIMITS', undefined, function (e, response) { + if (e) { return void cb(e); } + if (response && response.length && typeof(response[0]) === "number") { + cb (void 0, response[0], response[1], response[2]); + } else { + cb('INVALID_RESPONSE'); + } + }); + }; + // Get the storage limit associated with your publicKey + exp.getLimit = function (cb) { + rpc.send('GET_LIMIT', undefined, function (e, response) { + if (e) { return void cb(e); } + if (response && response.length && typeof(response[0]) === "number") { + cb (void 0, response[0], response[1], response[2]); + } else { + cb('INVALID_RESPONSE'); + } + }); + }; + + exp.trimHistory = function (data, _cb) { + var cb = Util.once(Util.mkAsync(_cb)); + if (typeof(data) !== 'object' || !data.channel || !data.hash) { + return void cb('INVALID_ARGUMENTS'); + } + rpc.send('TRIM_HISTORY', data, function (e) { + if (e) { return cb(e); } + cb(); + }); + }; + + exp.clearOwnedChannel = function (channel, cb) { + if (typeof(channel) !== 'string' || channel.length !== 32) { + return void cb('INVALID_ARGUMENTS'); + } + rpc.send('CLEAR_OWNED_CHANNEL', channel, function (e) { + if (e) { return cb(e); } + cb(); + }); + }; + + exp.removeOwnedChannel = function (channel, cb, reason) { + if (typeof(channel) !== 'string' || [32,48].indexOf(channel.length) === -1) { + console.error('invalid channel to remove', channel); + return void cb('INVALID_ARGUMENTS'); + } + rpc.send('REMOVE_OWNED_CHANNEL', { + channel: channel, + reason: reason + }, function (e, response) { + if (e) { return void cb(e); } + if (response && response.length && response[0] === "OK") { + cb(); + if (Cache && Cache.clearChannel) { + Cache.clearChannel(channel); + } + } else { + cb('INVALID_RESPONSE'); + } + }); + }; + + exp.removePins = function (cb) { + rpc.send('REMOVE_PINS', undefined, function (e, response) { + if (e) { return void cb(e); } + if (response && response.length && response[0] === "OK") { + cb(); + } else { + cb('INVALID_RESPONSE'); + } + }); + }; + + exp.uploadComplete = function (id, cb) { + rpc.send('UPLOAD_COMPLETE', id, function (e, res) { + if (e) { return void cb(e); } + var id = res[0]; + if (typeof(id) !== 'string') { + return void cb('INVALID_ID'); + } + cb(void 0, id); + }); + }; + + exp.ownedUploadComplete = function (id, cb) { + rpc.send('OWNED_UPLOAD_COMPLETE', id, function (e, res) { + if (e) { return void cb(e); } + var id = res[0]; + if (typeof(id) !== 'string') { + return void cb('INVALID_ID'); + } + cb(void 0, id); + }); + }; + + exp.uploadStatus = function (size, cb) { + if (typeof(size) !== 'number') { + return void setTimeout(function () { + cb('INVALID_SIZE'); + }); + } + rpc.send('UPLOAD_STATUS', size, function (e, res) { + if (e) { return void cb(e); } + var pending = res[0]; + if (typeof(pending) !== 'boolean') { + return void cb('INVALID_RESPONSE'); + } + cb(void 0, pending); + }); + }; + + exp.uploadCancel = function (size, cb) { + rpc.send('UPLOAD_CANCEL', size, function (e) { + if (e) { return void cb(e); } + cb(); + }); + }; + + // Get data for the admin panel + exp.setMetadata = function (obj, cb) { + rpc.send('SET_METADATA', { + channel: obj.channel, + command: obj.command, + value: obj.value + }, cb); + }; + + cb(e, exp); + }); + }; + + return { create: create }; +}; + if (typeof(module) !== 'undefined' && module.exports) { + module.exports = factory(require('./common-util'), require("./rpc")); + } else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { + define([ '/common/common-util.js', '/common/rpc.js', ], function (Util, Rpc) { return factory(Util, Rpc); }); + } +}()); diff --git a/src/common/proxy-manager.js b/src/common/proxy-manager.js new file mode 100644 index 000000000..fee14db0d --- /dev/null +++ b/src/common/proxy-manager.js @@ -0,0 +1,1813 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(() => { +const factory = (UserObject, Util, Hash, + SF, Messages = {}, Feedback, nThen) => { + + let setCustomize = data => { + Messages = data.Messages; + UserObject.setCustomize(data); + }; + + var getConfig = function (Env) { + var cfg = {}; + for (var k in Env.cfg) { cfg[k] = Env.cfg[k]; } + return cfg; + }; + + // Add a shared folder to the list + var addProxy = function (Env, id, lm, leave, editKey, force) { + if (Env.folders[id] && !force && !Env.folders[id].restricted) { + // Shared folder already added to the proxy-manager, probably + // a cached version + if (Env.folders[id].offline && !lm.cache) { + Env.folders[id].offline = false; + if (Env.folders[id].userObject.fixFiles) { Env.folders[id].userObject.fixFiles(); } + Env.Store.refreshDriveUI(); + } + return; + } + var cfg = getConfig(Env); + cfg.sharedFolder = true; + cfg.id = id; + cfg.editKey = editKey; + cfg.rt = lm.realtime; + cfg.readOnly = Boolean(!editKey); + var userObject = UserObject.init(lm.proxy, cfg); + if (userObject.fixFiles) { + // Only in outer + userObject.fixFiles(); + } + var proxy = lm.proxy; + if (proxy.metadata && proxy.metadata.title) { + var sf = Env.user.proxy[UserObject.SHARED_FOLDERS][id]; + if (sf) { + sf.lastTitle = proxy.metadata.title; + } + } + Env.folders[id] = { + proxy: lm.proxy, + userObject: userObject, + leave: leave, + restricted: proxy.restricted, + offline: Boolean(lm.cache) + }; + if (proxy.on) { + proxy.on('disconnect', function () { + Env.folders[id].offline = true; + }); + proxy.on('reconnect', function () { + Env.folders[id].offline = false; + }); + } + return userObject; + }; + + var removeProxy = function (Env, id) { + var f = Env.folders[id]; + if (!f) { return; } + f.leave(); + delete Env.folders[id]; + }; + + var sendNotification = (Env, sfId, title) => { + var mailbox = Env.store.mailbox; + if (!mailbox) { return; } + var team = Env.cfg.teamId; + var box; + if (team) { + let teams = Env.store.modules['team'].getTeamsData(); + box = teams[team]; + } else { + let md = Env.Store.getMetadata(null, null, () => {}); + box = md.user; + } + mailbox.sendTo('SF_DELETED', { + sfId: sfId, + team: team, + title: title + }, { + curvePublic: box.curvePublic, + channel: box.notifications + }, (err) => { + console.error(err); + }); + }; + + // Password may have changed + var deprecateProxy = function (Env, id, channel, reason) { + if (Env.folders[id] && Env.folders[id].deleting) { + // Folder is being deleted by its owner, don't deprecate it + return; + } + if (Env.user.userObject.readOnly) { + // In a read-only team, we can't deprecate a shared folder + // Use a empty object with a deprecated flag... + var lm = { proxy: { deprecated: true } }; + removeProxy(Env, id); + addProxy(Env, id, lm, function () {}); + return void Env.Store.refreshDriveUI(); + } + if (channel) { Env.unpinPads([channel], function () {}); } + + // If it's explicitely a deletion, no need to deprecate, just delete + if (reason && reason !== "PASSWORD_CHANGE") { + let temp = Util.find(Env, ['user', 'proxy', UserObject.SHARED_FOLDERS]); + let title = temp[id] && temp[id].lastTitle; + if (title) { sendNotification(Env, id, title); } + + delete temp[id]; + + if (Env.Store && Env.Store.refreshDriveUI) { Env.Store.refreshDriveUI(); } + return; + } + + // It's explicitely a password change, better message in drive: provide the "reason" to the UI + Env.user.userObject.deprecateSharedFolder(id, reason); + removeProxy(Env, id); + if (Env.Store && Env.Store.refreshDriveUI) { Env.Store.refreshDriveUI(); } + }; + + var restrictedProxy = function (Env, id) { + var lm = { proxy: { restricted: true, root: {}, filesData: {} } }; + removeProxy(Env, id); + addProxy(Env, id, lm, function () {}); + return void Env.Store.refreshDriveUI(); + }; + + /* + Tools + */ + var _ownedByMe = function (Env, owners) { + return Array.isArray(owners) && owners.indexOf(Env.edPublic) !== -1; + }; + var _ownedByOther = function (Env, owners) { + return Array.isArray(owners) && owners.length && + (!Env.edPublic || owners.indexOf(Env.edPublic) === -1); + }; + + var _getUserObjects = function (Env) { + var userObjects = [Env.user.userObject]; + var foldersUO = Object.keys(Env.folders).map(function (k) { + return Env.folders[k].userObject; + }); + Array.prototype.push.apply(userObjects, foldersUO); + return userObjects; + }; + + var _getUserObjectFromId = function (Env, id) { + var userObjects = _getUserObjects(Env); + var userObject = Env.user.userObject; + userObjects.some(function (uo) { + if (Object.keys(uo.getFileData(id)).length) { + userObject = uo; + return true; + } + }); + return userObject; + }; + + var _getUserObjectPath = function (Env, uo) { + var fId = Number(uo.id); + if (!fId) { return; } + var fPath = Env.user.userObject.findFile(fId)[0]; + return fPath; + }; + + // Return files data objects associated to a channel for setPadTitle + // All occurences are returned, in drive or shared folders + // If "editable" is true, the data returned is a proxy, otherwise + // it's a cloned object (NOTE: href should never be edited directly) + var findChannel = function (Env, channel, editable) { + var ret = []; + Env.user.userObject.findChannels([channel], true).forEach(function (id) { + // Check in shared folders, then clone if needed + var data = Env.user.proxy[UserObject.SHARED_FOLDERS][id]; + if (data && !editable) { data = JSON.parse(JSON.stringify(data)); } + // If it's not a shared folder, check the pads + if (!data) { data = Env.user.userObject.getFileData(id, editable); } + ret.push({ + id: id, + data: data, + userObject: Env.user.userObject + }); + }); + Object.keys(Env.folders).forEach(function (fId) { + Env.folders[fId].userObject.findChannels([channel]).forEach(function (id) { + ret.push({ + id: id, + fId: fId, + data: Env.folders[fId].userObject.getFileData(id, editable), + userObject: Env.folders[fId].userObject + }); + }); + }); + return ret; + }; + // Return files data objects associated to a given href for setPadAttribute... + // If "editable" is true, the data returned is a proxy, otherwise + // it's a cloned object (NOTE: href should never be edited directly) + var findHref = function (Env, href) { + var ret = []; + var id = Env.user.userObject.getIdFromHref(href); + if (id) { + ret.push({ + data: Env.user.userObject.getFileData(id), + userObject: Env.user.userObject + }); + } + Object.keys(Env.folders).forEach(function (fId) { + var id = Env.folders[fId].userObject.getIdFromHref(href); + if (!id) { return; } + ret.push({ + fId: fId, + data: Env.folders[fId].userObject.getFileData(id), + userObject: Env.folders[fId].userObject + }); + }); + return ret; + }; + // Return paths linked to a file ID + var findFile = function (Env, id) { + var ret = []; + var userObjects = _getUserObjects(Env); + userObjects.forEach(function (uo) { + var fPath = _getUserObjectPath(Env, uo); + var results = uo.findFile(id); + if (fPath) { + // This is a shared folder, we have to fix the paths in the results + results.forEach(function (p) { + Array.prototype.unshift.apply(p, fPath); + }); + } + // Push the results from this proxy + Array.prototype.push.apply(ret, results); + }); + return ret; + }; + + // Returns file IDs corresponding to the provided channels + var _findChannels = function (Env, channels, onlyMain) { + if (onlyMain) { + return Env.user.userObject.findChannels(channels); + } + var ret = []; + var userObjects = _getUserObjects(Env); + userObjects.forEach(function (uo) { + var results = uo.findChannels(channels); + Array.prototype.push.apply(ret, results); + }); + ret = Util.deduplicateString(ret); + return ret; + }; + + var _getFileData = function (Env, id, editable) { + var userObjects = _getUserObjects(Env); + var data = {}; + userObjects.some(function (uo) { + data = uo.getFileData(id, editable); + if (data && Object.keys(data).length) { return true; } + }); + return data; + }; + + var getSharedFolderData = function (Env, id) { + var inHistory; + if (Env.isHistoryMode && !Env.folders[id]) { inHistory = true; } + else if (!Env.folders[id]) { return {}; } + var proxy = inHistory? {}: Env.folders[id].proxy; + + // Clean deprecated values + if (Object.keys(proxy.metadata || {}).length > 1) { + proxy.metadata = { title: proxy.metadata.title }; + } + + var obj = Util.clone(proxy.metadata || {}); + + for (var k in Env.user.proxy[UserObject.SHARED_FOLDERS][id] || {}) { + if (typeof(Env.user.proxy[UserObject.SHARED_FOLDERS][id][k]) === "undefined") { // TODO "deleted folder" for restricted shared folders when viewer in a team + continue; + } + var data = Util.clone(Env.user.proxy[UserObject.SHARED_FOLDERS][id][k]); + if (k === "href" && data.indexOf('#') === -1) { + try { + data = Env.user.userObject.cryptor.decrypt(data); + } catch (e) {} + } + if (k === "href" && data.indexOf('#') === -1) { data = undefined; } + obj[k] = data; + } + return obj; + }; + + + // Transform an absolute path into a path relative to the correct shared folder + var _resolvePath = function (Env, path) { + var res = { + id: null, + userObject: Env.user.userObject, + path: path + }; + if (!Array.isArray(path) || path.length <= 1) { + return res; + } + var current; + var uo = Env.user.userObject; + // We don't need to check the last element of the path because we only need to split it + // when the path contains an element inside the shared folder + for (var i=2; i