diff --git a/config/config.example.js b/config/config.example.js index 273c196d2..3826a7291 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -89,6 +89,14 @@ module.exports = { */ //httpSafePort: 3001, +/* CryptPad will launch a child process for every core available + * in order to perform CPU-intensive tasks in parallel. + * Some host environments may have a very large number of cores available + * or you may want to limit how much computing power CryptPad can take. + * If so, set 'maxWorkers' to a positive integer. + */ + // maxWorkers: 4, + /* ===================== * Admin * ===================== */ diff --git a/customize.dist/src/outer.css b/customize.dist/src/outer.css index b36fb2c10..75f449e61 100644 --- a/customize.dist/src/outer.css +++ b/customize.dist/src/outer.css @@ -2,7 +2,7 @@ html, body { margin: 0px; padding: 0px; } -#sbox-iframe, #sbox-share-iframe, #sbox-filePicker-iframe { +#sbox-iframe, #sbox-secure-iframe { position: fixed; top:0; left:0; bottom:0; right:0; diff --git a/docs/example.nginx.conf b/docs/example.nginx.conf index ea8224c14..f6e163910 100644 --- a/docs/example.nginx.conf +++ b/docs/example.nginx.conf @@ -54,6 +54,7 @@ server { add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-XSS-Protection "1; mode=block"; add_header X-Content-Type-Options nosniff; + add_header Access-Control-Allow-Origin "*"; # add_header X-Frame-Options "SAMEORIGIN"; # Insert the path to your CryptPad repository root here diff --git a/lib/commands/admin-rpc.js b/lib/commands/admin-rpc.js index ed1d5b8a4..c60cdcfbc 100644 --- a/lib/commands/admin-rpc.js +++ b/lib/commands/admin-rpc.js @@ -1,4 +1,5 @@ /*jshint esversion: 6 */ +/* globals process */ const nThen = require("nthen"); const getFolderSize = require("get-folder-size"); const Util = require("../common-util"); @@ -50,6 +51,7 @@ var getCacheStats = function (env, server, cb) { metaSize: metaSize, channel: channelCount, channelSize: channelSize, + memoryUsage: process.memoryUsage(), }); }; diff --git a/lib/commands/channel.js b/lib/commands/channel.js index d35e15241..fce5b048c 100644 --- a/lib/commands/channel.js +++ b/lib/commands/channel.js @@ -54,16 +54,8 @@ Channel.clearOwnedChannel = function (Env, safeKey, channelId, cb, Server) { }); }; -Channel.removeOwnedChannel = function (Env, safeKey, channelId, cb, Server) { - if (typeof(channelId) !== 'string' || !Core.isValidId(channelId)) { - return cb('INVALID_ARGUMENTS'); - } +var archiveOwnedChannel = function (Env, safeKey, channelId, cb, Server) { var unsafeKey = Util.unescapeKeyCharacters(safeKey); - - if (Env.blobStore.isFileId(channelId)) { - return void Env.removeOwnedBlob(channelId, safeKey, cb); - } - Metadata.getMetadata(Env, channelId, function (err, metadata) { if (err) { return void cb(err); } if (!Core.hasOwners(metadata)) { return void cb('E_NO_OWNERS'); } @@ -124,6 +116,24 @@ Channel.removeOwnedChannel = function (Env, safeKey, channelId, cb, Server) { }); }; +Channel.removeOwnedChannel = function (Env, safeKey, channelId, __cb, Server) { + var _cb = Util.once(Util.mkAsync(__cb)); + + if (typeof(channelId) !== 'string' || !Core.isValidId(channelId)) { + return _cb('INVALID_ARGUMENTS'); + } + + // archiving large channels or files can be expensive, so do it one at a time + // for any given user to ensure that nobody can use too much of the server's resources + Env.queueDeletes(safeKey, function (next) { + var cb = Util.both(_cb, next); + if (Env.blobStore.isFileId(channelId)) { + return void Env.removeOwnedBlob(channelId, safeKey, cb); + } + archiveOwnedChannel(Env, safeKey, channelId, cb, Server); + }); +}; + Channel.trimHistory = function (Env, safeKey, data, cb) { if (!(data && typeof(data.channel) === 'string' && typeof(data.hash) === 'string' && data.hash.length === 64)) { return void cb('INVALID_ARGS'); diff --git a/lib/commands/pin-rpc.js b/lib/commands/pin-rpc.js index f3ade0489..a6f1642c6 100644 --- a/lib/commands/pin-rpc.js +++ b/lib/commands/pin-rpc.js @@ -49,16 +49,19 @@ var loadUserPins = function (Env, safeKey, cb) { // only put this into the cache if it completes session.channels = value; } - session.channels = value; done(value); }); }); }; var truthyKeys = function (O) { - return Object.keys(O).filter(function (k) { - return O[k]; - }); + try { + return Object.keys(O).filter(function (k) { + return O[k]; + }); + } catch (err) { + return []; + } }; var getChannelList = Pinning.getChannelList = function (Env, safeKey, _cb) { diff --git a/lib/historyKeeper.js b/lib/historyKeeper.js index f70dba006..ff0327751 100644 --- a/lib/historyKeeper.js +++ b/lib/historyKeeper.js @@ -31,13 +31,13 @@ module.exports.create = function (config, cb) { // and more easily share state between historyKeeper and rpc const Env = { Log: Log, - // tasks // store id: Crypto.randomBytes(8).toString('hex'), metadata_cache: {}, channel_cache: {}, queueStorage: WriteQueue(), + queueDeletes: WriteQueue(), batchIndexReads: BatchRead("HK_GET_INDEX"), batchMetadata: BatchRead('GET_METADATA'), @@ -98,7 +98,7 @@ module.exports.create = function (config, cb) { paths.staging = keyOrDefaultString('blobStagingPath', './blobstage'); paths.blob = keyOrDefaultString('blobPath', './blob'); - Env.defaultStorageLimit = typeof(config.defaultStorageLimit) === 'number' && config.defaultStorageLimit > 0? + Env.defaultStorageLimit = typeof(config.defaultStorageLimit) === 'number' && config.defaultStorageLimit >= 0? config.defaultStorageLimit: Core.DEFAULT_LIMIT; @@ -252,17 +252,14 @@ module.exports.create = function (config, cb) { channelExpirationMs: config.channelExpirationMs, verbose: config.verbose, openFileLimit: config.openFileLimit, + + maxWorkers: config.maxWorkers, }, w(function (err) { if (err) { throw new Error(err); } })); }).nThen(function (w) { - // create a task store (for scheduling tasks) - require("./storage/tasks").create(config, w(function (e, tasks) { - if (e) { throw e; } - Env.tasks = tasks; - })); if (config.disableIntegratedTasks) { return; } config.intervals = config.intervals || {}; diff --git a/lib/hk-util.js b/lib/hk-util.js index 0bb96a52b..0e79c0691 100644 --- a/lib/hk-util.js +++ b/lib/hk-util.js @@ -529,7 +529,7 @@ const handleFirstMessage = function (Env, channelName, metadata) { if(metadata.expire && typeof(metadata.expire) === 'number') { // the fun part... // the user has said they want this pad to expire at some point - Env.tasks.write(metadata.expire, "EXPIRE", [ channelName ], function (err) { + Env.writeTask(metadata.expire, "EXPIRE", [ channelName ], function (err) { if (err) { // if there is an error, we don't want to crash the whole server... // just log it, and if there's a problem you'll be able to fix it @@ -621,7 +621,10 @@ const handleGetHistory = function (Env, Server, seq, userId, parsed) { Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(msg)], readMore); }, (err) => { if (err && err.code !== 'ENOENT') { - if (err.message !== 'EINVAL') { Log.error("HK_GET_HISTORY", err); } + if (err.message !== 'EINVAL') { Log.error("HK_GET_HISTORY", { + err: err && err.message, + stack: err && err.stack, + }); } const parsedMsg = {error:err.message, channel: channelName, txid: txid}; Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(parsedMsg)]); return; @@ -669,6 +672,7 @@ const handleGetHistoryRange = function (Env, Server, seq, userId, parsed) { if (!Array.isArray(messages)) { messages = []; } + // FIXME this reduction could be done in the worker instead of the main process var toSend = []; if (typeof (desiredMessages) === "number") { toSend = messages.slice(-desiredMessages); diff --git a/lib/workers/crypto-worker.js b/lib/workers/crypto-worker.js deleted file mode 100644 index 5ed58ac7c..000000000 --- a/lib/workers/crypto-worker.js +++ /dev/null @@ -1,113 +0,0 @@ -/* jshint esversion: 6 */ -/* global process */ -const Nacl = require('tweetnacl/nacl-fast'); - -const COMMANDS = {}; - -COMMANDS.INLINE = function (data, cb) { - var signedMsg; - try { - signedMsg = Nacl.util.decodeBase64(data.msg); - } catch (e) { - return void cb('E_BAD_MESSAGE'); - } - - var validateKey; - try { - validateKey = Nacl.util.decodeBase64(data.key); - } catch (e) { - return void cb("E_BADKEY"); - } - // validate the message - const validated = Nacl.sign.open(signedMsg, validateKey); - if (!validated) { - return void cb("FAILED"); - } - cb(); -}; - -const checkDetachedSignature = function (signedMsg, signature, publicKey) { - if (!(signedMsg && publicKey)) { return false; } - - var signedBuffer; - var pubBuffer; - var signatureBuffer; - - try { - signedBuffer = Nacl.util.decodeUTF8(signedMsg); - } catch (e) { - throw new Error("INVALID_SIGNED_BUFFER"); - } - - try { - pubBuffer = Nacl.util.decodeBase64(publicKey); - } catch (e) { - throw new Error("INVALID_PUBLIC_KEY"); - } - - try { - signatureBuffer = Nacl.util.decodeBase64(signature); - } catch (e) { - throw new Error("INVALID_SIGNATURE"); - } - - if (pubBuffer.length !== 32) { - throw new Error("INVALID_PUBLIC_KEY_LENGTH"); - } - - if (signatureBuffer.length !== 64) { - throw new Error("INVALID_SIGNATURE_LENGTH"); - } - - if (Nacl.sign.detached.verify(signedBuffer, signatureBuffer, pubBuffer) !== true) { - throw new Error("FAILED"); - } -}; - -COMMANDS.DETACHED = function (data, cb) { - try { - checkDetachedSignature(data.msg, data.sig, data.key); - } catch (err) { - return void cb(err && err.message); - } - cb(); -}; - -COMMANDS.HASH_CHANNEL_LIST = function (data, cb) { - var channels = data.channels; - if (!Array.isArray(channels)) { return void cb('INVALID_CHANNEL_LIST'); } - var uniques = []; - - channels.forEach(function (a) { - if (uniques.indexOf(a) === -1) { uniques.push(a); } - }); - uniques.sort(); - - var hash = Nacl.util.encodeBase64(Nacl.hash(Nacl - .util.decodeUTF8(JSON.stringify(uniques)))); - - cb(void 0, hash); -}; - -process.on('message', function (data) { - if (!data || !data.txid) { - return void process.send({ - error:'E_INVAL' - }); - } - - const cb = function (err, value) { - process.send({ - txid: data.txid, - error: err, - value: value, - }); - }; - - const command = COMMANDS[data.command]; - if (typeof(command) !== 'function') { - return void cb("E_BAD_COMMAND"); - } - - command(data, cb); -}); diff --git a/lib/workers/db-worker.js b/lib/workers/db-worker.js index 0b9de4f53..0f3d3b87f 100644 --- a/lib/workers/db-worker.js +++ b/lib/workers/db-worker.js @@ -12,6 +12,7 @@ const Core = require("../commands/core"); const Saferphore = require("saferphore"); const Logger = require("../log"); const Tasks = require("../storage/tasks"); +const Nacl = require('tweetnacl/nacl-fast'); const Env = { Log: {}, @@ -418,6 +419,10 @@ const runTasks = function (data, cb) { Env.tasks.runAll(cb); }; +const writeTask = function (data, cb) { + Env.tasks.write(data.time, data.task_command, data.args, cb); +}; + const COMMANDS = { COMPUTE_INDEX: computeIndex, COMPUTE_METADATA: computeMetadata, @@ -430,6 +435,92 @@ const COMMANDS = { GET_HASH_OFFSET: getHashOffset, REMOVE_OWNED_BLOB: removeOwnedBlob, RUN_TASKS: runTasks, + WRITE_TASK: writeTask, +}; + +COMMANDS.INLINE = function (data, cb) { + var signedMsg; + try { + signedMsg = Nacl.util.decodeBase64(data.msg); + } catch (e) { + return void cb('E_BAD_MESSAGE'); + } + + var validateKey; + try { + validateKey = Nacl.util.decodeBase64(data.key); + } catch (e) { + return void cb("E_BADKEY"); + } + // validate the message + const validated = Nacl.sign.open(signedMsg, validateKey); + if (!validated) { + return void cb("FAILED"); + } + cb(); +}; + +const checkDetachedSignature = function (signedMsg, signature, publicKey) { + if (!(signedMsg && publicKey)) { return false; } + + var signedBuffer; + var pubBuffer; + var signatureBuffer; + + try { + signedBuffer = Nacl.util.decodeUTF8(signedMsg); + } catch (e) { + throw new Error("INVALID_SIGNED_BUFFER"); + } + + try { + pubBuffer = Nacl.util.decodeBase64(publicKey); + } catch (e) { + throw new Error("INVALID_PUBLIC_KEY"); + } + + try { + signatureBuffer = Nacl.util.decodeBase64(signature); + } catch (e) { + throw new Error("INVALID_SIGNATURE"); + } + + if (pubBuffer.length !== 32) { + throw new Error("INVALID_PUBLIC_KEY_LENGTH"); + } + + if (signatureBuffer.length !== 64) { + throw new Error("INVALID_SIGNATURE_LENGTH"); + } + + if (Nacl.sign.detached.verify(signedBuffer, signatureBuffer, pubBuffer) !== true) { + throw new Error("FAILED"); + } +}; + +COMMANDS.DETACHED = function (data, cb) { + try { + checkDetachedSignature(data.msg, data.sig, data.key); + } catch (err) { + return void cb(err && err.message); + } + cb(); +}; + +COMMANDS.HASH_CHANNEL_LIST = function (data, cb) { + var channels = data.channels; + if (!Array.isArray(channels)) { return void cb('INVALID_CHANNEL_LIST'); } + var uniques = []; + + channels.forEach(function (a) { + if (uniques.indexOf(a) === -1) { uniques.push(a); } + }); + uniques.sort(); + + var hash = Nacl.util.encodeBase64(Nacl.hash(Nacl + .util.decodeUTF8(JSON.stringify(uniques)))); + + cb(void 0, hash); }; process.on('message', function (data) { diff --git a/lib/workers/index.js b/lib/workers/index.js index 3190741d3..cc11d538b 100644 --- a/lib/workers/index.js +++ b/lib/workers/index.js @@ -3,103 +3,14 @@ const Util = require("../common-util"); const nThen = require('nthen'); const OS = require("os"); -const numCPUs = OS.cpus().length; const { fork } = require('child_process'); const Workers = module.exports; const PID = process.pid; -const CRYPTO_PATH = 'lib/workers/crypto-worker'; const DB_PATH = 'lib/workers/db-worker'; +const MAX_JOBS = 16; -Workers.initializeValidationWorkers = function (Env) { - if (typeof(Env.validateMessage) !== 'undefined') { - return void console.error("validation workers are already initialized"); - } - - // Create our workers - const workers = []; - for (let i = 0; i < numCPUs; i++) { - workers.push(fork(CRYPTO_PATH)); - } - - const response = Util.response(function (errLabel, info) { - Env.Log.error('HK_VALIDATE_WORKER__' + errLabel, info); - }); - - var initWorker = function (worker) { - worker.on('message', function (res) { - if (!res || !res.txid) { return; } - response.handle(res.txid, [res.error, res.value]); - }); - - var substituteWorker = Util.once( function () { - Env.Log.info("SUBSTITUTE_VALIDATION_WORKER", ''); - var idx = workers.indexOf(worker); - if (idx !== -1) { - workers.splice(idx, 1); - } - // Spawn a new one - var w = fork(CRYPTO_PATH); - workers.push(w); - initWorker(w); - }); - - // Spawn a new process in one ends - worker.on('exit', substituteWorker); - worker.on('close', substituteWorker); - worker.on('error', function (err) { - substituteWorker(); - Env.Log.error('VALIDATION_WORKER_ERROR', { - error: err, - }); - }); - }; - workers.forEach(initWorker); - - var nextWorker = 0; - const send = function (msg, _cb) { - var cb = Util.once(Util.mkAsync(_cb)); - // let's be paranoid about asynchrony and only calling back once.. - nextWorker = (nextWorker + 1) % workers.length; - if (workers.length === 0 || typeof(workers[nextWorker].send) !== 'function') { - return void cb("INVALID_WORKERS"); - } - - var txid = msg.txid = Util.uid(); - - // expect a response within 45s - response.expect(txid, cb, 60000); - - // Send the request - workers[nextWorker].send(msg); - }; - - Env.validateMessage = function (signedMsg, key, cb) { - send({ - msg: signedMsg, - key: key, - command: 'INLINE', - }, cb); - }; - - Env.checkSignature = function (signedMsg, signature, publicKey, cb) { - send({ - command: 'DETACHED', - sig: signature, - msg: signedMsg, - key: publicKey, - }, cb); - }; - - Env.hashChannelList = function (channels, cb) { - send({ - command: 'HASH_CHANNEL_LIST', - channels: channels, - }, cb); - }; -}; - -Workers.initializeIndexWorkers = function (Env, config, _cb) { +Workers.initialize = function (Env, config, _cb) { var cb = Util.once(Util.mkAsync(_cb)); const workers = []; @@ -124,16 +35,60 @@ Workers.initializeIndexWorkers = function (Env, config, _cb) { return response.expected(id)? guid(): id; }; - var workerIndex = 0; - var sendCommand = function (msg, _cb) { - var cb = Util.once(Util.mkAsync(_cb)); + var workerOffset = -1; + var queue = []; + var getAvailableWorkerIndex = function () { +// If there is already a backlog of tasks you can avoid some work +// by going to the end of the line + if (queue.length) { return -1; } + + var L = workers.length; + if (L === 0) { + Log.error('NO_WORKERS_AVAILABLE', { + queue: queue.length, + }); + return -1; + } - workerIndex = (workerIndex + 1) % workers.length; - if (!isWorker(workers[workerIndex])) { - return void cb("NO_WORKERS"); + // cycle through the workers once + // start from a different offset each time + // return -1 if none are available + + workerOffset = (workerOffset + 1) % L; + + var temp; + for (let i = 0; i < L; i++) { + temp = (workerOffset + i) % L; +/* I'd like for this condition to be more efficient + (`Object.keys` is sub-optimal) but I found some bugs in my initial + implementation stemming from a task counter variable going out-of-sync + with reality when a worker crashed and its tasks were re-assigned to + its substitute. I'm sure it can be done correctly and efficiently, + but this is a relatively easy way to make sure it's always up to date. + We'll see how it performs in practice before optimizing. +*/ + if (workers[temp] && Object.keys(workers[temp]).length < MAX_JOBS) { + return temp; + } } + return -1; + }; - var state = workers[workerIndex]; + var sendCommand = function (msg, _cb) { + var index = getAvailableWorkerIndex(); + + var state = workers[index]; + // if there is no worker available: + if (!isWorker(state)) { + // queue the message for when one becomes available + queue.push({ + msg: msg, + cb: _cb, + }); + return; + } + + var cb = Util.once(Util.mkAsync(_cb)); const txid = guid(); msg.txid = txid; @@ -141,14 +96,42 @@ Workers.initializeIndexWorkers = function (Env, config, _cb) { // track which worker is doing which jobs state.tasks[txid] = msg; - response.expect(txid, function (err, value) { - // clean up when you get a response - delete state[txid]; - cb(err, value); - }, 60000); + + response.expect(txid, cb, 60000); state.worker.send(msg); }; + var handleResponse = function (state, res) { + if (!res) { return; } + // handle log messages before checking if it was addressed to your PID + // it might still be useful to know what happened inside an orphaned worker + if (res.log) { + return void handleLog(res.log, res.label, res.info); + } + // but don't bother handling things addressed to other processes + // since it's basically guaranteed not to work + if (res.pid !== PID) { + return void Log.error("WRONG_PID", res); + } + + if (!res.txid) { return; } + response.handle(res.txid, [res.error, res.value]); + delete state.tasks[res.txid]; + if (!queue.length) { return; } + + var nextMsg = queue.shift(); +/* `nextMsg` was at the top of the queue. + We know that a job just finished and all of this code + is synchronous, so calling `sendCommand` should take the worker + which was just freed up. This is somewhat fragile though, so + be careful if you want to modify this block. The risk is that + we take something that was at the top of the queue and push it + to the back because the following msg took its place. OR, in an + even worse scenario, we cycle through the queue but don't run anything. +*/ + sendCommand(nextMsg.msg, nextMsg.cb); + }; + const initWorker = function (worker, cb) { const txid = guid(); @@ -170,19 +153,7 @@ Workers.initializeIndexWorkers = function (Env, config, _cb) { }); worker.on('message', function (res) { - if (!res) { return; } - // handle log messages before checking if it was addressed to your PID - // it might still be useful to know what happened inside an orphaned worker - if (res.log) { - return void handleLog(res.log, res.label, res.info); - } - // but don't bother handling things addressed to other processes - // since it's basically guaranteed not to work - if (res.pid !== PID) { - return void Log.error("WRONG_PID", res); - } - - response.handle(res.txid, [res.error, res.value]); + handleResponse(state, res); }); var substituteWorker = Util.once(function () { @@ -222,7 +193,32 @@ Workers.initializeIndexWorkers = function (Env, config, _cb) { }; nThen(function (w) { - OS.cpus().forEach(function () { + const max = config.maxWorkers; + + var limit; + if (typeof(max) !== 'undefined') { + // the admin provided a limit on the number of workers + if (typeof(max) === 'number' && !isNaN(max)) { + if (max < 1) { + Log.info("INSUFFICIENT_MAX_WORKERS", max); + limit = 1; + } + } else { + Log.error("INVALID_MAX_WORKERS", '[' + max + ']'); + } + } + + var logged; + + OS.cpus().forEach(function (cpu, index) { + if (limit && index >= limit) { + if (!logged) { + logged = true; + Log.info('WORKER_LIMIT', "(Opting not to use available CPUs beyond " + index + ')'); + } + return; + } + initWorker(fork(DB_PATH), w(function (err) { if (!err) { return; } w.abort(); @@ -327,11 +323,42 @@ Workers.initializeIndexWorkers = function (Env, config, _cb) { }, cb); }; + Env.writeTask = function (time, command, args, cb) { + sendCommand({ + command: 'WRITE_TASK', + time: time, + task_command: command, + args: args, + }, cb); + }; + + // Synchronous crypto functions + Env.validateMessage = function (signedMsg, key, cb) { + sendCommand({ + msg: signedMsg, + key: key, + command: 'INLINE', + }, cb); + }; + + Env.checkSignature = function (signedMsg, signature, publicKey, cb) { + sendCommand({ + command: 'DETACHED', + sig: signature, + msg: signedMsg, + key: publicKey, + }, cb); + }; + + Env.hashChannelList = function (channels, cb) { + sendCommand({ + command: 'HASH_CHANNEL_LIST', + channels: channels, + }, cb); + }; + cb(void 0); }); }; -Workers.initialize = function (Env, config, cb) { - Workers.initializeValidationWorkers(Env); - Workers.initializeIndexWorkers(Env, config, cb); -}; + diff --git a/www/code/inner.js b/www/code/inner.js index 408e78c45..09158d952 100644 --- a/www/code/inner.js +++ b/www/code/inner.js @@ -281,6 +281,7 @@ define([ if (MEDIA_TAG_MODES.indexOf(mode) !== -1) { // Embedding is endabled framework.setMediaTagEmbedder(function (mt) { + editor.focus(); editor.replaceSelection($(mt)[0].outerHTML); }); } else { diff --git a/www/common/common-interface.js b/www/common/common-interface.js index 4c4518434..b2cd042a8 100644 --- a/www/common/common-interface.js +++ b/www/common/common-interface.js @@ -196,6 +196,7 @@ define([ ]); var $frame = $(frame); frame.closeModal = function (cb) { + frame.closeModal = function () {}; // Prevent further calls $frame.fadeOut(150, function () { $frame.detach(); if (typeof(cb) === "function") { cb(); } @@ -464,16 +465,23 @@ define([ UI.createModal = function (cfg) { var $body = cfg.$body || $('body'); - var $blockContainer = $body.find('#'+cfg.id); - if (!$blockContainer.length) { - $blockContainer = $(h('div.cp-modal-container#'+cfg.id, { + var $blockContainer = cfg.id && $body.find('#'+cfg.id); + if (!$blockContainer || !$blockContainer.length) { + var id = ''; + if (cfg.id) { id = '#'+cfg.id; } + $blockContainer = $(h('div.cp-modal-container'+id, { tabindex: 1 })); } + var deleted = false; var hide = function () { - if (cfg.onClose) { return void cfg.onClose(); } + if (deleted) { return; } $blockContainer.hide(); - if (cfg.onClosed) { cfg.onClosed(); } + if (!cfg.id) { + deleted = true; + $blockContainer.remove(); + } + if (cfg.onClose) { cfg.onClose(); } }; $blockContainer.html('').appendTo($body); var $block = $(h('div.cp-modal')).appendTo($blockContainer); diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index a9c682afc..75ee78c15 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -175,9 +175,9 @@ define([ var common = config.common; var sframeChan = common.getSframeChannel(); var title = config.title; - var friends = config.friends; + var friends = config.friends || {}; + var teams = config.teams || {}; var myName = common.getMetadataMgr().getUserData().name; - if (!friends) { return; } var order = []; var smallCurves = Object.keys(friends).map(function (c) { @@ -206,40 +206,28 @@ define([ delete friends[curve]; }); - var friendsList = UIElements.getUserGrid(Messages.share_linkFriends, { - common: common, - data: friends, - noFilter: false, - large: true - }, refreshButtons); - var friendDiv = friendsList.div; - $div.append(friendDiv); - var others = friendsList.icons; + var others = []; + if (Object.keys(friends).length) { + var friendsList = UIElements.getUserGrid(Messages.share_linkFriends, { + common: common, + data: friends, + noFilter: false, + large: true + }, refreshButtons); + var friendDiv = friendsList.div; + $div.append(friendDiv); + others = friendsList.icons; + } - var privateData = common.getMetadataMgr().getPrivateData(); - var teamsData = Util.tryParse(JSON.stringify(privateData.teams)) || {}; - var teams = {}; - Object.keys(teamsData).forEach(function (id) { - // config.teamId only exists when we're trying to share a pad from a team drive - // In this case, we don't want to share the pad with the current team - if (config.teamId && config.teamId === id) { return; } - if (!teamsData[id].hasSecondaryKey) { return; } - var t = teamsData[id]; - teams[t.edPublic] = { - notifications: true, - displayName: t.name, - edPublic: t.edPublic, - avatar: t.avatar, - id: id - }; - }); - var teamsList = UIElements.getUserGrid(Messages.share_linkTeam, { - common: common, - noFilter: true, - large: true, - data: teams - }, refreshButtons); - $div.append(teamsList.div); + if (Object.keys(teams).length) { + var teamsList = UIElements.getUserGrid(Messages.share_linkTeam, { + common: common, + noFilter: true, + large: true, + data: teams + }, refreshButtons); + $div.append(teamsList.div); + } var shareButton = { className: 'primary cp-share-with-friends', @@ -395,6 +383,26 @@ define([ } }; + var getEditableTeams = function (common, config) { + var privateData = common.getMetadataMgr().getPrivateData(); + var teamsData = Util.tryParse(JSON.stringify(privateData.teams)) || {}; + var teams = {}; + Object.keys(teamsData).forEach(function (id) { + // config.teamId only exists when we're trying to share a pad from a team drive + // In this case, we don't want to share the pad with the current team + if (config.teamId && config.teamId === id) { return; } + if (!teamsData[id].hasSecondaryKey) { return; } + var t = teamsData[id]; + teams[t.edPublic] = { + notifications: true, + displayName: t.name, + edPublic: t.edPublic, + avatar: t.avatar, + id: id + }; + }); + return teams; + }; var makeBurnAfterReadingUrl = function (common, href, channel, cb) { var keyPair = Hash.generateSignPair(); var parsed = Hash.parsePadUrl(href); @@ -643,7 +651,10 @@ define([ // Share with contacts tab - var hasFriends = Object.keys(config.friends || {}).length !== 0; + var teams = getEditableTeams(common, config); + config.teams = teams; + var hasFriends = Object.keys(config.friends || {}).length || + Object.keys(teams).length; var onFriendShare = Util.mkEvent(); @@ -926,7 +937,10 @@ define([ }); // share with contacts tab - var hasFriends = Object.keys(config.friends || {}).length !== 0; + var teams = getEditableTeams(common, config); + config.teams = teams; + var hasFriends = Object.keys(config.friends || {}).length || + Object.keys(teams).length; var friendsObject = hasFriends ? createShareWithFriends(config, null, getLinkValue) : noContactsMessage(common); var friendsList = friendsObject.content; @@ -1592,11 +1606,7 @@ define([ .text(Messages.accessButton)) .click(common.prepareFeedback(type)) .click(function () { - require(['/common/inner/access.js'], function (Access) { - Access.getAccessModal(common, {}, function (e) { - if (e) { console.error(e); } - }); - }); + sframeChan.event('EV_ACCESS_OPEN'); }); break; case 'properties': @@ -1611,11 +1621,7 @@ define([ if (!data) { return void UI.alert(Messages.autostore_notAvailable); } - require(['/common/inner/properties.js'], function (Properties) { - Properties.getPropertiesModal(common, {}, function (e) { - if (e) { console.error(e); } - }); - }); + sframeChan.event('EV_PROPERTIES_OPEN'); }); }); break; @@ -2628,17 +2634,13 @@ define([ }); }; - UIElements.initFilePicker = function (common, cfg) { - var onSelect = cfg.onSelect || $.noop; + UIElements.openFilePicker = function (common, types, cb) { var sframeChan = common.getSframeChannel(); - sframeChan.on("EV_FILE_PICKED", function (data) { - onSelect(data); + sframeChan.query("Q_FILE_PICKER_OPEN", types, function (err, data) { + if (err) { return; } + cb(data); }); }; - UIElements.openFilePicker = function (common, types) { - var sframeChan = common.getSframeChannel(); - sframeChan.event("EV_FILE_PICKER_OPEN", types); - }; UIElements.openTemplatePicker = function (common, force) { var metadataMgr = common.getMetadataMgr(); @@ -2661,29 +2663,25 @@ define([ return; } delete pickerCfg.hidden; - common.openFilePicker(pickerCfg); var first = true; // We can only pick a template once (for a new document) - var fileDialogCfg = { - onSelect: function (data) { - if (data.type === type && first) { - UI.addLoadingScreen({hideTips: true}); - var chatChan = common.getPadChat(); - var cursorChan = common.getCursorChannel(); - sframeChan.query('Q_TEMPLATE_USE', { - href: data.href, - chat: chatChan, - cursor: cursorChan - }, function () { - first = false; - UI.removeLoadingScreen(); - Feedback.send('TEMPLATE_USED'); - }); - if (focus) { focus.focus(); } - return; - } + common.openFilePicker(pickerCfg, function (data) { + if (data.type === type && first) { + UI.addLoadingScreen({hideTips: true}); + var chatChan = common.getPadChat(); + var cursorChan = common.getCursorChannel(); + sframeChan.query('Q_TEMPLATE_USE', { + href: data.href, + chat: chatChan, + cursor: cursorChan + }, function () { + first = false; + UI.removeLoadingScreen(); + Feedback.send('TEMPLATE_USED'); + }); + if (focus) { focus.focus(); } + return; } - }; - common.initFilePicker(fileDialogCfg); + }); }; sframeChan.query("Q_TEMPLATE_EXIST", type, function (err, data) { diff --git a/www/common/diffMarked.js b/www/common/diffMarked.js index f53828e17..8fc8aab6e 100644 --- a/www/common/diffMarked.js +++ b/www/common/diffMarked.js @@ -23,8 +23,17 @@ define([ init: function () {} }; + var mermaidThemeCSS = ".node rect { fill: #DDD; stroke: #AAA; } " + + "rect.task, rect.task0, rect.task2 { stroke-width: 1 !important; rx: 0 !important; } " + + "g.grid g.tick line { opacity: 0.25; }" + + "g.today line { stroke: red; stroke-width: 1; stroke-dasharray: 3; opacity: 0.5; }"; + require(['mermaid', 'css!/code/mermaid-new.css'], function (_Mermaid) { Mermaid = _Mermaid; + Mermaid.initialize({ + gantt: { axisFormat: '%m-%d', }, + "themeCSS": mermaidThemeCSS, + }); }); var highlighter = function () { @@ -351,6 +360,12 @@ define([ // retrieve the attached source code which it was drawn var src = el.getAttribute('mermaid-source'); +/* The new source might have syntax errors that will prevent rendering. + It might be preferable to keep the existing state instead of removing it + if you don't have something better to display. Ideally we should display + the cause of the syntax error so that the user knows what to correct. */ + //if (!Mermaid.parse(src)) { } // TODO + // check if that source exists in the set of charts which are about to be rendered if (mermaid_source.indexOf(src) === -1) { // if it's not, then you can remove it @@ -418,7 +433,7 @@ define([ throw new Error(patch); } else { DD.apply($content[0], patch); - var $mts = $content.find('media-tag:not(:has(*))'); + var $mts = $content.find('media-tag'); $mts.each(function (i, el) { var $mt = $(el).contextmenu(function (e) { e.preventDefault(); @@ -426,6 +441,16 @@ define([ $(contextMenu.menu).find('li').show(); contextMenu.show(e); }); + if ($mt.children().length) { + $mt.off('dblclick preview'); + $mt.on('preview', onPreview($mt)); + if ($mt.find('img').length) { + $mt.on('dblclick', function () { + $mt.trigger('preview'); + }); + } + return; + } MediaTag(el); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { diff --git a/www/common/inner/common-modal.js b/www/common/inner/common-modal.js index ee475c028..33ac14d9f 100644 --- a/www/common/inner/common-modal.js +++ b/www/common/inner/common-modal.js @@ -59,7 +59,7 @@ define([ }), opts.href); // If this is a file, don't try to look for metadata - if (opts.channel && opts.channel.length > 34) { return; } + if (opts.channel && opts.channel.length > 32) { return; } if (opts.channel) { data.channel = opts.channel; } Modal.loadMetadata(Env, data, waitFor); }).nThen(function () { @@ -129,7 +129,10 @@ define([ tabs[i] = { content: c && UI.dialog.customModal(node, { buttons: obj.buttons || button, - onClose: function () { blocked = false; } + onClose: function () { + blocked = false; + if (typeof(opts.onClose) === "function") { opts.onClose(); } + } }), disabled: !c, title: obj.title, diff --git a/www/common/onlyoffice/inner.js b/www/common/onlyoffice/inner.js index 5ac3da269..20125d82c 100644 --- a/www/common/onlyoffice/inner.js +++ b/www/common/onlyoffice/inner.js @@ -132,6 +132,12 @@ define([ APP.onLocal(); }; + var isRegisteredUserOnline = function () { + var users = metadataMgr.getMetadata().users || {}; + return Object.keys(users).some(function (id) { + return users[id] && users[id].curvePublic; + }); + }; var isUserOnline = function (ooid) { // Remove ids for users that have left the channel deleteOffline(); @@ -348,16 +354,44 @@ define([ fixSheets(); APP.FM.handleFile(blob, data); }; + + Messages.oo_login = 'Log in...'; // XXX + var noLogin = false; + var makeCheckpoint = function (force) { - if (!common.isLoggedIn()) { return; } var locked = content.saveLock; var lastCp = getLastCp(); var needCp = force || ooChannel.cpIndex % CHECKPOINT_INTERVAL === 0 || - (ooChannel.cpIndex - lastCp.index) > CHECKPOINT_INTERVAL; + (ooChannel.cpIndex - (lastCp.index || 0)) > CHECKPOINT_INTERVAL; if (!needCp) { return; } if (!locked || !isUserOnline(locked) || force) { + if (!common.isLoggedIn() && !isRegisteredUserOnline() && !noLogin) { + var login = h('button.cp-corner-primary', Messages.login_login); + var register = h('button.cp-corner-primary', Messages.login_register); + var cancel = h('button.cp-corner-cancel', Messages.cancel); + var actions = h('div', [cancel, register, login]); + var modal = UI.cornerPopup(Messages.oo_login, actions, '', {alt: true}); + $(register).click(function () { + common.setLoginRedirect(function () { + common.gotoURL('/register/'); + }); + modal.delete(); + }); + $(login).click(function () { + common.setLoginRedirect(function () { + common.gotoURL('/login/'); + }); + modal.delete(); + }); + $(cancel).click(function () { + modal.delete(); + noLogin = true; + }); + return; + } + if (!common.isLoggedIn()) { return; } content.saveLock = myOOId; APP.onLocal(); APP.realtime.onSettle(function () { @@ -907,8 +941,16 @@ define([ if (ifr) { ifr.remove(); } }; - common.initFilePicker({ - onSelect: function (data) { + APP.AddImage = function(cb1, cb2) { + APP.AddImageSuccessCallback = cb1; + APP.AddImageErrorCallback = cb2; + common.openFilePicker({ + types: ['file'], + where: ['root'], + filter: { + fileType: ['image/'] + } + }, function (data) { if (data.type !== 'file') { debug("Unexpected data type picked " + data.type); return; @@ -929,19 +971,6 @@ define([ }); }); }); - } - }); - - APP.AddImage = function(cb1, cb2) { - APP.AddImageSuccessCallback = cb1; - APP.AddImageErrorCallback = cb2; - common.openFilePicker({ - types: ['file'], - where: ['root'], - filter: { - fileType: ['image/'] - } - }); }; @@ -1091,12 +1120,12 @@ define([ var x2tSaveAndConvertData = function(data, filename, extension, finalFilename) { // Perform the x2t conversion - require(['/common/onlyoffice/x2t/x2t.js'], function() { + require(['/common/onlyoffice/x2t/x2t.js'], function() { // XXX why does this fail without an access-control-allow-origin header? var x2t = window.Module; x2t.run(); if (x2tInitialized) { debug("x2t runtime already initialized"); - x2tSaveAndConvertDataInternal(x2t, data, filename, extension, finalFilename); + x2tSaveAndConvertDataInternal(x2t, data, filename, extension, finalFilename); // XXX shouldn't this return ? } x2t.onRuntimeInitialized = function() { diff --git a/www/common/outer/async-store.js b/www/common/outer/async-store.js index 604c799f3..c8de4f787 100644 --- a/www/common/outer/async-store.js +++ b/www/common/outer/async-store.js @@ -675,6 +675,8 @@ define([ sem.take(function (give) { var otherOwners = false; nThen(function (_w) { + // Don't check server metadata for blobs + if (c.length !== 32) { return; } Store.anonRpcMsg(null, { msg: 'GET_METADATA', data: c @@ -1807,6 +1809,7 @@ define([ var cb = Util.once(Util.mkAsync(_cb)); if (!data.channel) { return void cb({ error: 'ENOTFOUND'}); } + if (data.channel.length !== 32) { return void cb({ error: 'EINVAL'}); } store.anon_rpc.send('GET_METADATA', data.channel, function (err, obj) { if (err) { return void cb({error: err}); } var metadata = (obj && obj[0]) || {}; diff --git a/www/common/outer/team.js b/www/common/outer/team.js index a2dbf7c05..c151de004 100644 --- a/www/common/outer/team.js +++ b/www/common/outer/team.js @@ -678,6 +678,8 @@ define([ sem.take(function (give) { var otherOwners = false; nThen(function (_w) { + // Don't check server metadata for blobs + if (c.length !== 32) { return; } ctx.Store.anonRpcMsg(null, { msg: 'GET_METADATA', data: c diff --git a/www/common/sframe-app-framework.js b/www/common/sframe-app-framework.js index e3adfd6c7..24e7d0abd 100644 --- a/www/common/sframe-app-framework.js +++ b/www/common/sframe-app-framework.js @@ -503,8 +503,13 @@ define([ var createFilePicker = function () { if (!common.isLoggedIn()) { return; } - common.initFilePicker({ - onSelect: function (data) { + $embedButton = common.createButton('mediatag', true).click(function () { + var cfg = { + types: ['file'], + where: ['root'] + }; + if ($embedButton.data('filter')) { cfg.filter = $embedButton.data('filter'); } + common.openFilePicker(cfg, function (data) { if (data.type !== 'file') { console.log("Unexpected data type picked " + data.type); return; @@ -516,17 +521,7 @@ define([ var src = data.src = data.src.slice(0,1) === '/' ? origin + data.src : data.src; mediaTagEmbedder($(''), data); - } - }); - $embedButton = common.createButton('mediatag', true).click(function () { - var cfg = { - types: ['file'], - where: ['root'] - }; - if ($embedButton.data('filter')) { - cfg.filter = $embedButton.data('filter'); - } - common.openFilePicker(cfg); + }); }).appendTo(toolbar.$rightside).hide(); }; var setMediaTagEmbedder = function (mte, filter) { diff --git a/www/common/sframe-common-outer.js b/www/common/sframe-common-outer.js index b781ac95e..0971300b6 100644 --- a/www/common/sframe-common-outer.js +++ b/www/common/sframe-common-outer.js @@ -18,8 +18,7 @@ define([ var Cryptget; var SFrameChannel; var sframeChan; - var FilePicker; - var Share; + var SecureIframe; var Messaging; var Notifier; var Utils = { @@ -44,8 +43,7 @@ define([ '/bower_components/chainpad-crypto/crypto.js', '/common/cryptget.js', '/common/outer/worker-channel.js', - '/filepicker/main.js', - '/share/main.js', + '/secureiframe/main.js', '/common/common-messaging.js', '/common/common-notifier.js', '/common/common-hash.js', @@ -58,15 +56,14 @@ define([ '/common/test.js', '/common/userObject.js', ], waitFor(function (_CpNfOuter, _Cryptpad, _Crypto, _Cryptget, _SFrameChannel, - _FilePicker, _Share, _Messaging, _Notifier, _Hash, _Util, _Realtime, + _SecureIframe, _Messaging, _Notifier, _Hash, _Util, _Realtime, _Constants, _Feedback, _LocalStore, _AppConfig, _Test, _UserObject) { CpNfOuter = _CpNfOuter; Cryptpad = _Cryptpad; Crypto = Utils.Crypto = _Crypto; Cryptget = _Cryptget; SFrameChannel = _SFrameChannel; - FilePicker = _FilePicker; - Share = _Share; + SecureIframe = _SecureIframe; Messaging = _Messaging; Notifier = _Notifier; Utils.Hash = _Hash; @@ -491,7 +488,7 @@ define([ // Put in the following function the RPC queries that should also work in filepicker - var addCommonRpc = function (sframeChan) { + var addCommonRpc = function (sframeChan, safe) { sframeChan.on('Q_ANON_RPC_MESSAGE', function (data, cb) { Cryptpad.anonRpcMsg(data.msg, data.content, function (err, response) { cb({error: err, response: response}); @@ -598,6 +595,12 @@ define([ } if (data.href) { href = data.href; } Cryptpad.getPadAttribute(data.key, function (e, data) { + if (!safe && data) { + // Remove unsafe data for the unsafe iframe + delete data.href; + delete data.roHref; + delete data.password; + } cb({ error: e, data: data @@ -985,81 +988,63 @@ define([ onFileUpload(sframeChan, data, cb); }); - // File picker - var FP = {}; - var initFilePicker = function (cfg) { - // cfg.hidden means pre-loading the filepicker while keeping it hidden. + // Secure modal + var SecureModal = {}; + // Create or display the iframe and modal + var initSecureModal = function (type, cfg, cb) { + cfg.modal = type; + SecureModal.cb = cb; + // cfg.hidden means pre-loading the iframe while keeping it hidden. // if cfg.hidden is true and the iframe already exists, do nothing - if (!FP.$iframe) { + if (!SecureModal.$iframe) { var config = {}; - config.onFilePicked = function (data) { - sframeChan.event('EV_FILE_PICKED', data); + config.onAction = function (data) { + if (typeof(SecureModal.cb) !== "function") { return; } + SecureModal.cb(data); + SecureModal.$iframe.hide(); }; config.onClose = function () { - FP.$iframe.hide(); - }; - config.onFileUpload = onFileUpload; - config.types = cfg; - config.addCommonRpc = addCommonRpc; - config.modules = { - Cryptpad: Cryptpad, - SFrameChannel: SFrameChannel, - Utils: Utils + SecureModal.$iframe.hide(); }; - FP.$iframe = $('