Merge branch 'staging' into communities-comments

pull/1/head
yflory 5 years ago
commit 3f2d21fd71

@ -89,6 +89,14 @@ module.exports = {
*/ */
//httpSafePort: 3001, //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 * Admin
* ===================== */ * ===================== */

@ -11,6 +11,8 @@ CKEDITOR.editorConfig = function( config ) {
config.removePlugins= 'resize,elementspath'; config.removePlugins= 'resize,elementspath';
config.resize_enabled= false; //bottom-bar config.resize_enabled= false; //bottom-bar
config.extraPlugins= 'autolink,colorbutton,colordialog,font,indentblock,justify,mediatag,print,blockbase64,mathjax,wordcount,comments'; config.extraPlugins= 'autolink,colorbutton,colordialog,font,indentblock,justify,mediatag,print,blockbase64,mathjax,wordcount,comments';
// FIXME translation for default? updating to a newer CKEditor seems like it will add 'default' by default
config.fontSize_sizes = '(Default)/unset;8/8px;9/9px;10/10px;11/11px;12/12px;14/14px;16/16px;18/18px;20/20px;22/22px;24/24px;26/26px;28/28px;36/36px;48/48px;72/72px';
config.toolbarGroups= [ config.toolbarGroups= [
// {"name":"clipboard","groups":["clipboard","undo"]}, // {"name":"clipboard","groups":["clipboard","undo"]},
//{"name":"editing","groups":["find","selection"]}, //{"name":"editing","groups":["find","selection"]},

@ -107,7 +107,7 @@ define([
])*/ ])*/
]) ])
]), ]),
h('div.cp-version-footer', "CryptPad v3.15.0 (PigFootedBandicoot)") h('div.cp-version-footer', "CryptPad v3.16.0 (Quagga)")
]); ]);
}; };

@ -1179,6 +1179,7 @@
&.fa-download { order: 2; } &.fa-download { order: 2; }
&.fa-upload { order: 3; } &.fa-upload { order: 3; }
&.fa-print { order: 4; } &.fa-print { order: 4; }
&.fa-arrows-h { order: 5; }
&.fa-cog { order: 5; } &.fa-cog { order: 5; }
&.fa-info-circle { order: 6; } &.fa-info-circle { order: 6; }
&.fa-help { order: 7; } &.fa-help { order: 7; }

@ -54,6 +54,7 @@ server {
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-XSS-Protection "1; mode=block"; add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options nosniff; add_header X-Content-Type-Options nosniff;
add_header Access-Control-Allow-Origin "*";
# add_header X-Frame-Options "SAMEORIGIN"; # add_header X-Frame-Options "SAMEORIGIN";
# Insert the path to your CryptPad repository root here # Insert the path to your CryptPad repository root here

@ -1,4 +1,5 @@
/*jshint esversion: 6 */ /*jshint esversion: 6 */
/* globals process */
const nThen = require("nthen"); const nThen = require("nthen");
const getFolderSize = require("get-folder-size"); const getFolderSize = require("get-folder-size");
const Util = require("../common-util"); const Util = require("../common-util");
@ -50,6 +51,7 @@ var getCacheStats = function (env, server, cb) {
metaSize: metaSize, metaSize: metaSize,
channel: channelCount, channel: channelCount,
channelSize: channelSize, channelSize: channelSize,
memoryUsage: process.memoryUsage(),
}); });
}; };

@ -54,16 +54,8 @@ Channel.clearOwnedChannel = function (Env, safeKey, channelId, cb, Server) {
}); });
}; };
Channel.removeOwnedChannel = function (Env, safeKey, channelId, cb, Server) { var archiveOwnedChannel = function (Env, safeKey, channelId, cb, Server) {
if (typeof(channelId) !== 'string' || !Core.isValidId(channelId)) {
return cb('INVALID_ARGUMENTS');
}
var unsafeKey = Util.unescapeKeyCharacters(safeKey); var unsafeKey = Util.unescapeKeyCharacters(safeKey);
if (Env.blobStore.isFileId(channelId)) {
return void Env.removeOwnedBlob(channelId, safeKey, cb);
}
Metadata.getMetadata(Env, channelId, function (err, metadata) { Metadata.getMetadata(Env, channelId, function (err, metadata) {
if (err) { return void cb(err); } if (err) { return void cb(err); }
if (!Core.hasOwners(metadata)) { return void cb('E_NO_OWNERS'); } 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) { Channel.trimHistory = function (Env, safeKey, data, cb) {
if (!(data && typeof(data.channel) === 'string' && typeof(data.hash) === 'string' && data.hash.length === 64)) { if (!(data && typeof(data.channel) === 'string' && typeof(data.hash) === 'string' && data.hash.length === 64)) {
return void cb('INVALID_ARGS'); return void cb('INVALID_ARGS');

@ -49,16 +49,19 @@ var loadUserPins = function (Env, safeKey, cb) {
// only put this into the cache if it completes // only put this into the cache if it completes
session.channels = value; session.channels = value;
} }
session.channels = value;
done(value); done(value);
}); });
}); });
}; };
var truthyKeys = function (O) { var truthyKeys = function (O) {
return Object.keys(O).filter(function (k) { try {
return O[k]; return Object.keys(O).filter(function (k) {
}); return O[k];
});
} catch (err) {
return [];
}
}; };
var getChannelList = Pinning.getChannelList = function (Env, safeKey, _cb) { var getChannelList = Pinning.getChannelList = function (Env, safeKey, _cb) {

@ -31,13 +31,13 @@ module.exports.create = function (config, cb) {
// and more easily share state between historyKeeper and rpc // and more easily share state between historyKeeper and rpc
const Env = { const Env = {
Log: Log, Log: Log,
// tasks
// store // store
id: Crypto.randomBytes(8).toString('hex'), id: Crypto.randomBytes(8).toString('hex'),
metadata_cache: {}, metadata_cache: {},
channel_cache: {}, channel_cache: {},
queueStorage: WriteQueue(), queueStorage: WriteQueue(),
queueDeletes: WriteQueue(),
batchIndexReads: BatchRead("HK_GET_INDEX"), batchIndexReads: BatchRead("HK_GET_INDEX"),
batchMetadata: BatchRead('GET_METADATA'), batchMetadata: BatchRead('GET_METADATA'),
@ -98,7 +98,7 @@ module.exports.create = function (config, cb) {
paths.staging = keyOrDefaultString('blobStagingPath', './blobstage'); paths.staging = keyOrDefaultString('blobStagingPath', './blobstage');
paths.blob = keyOrDefaultString('blobPath', './blob'); 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: config.defaultStorageLimit:
Core.DEFAULT_LIMIT; Core.DEFAULT_LIMIT;
@ -252,17 +252,14 @@ module.exports.create = function (config, cb) {
channelExpirationMs: config.channelExpirationMs, channelExpirationMs: config.channelExpirationMs,
verbose: config.verbose, verbose: config.verbose,
openFileLimit: config.openFileLimit, openFileLimit: config.openFileLimit,
maxWorkers: config.maxWorkers,
}, w(function (err) { }, w(function (err) {
if (err) { if (err) {
throw new Error(err); throw new Error(err);
} }
})); }));
}).nThen(function (w) { }).nThen(function () {
// 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; } if (config.disableIntegratedTasks) { return; }
config.intervals = config.intervals || {}; config.intervals = config.intervals || {};

@ -529,7 +529,7 @@ const handleFirstMessage = function (Env, channelName, metadata) {
if(metadata.expire && typeof(metadata.expire) === 'number') { if(metadata.expire && typeof(metadata.expire) === 'number') {
// the fun part... // the fun part...
// the user has said they want this pad to expire at some point // 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 (err) {
// if there is an error, we don't want to crash the whole server... // 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 // 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); Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(msg)], readMore);
}, (err) => { }, (err) => {
if (err && err.code !== 'ENOENT') { 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}; const parsedMsg = {error:err.message, channel: channelName, txid: txid};
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(parsedMsg)]); Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(parsedMsg)]);
return; return;
@ -662,30 +665,17 @@ const handleGetHistoryRange = function (Env, Server, seq, userId, parsed) {
} }
Server.send(userId, [seq, 'ACK']); Server.send(userId, [seq, 'ACK']);
Env.getOlderHistory(channelName, oldestKnownHash, function (err, messages) { Env.getOlderHistory(channelName, oldestKnownHash, desiredMessages, desiredCheckpoint, function (err, toSend) {
if (err && err.code !== 'ENOENT') { if (err && err.code !== 'ENOENT') {
Env.Log.error("HK_GET_OLDER_HISTORY", err); Env.Log.error("HK_GET_OLDER_HISTORY", err);
} }
if (!Array.isArray(messages)) { messages = []; } if (Array.isArray(toSend)) {
toSend.forEach(function (msg) {
var toSend = []; Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId,
if (typeof (desiredMessages) === "number") { JSON.stringify(['HISTORY_RANGE', txid, msg])]);
toSend = messages.slice(-desiredMessages); });
} else {
let cpCount = 0;
for (var i = messages.length - 1; i >= 0; i--) {
if (/^cp\|/.test(messages[i][4]) && i !== (messages.length - 1)) {
cpCount++;
}
toSend.unshift(messages[i]);
if (cpCount >= desiredCheckpoint) { break; }
}
} }
toSend.forEach(function (msg) {
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId,
JSON.stringify(['HISTORY_RANGE', txid, msg])]);
});
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId,
JSON.stringify(['HISTORY_RANGE_END', txid, channelName]) JSON.stringify(['HISTORY_RANGE_END', txid, channelName])

@ -7,6 +7,9 @@ const Path = require("path");
const Util = require("./common-util"); const Util = require("./common-util");
const Plan = require("./plan"); const Plan = require("./plan");
const Semaphore = require('saferphore');
const nThen = require('nthen');
/* Accepts a reference to an object, and... /* Accepts a reference to an object, and...
either a string describing which log is being processed (backwards compatibility), either a string describing which log is being processed (backwards compatibility),
or a function which will log the error with all relevant data or a function which will log the error with all relevant data
@ -194,3 +197,63 @@ Pins.list = function (_done, config) {
}).start(); }).start();
}); });
}; };
Pins.load = function (cb, config) {
const sema = Semaphore.create(config.workers || 5);
let dirList;
const fileList = [];
const pinned = {};
var pinPath = config.pinPath || './pins';
var done = Util.once(cb);
nThen((waitFor) => {
// recurse over the configured pinPath, or the default
Fs.readdir(pinPath, waitFor((err, list) => {
if (err) {
if (err.code === 'ENOENT') {
dirList = [];
return; // this ends up calling back with an empty object
}
waitFor.abort();
return void done(err);
}
dirList = list;
}));
}).nThen((waitFor) => {
dirList.forEach((f) => {
sema.take((returnAfter) => {
// iterate over all the subdirectories in the pin store
Fs.readdir(Path.join(pinPath, f), waitFor(returnAfter((err, list2) => {
if (err) {
waitFor.abort();
return void done(err);
}
list2.forEach((ff) => {
if (config && config.exclude && config.exclude.indexOf(ff) > -1) { return; }
fileList.push(Path.join(pinPath, f, ff));
});
})));
});
});
}).nThen((waitFor) => {
fileList.forEach((f) => {
sema.take((returnAfter) => {
Fs.readFile(f, waitFor(returnAfter((err, content) => {
if (err) {
waitFor.abort();
return void done(err);
}
const hashes = Pins.calculateFromLog(content.toString('utf8'), f);
hashes.forEach((x) => {
(pinned[x] = pinned[x] || {})[f.replace(/.*\/([^/]*).ndjson$/, (x, y)=>y)] = 1;
});
})));
});
});
}).nThen(() => {
done(void 0, pinned);
});
};

@ -14,6 +14,28 @@ const readFileBin = require("../stream-file").readFileBin;
const BatchRead = require("../batch-read"); const BatchRead = require("../batch-read");
const Schedule = require("../schedule"); const Schedule = require("../schedule");
/* Each time you write to a channel it will either use an open file descriptor
for that channel or open a new descriptor if one is not available. These are
automatically closed after this window to prevent a file descriptor leak, so
writes that take longer than this time may be dropped! */
const CHANNEL_WRITE_WINDOW = 300000;
/* Each time you read a channel it will have this many milliseconds to complete
otherwise it will be closed to prevent a file descriptor leak. The server will
lock up if it uses all available file descriptors, so it's important to close
them. The tradeoff with this timeout is that some functions, the stream, and
and the timeout itself are stored in memory. A longer timeout uses more memory
and running out of memory will also kill the server. */
const STREAM_CLOSE_TIMEOUT = 120000;
/* The above timeout closes the stream, but apparently that doesn't always work.
We set yet another timeout to allow the runtime to gracefully close the stream
(flushing all pending writes/reads and doing who knows what else). After this timeout
it will be MERCILESSLY DESTROYED. This isn't graceful, but again, file descriptor
leaks are bad. */
const STREAM_DESTROY_TIMEOUT = 30000;
const isValidChannelId = function (id) { const isValidChannelId = function (id) {
return typeof(id) === 'string' && return typeof(id) === 'string' &&
id.length >= 32 && id.length < 50 && id.length >= 32 && id.length < 50 &&
@ -61,20 +83,36 @@ var channelExists = function (filepath, cb) {
const destroyStream = function (stream) { const destroyStream = function (stream) {
if (!stream) { return; } if (!stream) { return; }
try { stream.close(); } catch (err) { console.error(err); } try {
stream.close();
if (stream.closed && stream.fd === null) { return; }
} catch (err) {
console.error(err);
}
setTimeout(function () { setTimeout(function () {
try { stream.destroy(); } catch (err) { console.error(err); } try { stream.destroy(); } catch (err) { console.error(err); }
}, 15000); }, STREAM_DESTROY_TIMEOUT);
}; };
/*
accept a stream, an id (used as a label) and an optional number of milliseconds
return a function which ignores all arguments
and first tries to gracefully close a stream
then destroys it after a period if the close was not successful
if the function is not called within the specified number of milliseconds
then it will be called implicitly with an error to indicate
that it was run because it timed out
*/
const ensureStreamCloses = function (stream, id, ms) { const ensureStreamCloses = function (stream, id, ms) {
return Util.bake(Util.mkTimeout(Util.once(function (err) { return Util.bake(Util.mkTimeout(Util.once(function (err) {
destroyStream(stream, id); destroyStream(stream);
if (err) { if (err) {
// this can only be a timeout error... // this can only be a timeout error...
console.log("stream close error:", err, id); console.log("stream close error:", err, id);
} }
}), ms || 45000), []); }), ms || STREAM_CLOSE_TIMEOUT), []);
}; };
// readMessagesBin asynchronously iterates over the messages in a channel log // readMessagesBin asynchronously iterates over the messages in a channel log
@ -84,7 +122,7 @@ const ensureStreamCloses = function (stream, id, ms) {
// it also allows the handler to abort reading at any time // it also allows the handler to abort reading at any time
const readMessagesBin = (env, id, start, msgHandler, cb) => { const readMessagesBin = (env, id, start, msgHandler, cb) => {
const stream = Fs.createReadStream(mkPath(env, id), { start: start }); const stream = Fs.createReadStream(mkPath(env, id), { start: start });
const finish = ensureStreamCloses(stream, id); const finish = ensureStreamCloses(stream, '[readMessagesBin:' + id + ']');
return void readFileBin(stream, msgHandler, function (err) { return void readFileBin(stream, msgHandler, function (err) {
cb(err); cb(err);
finish(); finish();
@ -95,7 +133,7 @@ const readMessagesBin = (env, id, start, msgHandler, cb) => {
// returns undefined if the first message was not an object (not an array) // returns undefined if the first message was not an object (not an array)
var getMetadataAtPath = function (Env, path, _cb) { var getMetadataAtPath = function (Env, path, _cb) {
const stream = Fs.createReadStream(path, { start: 0 }); const stream = Fs.createReadStream(path, { start: 0 });
const finish = ensureStreamCloses(stream, path); const finish = ensureStreamCloses(stream, '[getMetadataAtPath:' + path + ']');
var cb = Util.once(Util.mkAsync(Util.both(_cb, finish)), function () { var cb = Util.once(Util.mkAsync(Util.both(_cb, finish)), function () {
throw new Error("Multiple Callbacks"); throw new Error("Multiple Callbacks");
}); });
@ -181,7 +219,7 @@ var clearChannel = function (env, channelId, _cb) {
*/ */
var readMessages = function (path, msgHandler, _cb) { var readMessages = function (path, msgHandler, _cb) {
var stream = Fs.createReadStream(path, { start: 0}); var stream = Fs.createReadStream(path, { start: 0});
const finish = ensureStreamCloses(stream, path); const finish = ensureStreamCloses(stream, '[readMessages:' + path + ']');
var cb = Util.once(Util.mkAsync(Util.both(finish, _cb))); var cb = Util.once(Util.mkAsync(Util.both(finish, _cb)));
return readFileBin(stream, function (msgObj, readMore) { return readFileBin(stream, function (msgObj, readMore) {
@ -209,7 +247,7 @@ var getDedicatedMetadata = function (env, channelId, handler, _cb) {
var metadataPath = mkMetadataPath(env, channelId); var metadataPath = mkMetadataPath(env, channelId);
var stream = Fs.createReadStream(metadataPath, {start: 0}); var stream = Fs.createReadStream(metadataPath, {start: 0});
const finish = ensureStreamCloses(stream, metadataPath); const finish = ensureStreamCloses(stream, '[getDedicatedMetadata:' + metadataPath + ']');
var cb = Util.both(finish, _cb); var cb = Util.both(finish, _cb);
readFileBin(stream, function (msgObj, readMore) { readFileBin(stream, function (msgObj, readMore) {
@ -729,7 +767,7 @@ var getChannel = function (env, id, _callback) {
delete env.channels[id]; delete env.channels[id];
destroyStream(channel.writeStream, path); destroyStream(channel.writeStream, path);
//console.log("closing writestream"); //console.log("closing writestream");
}, 120000); }, CHANNEL_WRITE_WINDOW);
channel.delayClose(); channel.delayClose();
env.channels[id] = channel; env.channels[id] = channel;
done(void 0, channel); done(void 0, channel);

@ -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);
});

@ -12,6 +12,7 @@ const Core = require("../commands/core");
const Saferphore = require("saferphore"); const Saferphore = require("saferphore");
const Logger = require("../log"); const Logger = require("../log");
const Tasks = require("../storage/tasks"); const Tasks = require("../storage/tasks");
const Nacl = require('tweetnacl/nacl-fast');
const Env = { const Env = {
Log: {}, Log: {},
@ -221,10 +222,10 @@ const computeMetadata = function (data, cb) {
const getOlderHistory = function (data, cb) { const getOlderHistory = function (data, cb) {
const oldestKnownHash = data.hash; const oldestKnownHash = data.hash;
const channelName = data.channel; const channelName = data.channel;
const desiredMessages = data.desiredMessages;
const desiredCheckpoint = data.desiredCheckpoint;
//const store = Env.store; var messages = [];
//const Log = Env.Log;
var messageBuffer = [];
var found = false; var found = false;
store.getMessages(channelName, function (msgStr) { store.getMessages(channelName, function (msgStr) {
if (found) { return; } if (found) { return; }
@ -245,9 +246,22 @@ const getOlderHistory = function (data, cb) {
if (hash === oldestKnownHash) { if (hash === oldestKnownHash) {
found = true; found = true;
} }
messageBuffer.push(parsed); messages.push(parsed);
}, function (err) { }, function (err) {
cb(err, messageBuffer); var toSend = [];
if (typeof (desiredMessages) === "number") {
toSend = messages.slice(-desiredMessages);
} else {
let cpCount = 0;
for (var i = messages.length - 1; i >= 0; i--) {
if (/^cp\|/.test(messages[i][4]) && i !== (messages.length - 1)) {
cpCount++;
}
toSend.unshift(messages[i]);
if (cpCount >= desiredCheckpoint) { break; }
}
}
cb(err, toSend);
}); });
}; };
@ -418,6 +432,10 @@ const runTasks = function (data, cb) {
Env.tasks.runAll(cb); Env.tasks.runAll(cb);
}; };
const writeTask = function (data, cb) {
Env.tasks.write(data.time, data.task_command, data.args, cb);
};
const COMMANDS = { const COMMANDS = {
COMPUTE_INDEX: computeIndex, COMPUTE_INDEX: computeIndex,
COMPUTE_METADATA: computeMetadata, COMPUTE_METADATA: computeMetadata,
@ -430,6 +448,92 @@ const COMMANDS = {
GET_HASH_OFFSET: getHashOffset, GET_HASH_OFFSET: getHashOffset,
REMOVE_OWNED_BLOB: removeOwnedBlob, REMOVE_OWNED_BLOB: removeOwnedBlob,
RUN_TASKS: runTasks, 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) { process.on('message', function (data) {

@ -3,103 +3,14 @@
const Util = require("../common-util"); const Util = require("../common-util");
const nThen = require('nthen'); const nThen = require('nthen');
const OS = require("os"); const OS = require("os");
const numCPUs = OS.cpus().length;
const { fork } = require('child_process'); const { fork } = require('child_process');
const Workers = module.exports; const Workers = module.exports;
const PID = process.pid; const PID = process.pid;
const CRYPTO_PATH = 'lib/workers/crypto-worker';
const DB_PATH = 'lib/workers/db-worker'; const DB_PATH = 'lib/workers/db-worker';
const MAX_JOBS = 16;
Workers.initializeValidationWorkers = function (Env) { Workers.initialize = function (Env, config, _cb) {
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) {
var cb = Util.once(Util.mkAsync(_cb)); var cb = Util.once(Util.mkAsync(_cb));
const workers = []; const workers = [];
@ -124,16 +35,60 @@ Workers.initializeIndexWorkers = function (Env, config, _cb) {
return response.expected(id)? guid(): id; return response.expected(id)? guid(): id;
}; };
var workerIndex = 0; var workerOffset = -1;
var sendCommand = function (msg, _cb) { var queue = [];
var cb = Util.once(Util.mkAsync(_cb)); 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; // cycle through the workers once
if (!isWorker(workers[workerIndex])) { // start from a different offset each time
return void cb("NO_WORKERS"); // 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(); const txid = guid();
msg.txid = txid; msg.txid = txid;
@ -141,14 +96,42 @@ Workers.initializeIndexWorkers = function (Env, config, _cb) {
// track which worker is doing which jobs // track which worker is doing which jobs
state.tasks[txid] = msg; state.tasks[txid] = msg;
response.expect(txid, function (err, value) {
// clean up when you get a response response.expect(txid, cb, 60000);
delete state[txid];
cb(err, value);
}, 60000);
state.worker.send(msg); 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 initWorker = function (worker, cb) {
const txid = guid(); const txid = guid();
@ -170,19 +153,7 @@ Workers.initializeIndexWorkers = function (Env, config, _cb) {
}); });
worker.on('message', function (res) { worker.on('message', function (res) {
if (!res) { return; } handleResponse(state, res);
// 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]);
}); });
var substituteWorker = Util.once(function () { var substituteWorker = Util.once(function () {
@ -222,7 +193,32 @@ Workers.initializeIndexWorkers = function (Env, config, _cb) {
}; };
nThen(function (w) { 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) { initWorker(fork(DB_PATH), w(function (err) {
if (!err) { return; } if (!err) { return; }
w.abort(); w.abort();
@ -254,12 +250,14 @@ Workers.initializeIndexWorkers = function (Env, config, _cb) {
}); });
}; };
Env.getOlderHistory = function (channel, oldestKnownHash, cb) { Env.getOlderHistory = function (channel, oldestKnownHash, desiredMessages, desiredCheckpoint, cb) {
Env.store.getWeakLock(channel, function (next) { Env.store.getWeakLock(channel, function (next) {
sendCommand({ sendCommand({
channel: channel, channel: channel,
command: "GET_OLDER_HISTORY", command: "GET_OLDER_HISTORY",
hash: oldestKnownHash, hash: oldestKnownHash,
desiredMessages: desiredMessages,
desiredCheckpoint: desiredCheckpoint,
}, Util.both(next, cb)); }, Util.both(next, cb));
}); });
}; };
@ -327,11 +325,42 @@ Workers.initializeIndexWorkers = function (Env, config, _cb) {
}, 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); cb(void 0);
}); });
}; };
Workers.initialize = function (Env, config, cb) {
Workers.initializeValidationWorkers(Env);
Workers.initializeIndexWorkers(Env, config, cb);
};

2
package-lock.json generated

@ -1,6 +1,6 @@
{ {
"name": "cryptpad", "name": "cryptpad",
"version": "3.15.0", "version": "3.16.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {

@ -1,7 +1,7 @@
{ {
"name": "cryptpad", "name": "cryptpad",
"description": "realtime collaborative visual editor with zero knowlege server", "description": "realtime collaborative visual editor with zero knowlege server",
"version": "3.15.0", "version": "3.16.0",
"license": "AGPL-3.0+", "license": "AGPL-3.0+",
"repository": { "repository": {
"type": "git", "type": "git",

@ -0,0 +1,42 @@
/* jshint esversion: 6, node: true */
const nThen = require("nthen");
const Pins = require("../lib/pins");
const Assert = require("assert");
const config = require("../lib/load-config");
var compare = function () {
console.log(config);
var conf = {
pinPath: config.pinPath,
};
var list, load;
nThen(function (w) {
Pins.list(w(function (err, p) {
if (err) { throw err; }
list = p;
console.log(p);
console.log(list);
console.log();
}), conf);
}).nThen(function (w) {
Pins.load(w(function (err, p) {
if (err) { throw err; }
load = p;
console.log(load);
console.log();
}), conf);
}).nThen(function () {
console.log({
listLength: Object.keys(list).length,
loadLength: Object.keys(load).length,
});
Assert.deepEqual(list, load);
console.log("methods are equivalent");
});
};
compare();

@ -42,7 +42,7 @@ nThen(function (w) {
store = _; store = _;
})); // load the list of pinned files so you know which files })); // load the list of pinned files so you know which files
// should not be archived or deleted // should not be archived or deleted
Pins.list(w(function (err, _) { Pins.load(w(function (err, _) {
if (err) { if (err) {
w.abort(); w.abort();
return void console.error(err); return void console.error(err);

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -71,7 +71,7 @@ define([
// Get contacts and extract their avatar channel and key // Get contacts and extract their avatar channel and key
var getData = function (obj, href) { var getData = function (obj, href) {
var parsed = Hash.parsePadUrl(href); var parsed = Hash.parsePadUrl(href);
if (!parsed || parsed.type !== "file") { return; } // XXX if (!parsed || parsed.type !== "file") { return; }
var secret = Hash.getSecrets('file', parsed.hash); var secret = Hash.getSecrets('file', parsed.hash);
if (!secret.keys || !secret.channel) { return; } if (!secret.keys || !secret.channel) { return; }
obj.avatarKey = Hash.encodeBase64(secret.keys && secret.keys.cryptKey); obj.avatarKey = Hash.encodeBase64(secret.keys && secret.keys.cryptKey);
@ -81,7 +81,7 @@ define([
contacts.friends = proxy.friends || {}; contacts.friends = proxy.friends || {};
Object.keys(contacts.friends).map(function (key) { Object.keys(contacts.friends).map(function (key) {
var friend = contacts.friends[key]; var friend = contacts.friends[key];
// if (!friend) { return; } // XXX how should this be handled? if (!friend) { return; }
var ret = { var ret = {
edPublic: friend.edPublic, edPublic: friend.edPublic,
name: friend.displayName, name: friend.displayName,
@ -91,7 +91,7 @@ define([
}); });
Object.keys(contacts.teams).map(function (key) { Object.keys(contacts.teams).map(function (key) {
var team = contacts.teams[key]; var team = contacts.teams[key];
// if (!team) { return; } // XXX how should this be handled. Is this possible? if (!team) { return; }
var avatar = team.metadata && team.metadata.avatar; var avatar = team.metadata && team.metadata.avatar;
var ret = { var ret = {
edPublic: team.keys && team.keys.drive && team.keys.drive.edPublic, edPublic: team.keys && team.keys.drive && team.keys.drive.edPublic,

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="/common/sframe-app-outer.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="/common/sframe-app-outer.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -175,9 +175,9 @@ define([
var common = config.common; var common = config.common;
var sframeChan = common.getSframeChannel(); var sframeChan = common.getSframeChannel();
var title = config.title; var title = config.title;
var friends = config.friends; var friends = config.friends || {};
var teams = config.teams || {};
var myName = common.getMetadataMgr().getUserData().name; var myName = common.getMetadataMgr().getUserData().name;
if (!friends) { return; }
var order = []; var order = [];
var smallCurves = Object.keys(friends).map(function (c) { var smallCurves = Object.keys(friends).map(function (c) {
@ -206,40 +206,28 @@ define([
delete friends[curve]; delete friends[curve];
}); });
var friendsList = UIElements.getUserGrid(Messages.share_linkFriends, { var others = [];
common: common, if (Object.keys(friends).length) {
data: friends, var friendsList = UIElements.getUserGrid(Messages.share_linkFriends, {
noFilter: false, common: common,
large: true data: friends,
}, refreshButtons); noFilter: false,
var friendDiv = friendsList.div; large: true
$div.append(friendDiv); }, refreshButtons);
var others = friendsList.icons; var friendDiv = friendsList.div;
$div.append(friendDiv);
others = friendsList.icons;
}
var privateData = common.getMetadataMgr().getPrivateData(); if (Object.keys(teams).length) {
var teamsData = Util.tryParse(JSON.stringify(privateData.teams)) || {}; var teamsList = UIElements.getUserGrid(Messages.share_linkTeam, {
var teams = {}; common: common,
Object.keys(teamsData).forEach(function (id) { noFilter: true,
// config.teamId only exists when we're trying to share a pad from a team drive large: true,
// In this case, we don't want to share the pad with the current team data: teams
if (config.teamId && config.teamId === id) { return; } }, refreshButtons);
if (!teamsData[id].hasSecondaryKey) { return; } $div.append(teamsList.div);
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);
var shareButton = { var shareButton = {
className: 'primary cp-share-with-friends', 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 makeBurnAfterReadingUrl = function (common, href, channel, cb) {
var keyPair = Hash.generateSignPair(); var keyPair = Hash.generateSignPair();
var parsed = Hash.parsePadUrl(href); var parsed = Hash.parsePadUrl(href);
@ -643,7 +651,10 @@ define([
// Share with contacts tab // 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(); var onFriendShare = Util.mkEvent();
@ -926,7 +937,10 @@ define([
}); });
// share with contacts tab // 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 friendsObject = hasFriends ? createShareWithFriends(config, null, getLinkValue) : noContactsMessage(common);
var friendsList = friendsObject.content; var friendsList = friendsObject.content;
@ -1637,7 +1651,6 @@ define([
if (data.hiddenReadOnly) { button.addClass('cp-hidden-if-readonly'); } if (data.hiddenReadOnly) { button.addClass('cp-hidden-if-readonly'); }
if (data.name) { if (data.name) {
button.addClass('cp-toolbar-icon-'+data.name); button.addClass('cp-toolbar-icon-'+data.name);
button.click(common.prepareFeedback(data.name));
} }
if (data.text) { if (data.text) {
$('<span>', {'class': 'cp-toolbar-drawer-element'}).text(data.text) $('<span>', {'class': 'cp-toolbar-drawer-element'}).text(data.text)

@ -23,8 +23,17 @@ define([
init: function () {} 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) { require(['mermaid', 'css!/code/mermaid-new.css'], function (_Mermaid) {
Mermaid = _Mermaid; Mermaid = _Mermaid;
Mermaid.initialize({
gantt: { axisFormat: '%m-%d', },
"themeCSS": mermaidThemeCSS,
});
}); });
var highlighter = function () { var highlighter = function () {
@ -304,6 +313,15 @@ define([
// finally, find all 'clickable' items and remove the class // finally, find all 'clickable' items and remove the class
$el.find('.clickable').removeClass('clickable'); $el.find('.clickable').removeClass('clickable');
}; };
var renderMermaid = function ($el) {
Mermaid.init(undefined, $el);
// clickable elements in mermaid don't work well with our sandboxing setup
// the function below strips clickable elements but still leaves behind some artifacts
// tippy tooltips might still be useful, so they're not removed. It would be
// preferable to just support links, but this covers up a rough edge in the meantime
removeMermaidClickables($el);
};
DiffMd.apply = function (newHtml, $content, common) { DiffMd.apply = function (newHtml, $content, common) {
var contextMenu = common.importMediaTagMenu(); var contextMenu = common.importMediaTagMenu();
@ -351,6 +369,12 @@ define([
// retrieve the attached source code which it was drawn // retrieve the attached source code which it was drawn
var src = el.getAttribute('mermaid-source'); 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 // check if that source exists in the set of charts which are about to be rendered
if (mermaid_source.indexOf(src) === -1) { if (mermaid_source.indexOf(src) === -1) {
// if it's not, then you can remove it // if it's not, then you can remove it
@ -372,8 +396,15 @@ define([
var mts = []; var mts = [];
$content.find('media-tag, pre.mermaid').each(function (i, el) { $content.find('media-tag, pre.mermaid').each(function (i, el) {
if (el.nodeName.toLowerCase() === "pre") { if (el.nodeName.toLowerCase() === "pre") {
var clone = el.cloneNode();
return void mts.push({ return void mts.push({
svg: el.cloneNode(true) svg: clone,
render: function () {
var $el = $(clone);
$el.text(clone.getAttribute('mermaid-source'));
$el.attr('data-processed', '');
renderMermaid($el);
}
}); });
} }
var $el = $(el); var $el = $(el);
@ -386,7 +417,7 @@ define([
// Find initial position // Find initial position
var idx = -1; var idx = -1;
mts.some(function (obj, i) { mts.some(function (obj, i) {
if (isSvg && $mt.find('svg').attr('id') === $(obj.svg).find('svg').attr('id')) { if (isSvg && $mt.attr('mermaid-source') === $(obj.svg).attr('mermaid-source')) {
idx = i; idx = i;
return true; return true;
} }
@ -397,8 +428,15 @@ define([
}); });
if (idx === -1) { if (idx === -1) {
if (isSvg) { if (isSvg) {
var clone = $mt[0].cloneNode();
mts.unshift({ mts.unshift({
svg: $mt[0].cloneNode(true) svg: clone,
render: function () {
var $el = $(clone);
$el.text(clone.getAttribute('mermaid-source'));
$el.attr('data-processed', '');
renderMermaid($el);
}
}); });
} else { } else {
mts.unshift({ mts.unshift({
@ -418,7 +456,7 @@ define([
throw new Error(patch); throw new Error(patch);
} else { } else {
DD.apply($content[0], patch); DD.apply($content[0], patch);
var $mts = $content.find('media-tag:not(:has(*))'); var $mts = $content.find('media-tag');
$mts.each(function (i, el) { $mts.each(function (i, el) {
var $mt = $(el).contextmenu(function (e) { var $mt = $(el).contextmenu(function (e) {
e.preventDefault(); e.preventDefault();
@ -426,6 +464,16 @@ define([
$(contextMenu.menu).find('li').show(); $(contextMenu.menu).find('li').show();
contextMenu.show(e); 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); MediaTag(el);
var observer = new MutationObserver(function(mutations) { var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) { mutations.forEach(function(mutation) {
@ -486,12 +534,7 @@ define([
// check if you had cached a pre-rendered instance of the supplied source // check if you had cached a pre-rendered instance of the supplied source
if (typeof(cached) !== 'object') { if (typeof(cached) !== 'object') {
try { try {
Mermaid.init(undefined, $el); renderMermaid($el);
// clickable elements in mermaid don't work well with our sandboxing setup
// the function below strips clickable elements but still leaves behind some artifacts
// tippy tooltips might still be useful, so they're not removed. It would be
// preferable to just support links, but this covers up a rough edge in the meantime
removeMermaidClickables($el);
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
return; return;
} }

@ -250,36 +250,47 @@ define([
// Check src and cryptkey // Check src and cryptkey
var cfg = tags[i]; var cfg = tags[i];
var tag;
if (cfg.svg) { if (cfg.svg) {
$spinner.hide();
$inner.append(cfg.svg); $inner.append(cfg.svg);
locked = false; if (!cfg.render) {
return; $spinner.hide();
} console.error('here');
locked = false;
var src = cfg.src; return;
var key = cfg.key; }
if (cfg.href) { console.error('there');
var parsed = Hash.parsePadUrl(cfg.href); setTimeout(cfg.render);
var secret = Hash.getSecrets(parsed.type, parsed.hash, cfg.password); tag = cfg.svg;
var host = priv.fileHost || priv.origin || ''; } else {
src = host + Hash.getBlobPathFromHex(secret.channel); var src = cfg.src;
var _key = secret.keys && secret.keys.cryptKey; var key = cfg.key;
if (_key) { key = 'cryptpad:' + Nacl.util.encodeBase64(_key); } if (cfg.href) {
} var parsed = Hash.parsePadUrl(cfg.href);
if (!src || !key) { var secret = Hash.getSecrets(parsed.type, parsed.hash, cfg.password);
locked = false; var host = priv.fileHost || priv.origin || '';
$spinner.hide(); src = host + Hash.getBlobPathFromHex(secret.channel);
return void UI.log(Messages.error); var _key = secret.keys && secret.keys.cryptKey;
if (_key) { key = 'cryptpad:' + Nacl.util.encodeBase64(_key); }
}
if (!src || !key) {
locked = false;
$spinner.hide();
return void UI.log(Messages.error);
}
tag = h('media-tag', {
src: src,
'data-crypto-key': key
});
$inner.append(tag);
MediaTag(tag).on('error', function () {
locked = false;
$spinner.hide();
UI.log(Messages.error);
});
} }
var tag = h('media-tag', {
src: src,
'data-crypto-key': key
});
$inner.append(tag);
var observer = new MutationObserver(function(mutations) { var observer = new MutationObserver(function(mutations) {
mutations.forEach(function() { mutations.forEach(function() {
locked = false; locked = false;
@ -291,11 +302,6 @@ define([
childList: true, childList: true,
characterData: false characterData: false
}); });
MediaTag(tag).on('error', function () {
locked = false;
$spinner.hide();
UI.log(Messages.error);
});
}; };
show(i); show(i);

@ -355,7 +355,6 @@ define([
APP.FM.handleFile(blob, data); APP.FM.handleFile(blob, data);
}; };
Messages.oo_login = 'Log in...'; // XXX
var noLogin = false; var noLogin = false;
var makeCheckpoint = function (force) { var makeCheckpoint = function (force) {
@ -1120,12 +1119,12 @@ define([
var x2tSaveAndConvertData = function(data, filename, extension, finalFilename) { var x2tSaveAndConvertData = function(data, filename, extension, finalFilename) {
// Perform the x2t conversion // Perform the x2t conversion
require(['/common/onlyoffice/x2t/x2t.js'], function() { require(['/common/onlyoffice/x2t/x2t.js'], function() { // FIXME why does this fail without an access-control-allow-origin header?
var x2t = window.Module; var x2t = window.Module;
x2t.run(); x2t.run();
if (x2tInitialized) { if (x2tInitialized) {
debug("x2t runtime already initialized"); debug("x2t runtime already initialized");
x2tSaveAndConvertDataInternal(x2t, data, filename, extension, finalFilename); return void x2tSaveAndConvertDataInternal(x2t, data, filename, extension, finalFilename);
} }
x2t.onRuntimeInitialized = function() { x2t.onRuntimeInitialized = function() {

@ -38,6 +38,9 @@ define([
drive: { drive: {
hideDuplicate: true hideDuplicate: true
}, },
pad: {
width: true
},
general: { general: {
allowUserFeedback: true allowUserFeedback: true
} }

@ -350,7 +350,7 @@
"fm_info_root": "Creeu aquí tantes carpetes imbrincades com vulgueu per ordenar els vostres fitxers.", "fm_info_root": "Creeu aquí tantes carpetes imbrincades com vulgueu per ordenar els vostres fitxers.",
"fm_info_unsorted": "Conté tots els fitxers que heu visitat i que encara no estan ordenats, a \"Documents\" o desplaçats a la \"Paperera\".", "fm_info_unsorted": "Conté tots els fitxers que heu visitat i que encara no estan ordenats, a \"Documents\" o desplaçats a la \"Paperera\".",
"fm_info_template": "Conté tots els documents desats com plantilles i que podeu reutilitzar quan vulgueu crear un nou document.", "fm_info_template": "Conté tots els documents desats com plantilles i que podeu reutilitzar quan vulgueu crear un nou document.",
"fm_info_recent": "Llista els documents modificats o oberts recentment.", "fm_info_recent": "Aquests documents s'han modificat o obert darrerament, per vós o per alguna persona col·laboradora.",
"fm_info_trash": "Buideu la paperera per alliberar espai al vostre CryptDrive.", "fm_info_trash": "Buideu la paperera per alliberar espai al vostre CryptDrive.",
"fm_info_allFiles": "Conté tots els fitxers de \"Documents\", \"Desordenats\" i \"Paperera\". No podeu desplaçar o suprimir fitxers des d'aquí.", "fm_info_allFiles": "Conté tots els fitxers de \"Documents\", \"Desordenats\" i \"Paperera\". No podeu desplaçar o suprimir fitxers des d'aquí.",
"fm_info_anonymous": "No heu iniciat la sessió, per tant, els vostres documents caducaran d'aquí a 3 mesos (<a href=\"https://blog.cryptpad.fr/2017/05/17/You-gotta-log-in/\" target=\"_blank\">saber-ne més</a>). Es desen al vostre navegador, per tant, si netegeu el vostre historial podríeu perdre'ls.<br><a href=\"/register/\">Registreu-vos</a> o <a href=\"/login/\">Inicieu la sessió</a> per mantenir-los accessibles.<br>", "fm_info_anonymous": "No heu iniciat la sessió, per tant, els vostres documents caducaran d'aquí a 3 mesos (<a href=\"https://blog.cryptpad.fr/2017/05/17/You-gotta-log-in/\" target=\"_blank\">saber-ne més</a>). Es desen al vostre navegador, per tant, si netegeu el vostre historial podríeu perdre'ls.<br><a href=\"/register/\">Registreu-vos</a> o <a href=\"/login/\">Inicieu la sessió</a> per mantenir-los accessibles.<br>",
@ -515,7 +515,7 @@
"settings_pinningError": "Alguna cosa no ha funcionat correctament", "settings_pinningError": "Alguna cosa no ha funcionat correctament",
"settings_usageAmount": "Els vostres documents fixats ocupen {0} MB", "settings_usageAmount": "Els vostres documents fixats ocupen {0} MB",
"settings_logoutEverywhereButton": "Tanca la sessió", "settings_logoutEverywhereButton": "Tanca la sessió",
"settings_logoutEverywhereTitle": "Tanca la sessió arreu", "settings_logoutEverywhereTitle": "Tanca les sessions remotes",
"settings_logoutEverywhere": "Tanca totes les altres sessions", "settings_logoutEverywhere": "Tanca totes les altres sessions",
"settings_logoutEverywhereConfirm": "De debò? Haureu de tornar a iniciar la vostra sessió a tots els dispositius.", "settings_logoutEverywhereConfirm": "De debò? Haureu de tornar a iniciar la vostra sessió a tots els dispositius.",
"settings_driveDuplicateTitle": "Documents propis duplicats", "settings_driveDuplicateTitle": "Documents propis duplicats",
@ -573,7 +573,7 @@
"upload_success": "El fitxer ({0}) ha estat carregat correctament i afegit al vostre CryptDrive.", "upload_success": "El fitxer ({0}) ha estat carregat correctament i afegit al vostre CryptDrive.",
"upload_notEnoughSpace": "No hi ha prou espai al CryptDrive per aquest fitxer.", "upload_notEnoughSpace": "No hi ha prou espai al CryptDrive per aquest fitxer.",
"upload_notEnoughSpaceBrief": "No hi ha prou espai", "upload_notEnoughSpaceBrief": "No hi ha prou espai",
"upload_tooLarge": "Aquest fitxer supera la mida màxima permesa.", "upload_tooLarge": "Aquest fitxer supera la mida màxima permesa pel vostre compte",
"upload_tooLargeBrief": "El fitxer és massa gran", "upload_tooLargeBrief": "El fitxer és massa gran",
"upload_choose": "Trieu un fitxer", "upload_choose": "Trieu un fitxer",
"upload_pending": "Pendent", "upload_pending": "Pendent",
@ -619,5 +619,20 @@
"download_resourceNotAvailable": "El recurs sol·licitat no estava disponible... Premeu Esc per continuar.", "download_resourceNotAvailable": "El recurs sol·licitat no estava disponible... Premeu Esc per continuar.",
"about_contributors": "Col·laboracions clau", "about_contributors": "Col·laboracions clau",
"about_core": "Desenvolupament principal", "about_core": "Desenvolupament principal",
"about_intro": "CryptPad s'ha creat dins l'Equip de Recerca de <a href=\"http://xwiki.com\">XWiki SAS</a>, una petita empresa de París, França i Iasi, Romania. Hi ha 3 membres de l'equip central treballant amb CryptPad més una quantitat de persones col·laboradores, dins i fora d'XWiki SAS." "about_intro": "CryptPad s'ha creat dins l'Equip de Recerca de <a href=\"http://xwiki.com\">XWiki SAS</a>, una petita empresa de París, França i Iasi, Romania. Hi ha 3 membres de l'equip central treballant amb CryptPad més una quantitat de persones col·laboradores, dins i fora d'XWiki SAS.",
"main_catch_phrase": "El Núvol Coneixement Zero",
"main_footerText": "Amb Cryptpad, podeu crear documents col·laboratius per prendre notes i posar en ordre idees de forma conjunta de forma ràpida.",
"footer_applications": "Aplicacions",
"footer_contact": "Contacte",
"footer_aboutUs": "Sobre nosaltres",
"about": "Sobre",
"contact": "Contacte",
"blog": "Bloc",
"topbar_whatIsCryptpad": "Què és CryptPad",
"whatis_collaboration": "Col·laboració fàcil i ràpida",
"whatis_title": "Què és CryptPad",
"terms": "Condicions d'ús",
"main_info": "<h2>Col·laboreu amb Confiança</h2>\nFeu créixer les vostres idees conjuntament amb documents compartits mentre la tecnologia <strong>Coneixement Zero</strong> assegura la vostra privacitat; <strong>fins i tot per nosaltres</strong>.",
"whatis_collaboration_p1": "Amb CryptPad, podeu crear de forma ràpida, documents col·laboratius per prendre notes i posar en ordre idees conjuntament. Quan us registreu i inicieu la vostra sessió, teniu la capacitat de carregar fitxers i un CryptDrive on podeu organitzar tots els vostres documents. Com a persona registrada disposeu de 50MB d'espai gratuït.",
"privacy": "Privacitat"
} }

@ -1342,5 +1342,11 @@
"admin_openFilesTitle": "Offene Dateien", "admin_openFilesTitle": "Offene Dateien",
"canvas_select": "Auswahl", "canvas_select": "Auswahl",
"canvas_brush": "Pinsel", "canvas_brush": "Pinsel",
"profile_copyKey": "Öffentlichen Schlüssel kopieren" "profile_copyKey": "Öffentlichen Schlüssel kopieren",
"cba_hide": "",
"cba_enable": "",
"cba_properties": "",
"cba_disable": "",
"cba_show": "",
"cba_writtenBy": ""
} }

@ -1342,5 +1342,13 @@
"admin_openFilesTitle": "Fichiers Ouverts", "admin_openFilesTitle": "Fichiers Ouverts",
"profile_copyKey": "Copier la clé publique", "profile_copyKey": "Copier la clé publique",
"canvas_select": "Selection", "canvas_select": "Selection",
"canvas_brush": "Pinceau" "canvas_brush": "Pinceau",
"cba_show": "Montrer les couleurs d'auteurs",
"cba_disable": "Effacer et Désactiver",
"cba_hint": "Ce réglage sera mémorisé lors de la création de votre prochain pad.",
"cba_enable": "Activer",
"cba_writtenBy": "Écrit par : {0}",
"cba_properties": "Couleurs par auteurs (expérimental)",
"cba_hide": "Cacher les couleurs d'auteurs",
"oo_login": "Veuillez vous connecter ou vous inscrire pour améliorer la performance des feuilles de calcul."
} }

@ -1342,5 +1342,13 @@
"admin_openFilesTitle": "Open Files", "admin_openFilesTitle": "Open Files",
"admin_openFilesHint": "Number of file descriptors currently open on the server.", "admin_openFilesHint": "Number of file descriptors currently open on the server.",
"canvas_brush": "Brush", "canvas_brush": "Brush",
"canvas_select": "Select" "canvas_select": "Select",
"cba_writtenBy": "Written by: {0}",
"cba_properties": "Author colors (experimental)",
"cba_hint": "This setting will be remembered when you create your next pad.",
"cba_enable": "Enable",
"cba_disable": "Clear and Disable",
"cba_show": "Show author colors",
"cba_hide": "Hide author colors",
"oo_login": "Please log in or register to improve the performance of spreadsheets."
} }

@ -201,6 +201,180 @@
"chainpadError": "Er was een kritieke fout bij het updaten van uw inhoud. Deze pagina is nu alleen leesbaar om uw werk niet kwijt te raken. <br>Druk op <em>Esc</em> om verder te gaan met het bekijken van deze werkomgeving, of herlaad de pagina om te proberen deze werkomgeving weer aan te kunnen passen.", "chainpadError": "Er was een kritieke fout bij het updaten van uw inhoud. Deze pagina is nu alleen leesbaar om uw werk niet kwijt te raken. <br>Druk op <em>Esc</em> om verder te gaan met het bekijken van deze werkomgeving, of herlaad de pagina om te proberen deze werkomgeving weer aan te kunnen passen.",
"inactiveError": "Deze werkomgeving is verwijderd wegens gebrek aan activiteit. Druk op Esc om een nieuwe werkomgeving aan te maken.", "inactiveError": "Deze werkomgeving is verwijderd wegens gebrek aan activiteit. Druk op Esc om een nieuwe werkomgeving aan te maken.",
"deletedError": "Deze werkomgeving is verwijderd door de eigenaar en is niet meer beschikbaar.", "deletedError": "Deze werkomgeving is verwijderd door de eigenaar en is niet meer beschikbaar.",
"main_title": "CryptPad: Geen Kennis, Onvertraagd Collaboratief Aanpassen" "main_title": "CryptPad: Geen Kennis, Onvertraagd Collaboratief Aanpassen",
"fc_open_ro": "Openen (alleen-lezen)",
"history_restorePrompt": "Weet u zeker dat u de bestaande documentversie wilt vervangen met de weergegeven versie?",
"history_restoreDone": "Document hersteld",
"history_version": "Versie:",
"openLinkInNewTab": "Open Link in Nieuw Tabblad",
"pad_mediatagTitle": "Mediamarkering Instellingen",
"pad_mediatagWidth": "Breedte (px)",
"pad_mediatagHeight": "Hoogte (px)",
"pad_mediatagRatio": "Behoud ratio",
"pad_mediatagBorder": "Randbreedte (px)",
"pad_mediatagPreview": "Voorbeeld",
"pad_mediatagImport": "Opslaan in uw CryptDrive",
"pad_mediatagOptions": "Afbeeldingeigenschappen",
"kanban_newBoard": "Nieuw bord",
"kanban_item": "Item {0}",
"kanban_todo": "Te Doen",
"kanban_working": "In voortgang",
"kanban_deleteBoard": "Weet u zeker dat u dit bord wilt verwijderen?",
"kanban_addBoard": "Een bord toevoegen",
"kanban_removeItem": "Verwijder dit item",
"kanban_removeItemConfirm": "Weet u zeker dat u dit item wilt verwijderen?",
"poll_p_save": "Uw instellingen worden direct geüpdate, dus u hoeft deze nooit op te slaan.",
"poll_p_encryption": "Al uw invoer worden versleuteld zodat alleen de personen met de link toegang kunnen krijgen. Zelfs de server kan niet zien wat u wijzigt.",
"wizardLog": "Klik op de knop linksboven om terug te gaan naar uw enquête",
"wizardTitle": "Gebruik het formulier om een enquête aan te maken",
"wizardConfirm": "Bent u echt klaar om deze opties toe te voegen aan uw enquête?",
"poll_publish_button": "Publiceren",
"poll_admin_button": "Admin",
"poll_create_user": "Voeg een nieuwe gebruiker toe",
"poll_create_option": "Voeg een nieuwe optie toe",
"poll_commit": "Indienen",
"poll_closeWizardButton": "Sluit formulier",
"poll_closeWizardButtonTitle": "Sluit formulier",
"poll_wizardComputeButton": "Bereken Opties",
"poll_wizardClearButton": "Tabel Leegmaken",
"poll_wizardAddDateButton": "+ Data",
"poll_wizardAddTimeButton": "+ Tijden",
"poll_optionPlaceholder": "Optie",
"poll_userPlaceholder": "Uw naam",
"poll_removeOption": "Weet u zeker dat u deze optie wilt verwijderen?",
"poll_removeUser": "Weet u zeker dat u deze gebruiker wilt verwijderen?",
"poll_titleHint": "Titel",
"poll_remove": "Verwijder",
"poll_edit": "Wijzig",
"poll_locked": "Vergrendeld",
"poll_unlocked": "Ontgrendeld",
"poll_bookmark_col": "Voeg een bladwijzer toe aan deze kolom zodat deze altijd ontgrendeld is en weergegeven aan het begin",
"poll_bookmarked_col": "Dit is uw kolom met bladwijzer. Deze is altijd ontgrendeld en weergegeven aan het begin.",
"poll_total": "TOTAAL",
"poll_comment_list": "Commentaar",
"poll_comment_add": "Commentaar toevoegen",
"poll_comment_submit": "Verzenden",
"poll_comment_placeholder": "Uw commentaar",
"poll_comment_disabled": "Publiceer deze enquête met de ✓ knop om commentaar toe te staan.",
"oo_reconnect": "De verbinding met de server is terug. Klik op OK om te herladen en door te gaan met werken.",
"oo_cantUpload": "Uploaden is niet toegestaan als andere gebruikers aanwezig zijn.",
"oo_uploaded": "Uw upload is voltooid. Klik op OK om te herladen of Annuleren om door te gaan in alleen-lezen modus.",
"canvas_clear": "Leegmaken",
"canvas_delete": "Verwijder selectie",
"canvas_disable": "Tekenen uitschakelen",
"canvas_enable": "Tekenen inschakelen",
"canvas_width": "Breedte",
"canvas_opacity": "Ondoorzichtigheid",
"canvas_opacityLabel": "Ondoorzichtigheid: {0}",
"canvas_widthLabel": "Breedte: {0}",
"canvas_saveToDrive": "Deze afbeelding opslaan als bestand in uw CryptDrive",
"canvas_currentBrush": "Huidige borstel",
"canvas_chooseColor": "Kies een kleur",
"canvas_imageEmbed": "Plaats een afbeelding van uw computer",
"profileButton": "Profiel",
"profile_urlPlaceholder": "URL",
"profile_namePlaceholder": "Naam weergegeven op uw profiel",
"profile_avatar": "Avatar",
"profile_upload": " Een nieuw avatar uploaden",
"profile_uploadSizeError": "Foutmelding: uw avatar moet kleiner zijn dan {0}",
"profile_uploadTypeError": "Foutmelding: uw avatartype is niet toegestaan. Toegestane types zijn: {0}",
"profile_error": "Foutmelding tijdens het maken van uw profiel: {0}",
"profile_register": "U moet zich aanmelden om een profiel aan te maken!",
"profile_create": "Een profiel aanmaken",
"profile_description": "Beschrijving",
"profile_viewMyProfile": "Mijn profiel bekijken",
"userlist_addAsFriendTitle": "Stuur \"{0}\" een contactverzoek",
"contacts_title": "Contacten",
"contacts_addError": "Foutmelding tijdens het toevoegen van die contact aan de lijst",
"contacts_added": "Contactuitnodiging geaccepteerd.",
"contacts_rejected": "Contactuitnodiging afgewezen",
"contacts_request": "<em>{0}</em> wilt u toevoegen als contactpersoon. <b>Accepteren<b>?",
"contacts_send": "Verzenden",
"contacts_remove": "Verwijder deze contactpersoon",
"contacts_confirmRemove": "Weet u zeker dat u <em>{0}</em> wilt verwijderen uit uw contacten?",
"contacts_typeHere": "Schrijf hier een bericht...",
"contacts_warning": "Alles dat u hier schrijft blijft beschikbaar voor alle huidige en toekomstige gebruikers van deze werkomgeving. Wees voorzichtig met gevoelige informatie!",
"contacts_padTitle": "Gesprek",
"contacts_info1": "Dit zijn uw contacten. Vanaf hier kunt u:",
"contacts_info2": "Klik op uw contactpersoon's icoon om een gesprek met hun te beginnen",
"contacts_info3": "Dubbelklik op hun icoon om hun profiel te bekijken",
"contacts_info4": "Beide deelnemers kunnen de gesprekgeschiedenis voorgoed leegmaken",
"contacts_removeHistoryTitle": "Gesprekgeschiedenis leegmaken",
"contacts_confirmRemoveHistory": "Weet u zeker dat u voorgoed de gesprekgeschiedenis wilt leegmaken? De data kan niet worden hersteld",
"contacts_removeHistoryServerError": "Foutmelding tijdens het verwijderen van uw gesprekgeschiedenis. Probeer later opnieuw",
"contacts_fetchHistory": "Eerdere geschiedenis binnenhalen",
"contacts_friends": "Contacten",
"contacts_rooms": "Kamers",
"contacts_leaveRoom": "Deze kamer verlaten",
"contacts_online": "Een andere gebruiker in deze kamer is online",
"debug_getGraphWait": "De grafiek genereren... Even wachten, alstublieft.",
"fm_rootName": "Documenten",
"fm_trashName": "Prullenbak",
"fm_unsortedName": "Niet-gesorteerde bestanden",
"fm_filesDataName": "Alle bestanden",
"fm_templateName": "Sjablonen",
"fm_searchName": "Zoeken",
"fm_recentPadsName": "Recente werkomgevingen",
"fm_tagsName": "Markeringen",
"fm_sharedFolderName": "Gedeelde map",
"fm_searchPlaceholder": "Zoeken...",
"fm_newButton": "Nieuw",
"fm_newButtonTitle": "Make een nieuwe werkomgeving of map, importeer een bestand in de huidige map",
"fm_newFolder": "Nieuwe map",
"fm_newFile": "Nieuwe werkomgeving",
"fm_folder": "Map",
"fm_sharedFolder": "Gedeelde map",
"fm_folderName": "Mapnaam",
"fm_numberOfFolders": "Aantal mappen",
"fm_numberOfFiles": "Aantal bestanden",
"fm_fileName": "Bestandsnaam",
"fm_title": "Titel",
"fm_type": "Type",
"fm_forbidden": "Verboden actie",
"fm_originalPath": "Oorspronkelijke pad",
"fm_openParent": "Weergeven in map",
"fm_noname": "Document Zonder Title",
"fm_emptyTrashDialog": "Weet u zeker dat u de prullenbak wilt legen?",
"fm_removeSeveralPermanentlyDialog": "Weet u zeker dat u deze {0} elementen voorgoed wilt verwijderen van uw CryptDrive?",
"fm_removePermanentlyDialog": "Weet u zeker dat u dit element voorgoed wilt verwijderen van uw CryptDrive?",
"fm_removePermanentlyNote": "Werkomgevingen in uw eigendom worden verwijderd van de server als u doorgaat.",
"fm_removeSeveralDialog": "Weet u zeker dat u deze {0} elementen wilt verplaatsen naar de prullenbak?",
"fm_removeDialog": "Weet u zeker dat u {0} wilt verplaatsen naar de prullenbak?",
"fm_deleteOwnedPad": "Weet u zeker dat u deze werkomgeving wilt verwijderen van de server?",
"fm_deleteOwnedPads": "Weet u zeker dat u deze werkomgevingen wilt verwijderen van de server?",
"fm_restoreDialog": "Weet u zeker dat u {0} wilt terugzetten naar de vorige lokatie?",
"fm_unknownFolderError": "De geselecteerde of laatst bezochte map bestaat niet meer. De bovenliggende map aan het openen...",
"fm_backup_title": "Backup link",
"fm_nameFile": "Hoe wilt u dit bestand noemen?",
"fm_error_cantPin": "Interne server fout. Graag de pagina herladen en opnieuw proberen.",
"fm_canBeShared": "Deze map is deelbaar",
"fm_prop_tagsList": "Markeringen",
"fm_burnThisDriveButton": "Verwijder alle informatie opgeslagen door CryptPad in uw browser",
"fm_padIsOwned": "U bent de eigenaar van deze werkomgeving",
"fm_padIsOwnedOther": "Deze werkomgeving is van een andere gebruiker",
"fm_deletedPads": "Deze werkomgeving bestaan niet meer op de server, ze zijn verwijderd van uw CryptDrive: {0}",
"fm_tags_name": "Markeringsnaam",
"fm_tags_used": "Aantal gebruikers",
"fm_passwordProtected": "Vergrendeld met wachtwoord",
"fc_newfolder": "Nieuwe map",
"fc_newsharedfolder": "Nieuwe gedeelde map",
"fc_rename": "Hernoemen",
"fc_color": "Kleur wijzigen",
"fc_open": "Openen",
"fc_openInCode": "Openen in Code bewerker",
"fc_expandAll": "Alles Uitvouwen",
"fc_collapseAll": "Alles Samenvouwen",
"fc_delete": "Naar prullenbak verplaatsen",
"fc_delete_owned": "Verwijderen van server",
"fc_restore": "Herstellen",
"fc_remove": "Verwijder uit uw CryptDrive",
"fc_remove_sharedfolder": "Verwijderen",
"fc_empty": "Prullenbak legen",
"fc_prop": "Eigenschappen",
"fc_hashtag": "Markeringen",
"getEmbedCode": "Krijg integratiecode",
"kanban_done": "Gedaan",
"poll_comment_remove": "Verwijder dit commentaar",
"profile_fieldSaved": "Nieuwe waarde opgeslagen: {0}",
"fm_morePads": "Meer"
} }

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -7,7 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="/common/sframe-app-outer.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="/common/sframe-app-outer.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="/common/onlyoffice/main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="/common/onlyoffice/main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="/common/onlyoffice/main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="/common/onlyoffice/main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="/common/sframe-app-outer.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="/common/sframe-app-outer.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -731,10 +731,30 @@ define([
$iframe.find('body').attr('spellcheck', true); $iframe.find('body').attr('spellcheck', true);
} }
}); });
Messages.pad_useFullWidth = "Use full width"; // XXX
Messages.pad_usePageWidth = "Use page mode"; // XXX
framework._.sfCommon.getAttribute(['pad', 'width'], function (err, data) { framework._.sfCommon.getAttribute(['pad', 'width'], function (err, data) {
if (data) { var active = data || typeof(data) === "undefined";
if (active) {
$iframe.find('html').addClass('cke_body_width'); $iframe.find('html').addClass('cke_body_width');
} }
var $width = framework._.sfCommon.createButton('', true, {
icon: 'fa-arrows-h',
text: active ? Messages.pad_useFullWidth : Messages.pad_usePageWidth,
name: "pad-width",
},function () {
if (active) {
$iframe.find('html').removeClass('cke_body_width');
} else {
$iframe.find('html').addClass('cke_body_width');
}
active = !active;
var key = active ? Messages.pad_useFullWidth : Messages.pad_usePageWidth;
$width.find('.cp-toolbar-drawer-element').text(key);
framework._.sfCommon.setAttribute(['pad', 'width'], active);
});
framework._.toolbar.$drawer.append($width);
}); });
framework._.sfCommon.isPadStored(function (err, val) { framework._.sfCommon.isPadStored(function (err, val) {

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="/common/onlyoffice/main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="/common/onlyoffice/main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="/common/sframe-app-outer.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="/common/sframe-app-outer.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<script async data-bootload="/common/sframe-app-outer.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script> <script async data-bootload="/common/sframe-app-outer.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<link href="/customize/src/outer.css" rel="stylesheet" type="text/css"> <link href="/customize/src/outer.css?ver=1.1" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<iframe id="sbox-iframe"> <iframe id="sbox-iframe">

Loading…
Cancel
Save