diff --git a/customize.dist/application_config.js b/customize.dist/application_config.js index 2320bf9af..0cebf4b51 100644 --- a/customize.dist/application_config.js +++ b/customize.dist/application_config.js @@ -1,74 +1,10 @@ -define(function() { - var config = {}; - - /* Select the buttons displayed on the main page to create new collaborative sessions - * Existing types : pad, code, poll, slide - */ - config.availablePadTypes = ['drive', 'pad', 'code', 'slide', 'poll', 'whiteboard', 'file', 'todo', 'contacts']; - config.registeredOnlyTypes = ['file', 'contacts']; - - /* Cryptpad apps use a common API to display notifications to users - * by default, notifications are hidden after 5 seconds - * You can change their duration here (measured in milliseconds) - */ - config.notificationTimeout = 5000; - config.disableUserlistNotifications = false; - config.hideLoadingScreenTips = false; - - config.enablePinning = true; - - config.whiteboardPalette = [ - '#000000', // black - '#FFFFFF', // white - '#848484', // grey - '#8B4513', // saddlebrown - '#FF0000', // red - '#FF8080', // peach? - '#FF8000', // orange - '#FFFF00', // yellow - '#80FF80', // light green - '#00FF00', // green - '#00FFFF', // cyan - '#008B8B', // dark cyan - '#0000FF', // blue - '#FF00FF', // fuschia - '#FF00C0', // hot pink - '#800080', // purple - ]; - - config.enableTemplates = true; - - config.enableHistory = true; - - /* user passwords are hashed with scrypt, and salted with their username. - this value will be appended to the username, causing the resulting hash - to differ from other CryptPad instances if customized. This makes it - such that anyone who wants to bruteforce common credentials must do so - again on each CryptPad instance that they wish to attack. - - WARNING: this should only be set when your CryptPad instance is first - created. Changing it at a later time will break logins for all existing - users. - */ - config.loginSalt = ''; - config.minimumPasswordLength = 8; - - config.badStateTimeout = 30000; - - config.applicationsIcon = { - file: 'fa-file-text-o', - pad: 'fa-file-word-o', - code: 'fa-file-code-o', - slide: 'fa-file-powerpoint-o', - poll: 'fa-calendar', - whiteboard: 'fa-paint-brush', - todo: 'fa-tasks', - contacts: 'fa-users', - }; - - config.displayCreationScreen = false; - - config.disableAnonymousStore = false; - - return config; +/* + * You can override the configurable values from this file. + * The recommended method is to make a copy of this file (/customize.dist/application_config.js) + in a 'customize' directory (/customize/application_config.js). + * If you want to check all the configurable values, you can open the internal configuration file + but you should not change it directly (/common/application_config_internal.js) +*/ +define(['/common/application_config_internal.js'], function (AppConfig) { + return AppConfig; }); diff --git a/customize.dist/index.html b/customize.dist/index.html index 31d4c99f8..55891ecc4 100644 --- a/customize.dist/index.html +++ b/customize.dist/index.html @@ -4,6 +4,7 @@ CryptPad: Zero Knowledge, Collaborative Real Time Editing + diff --git a/package.json b/package.json index e8339d950..dd03426ce 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,12 @@ "description": "realtime collaborative visual editor with zero knowlege server", "version": "1.25.0", "dependencies": { - "chainpad-server": "^1.0.1", + "chainpad-server": "^2.0.0", "express": "~4.10.1", "nthen": "~0.1.0", + "pull-stream": "^3.6.1", "saferphore": "0.0.1", + "stream-to-pull-stream": "^1.7.2", "tweetnacl": "~0.12.2", "ws": "^1.0.1" }, diff --git a/storage/file.js b/storage/file.js index 5d5d8c3db..ce6c3ae13 100644 --- a/storage/file.js +++ b/storage/file.js @@ -1,6 +1,11 @@ +/*@flow*/ +/* jshint esversion: 6 */ +/* global Buffer */ var Fs = require("fs"); var Path = require("path"); var nThen = require("nthen"); +const ToPull = require('stream-to-pull-stream'); +const Pull = require('pull-stream'); var mkPath = function (env, channelId) { return Path.join(env.root, channelId.slice(0, 2), channelId) + '.ndjson'; @@ -8,7 +13,7 @@ var mkPath = function (env, channelId) { var getMetadataAtPath = function (Env, path, cb) { var remainder = ''; - var stream = Fs.createReadStream(path, 'utf8'); + var stream = Fs.createReadStream(path, { encoding: 'utf8' }); var complete = function (err, data) { var _cb = cb; cb = undefined; @@ -25,16 +30,16 @@ var getMetadataAtPath = function (Env, path, cb) { var parsed = null; try { parsed = JSON.parse(metadata); - complete(void 0, parsed); + complete(undefined, parsed); } catch (e) { - console.log(); + console.log("getMetadataAtPath"); console.error(e); complete('INVALID_METADATA'); } }); stream.on('end', function () { - complete(null); + complete(); }); stream.on('error', function (e) { complete(e); }); }; @@ -59,7 +64,7 @@ var closeChannel = function (env, channelName, cb) { var clearChannel = function (env, channelId, cb) { var path = mkPath(env, channelId); getMetadataAtPath(env, path, function (e, metadata) { - if (e) { return cb(e); } + if (e) { return cb(new Error(e)); } if (!metadata) { return void Fs.truncate(path, 0, function (err) { if (err) { @@ -87,7 +92,7 @@ var clearChannel = function (env, channelId, cb) { var readMessages = function (path, msgHandler, cb) { var remainder = ''; - var stream = Fs.createReadStream(path, 'utf8'); + var stream = Fs.createReadStream(path, { encoding: 'utf8' }); var complete = function (err) { var _cb = cb; cb = undefined; @@ -106,6 +111,60 @@ var readMessages = function (path, msgHandler, cb) { stream.on('error', function (e) { complete(e); }); }; +const NEWLINE_CHR = ('\n').charCodeAt(0); +const mkBufferSplit = () => { + let remainder = null; + return Pull((read) => { + return (abort, cb) => { + read(abort, function (end, data) { + if (end) { + cb(end, remainder ? [remainder, data] : [data]); + remainder = null; + return; + } + const queue = []; + for (;;) { + const offset = data.indexOf(NEWLINE_CHR); + if (offset < 0) { + remainder = remainder ? Buffer.concat([remainder, data]) : data; + break; + } + let subArray = data.slice(0, offset); + if (remainder) { + subArray = Buffer.concat([remainder, subArray]); + remainder = null; + } + queue.push(subArray); + data = data.slice(offset + 1); + } + cb(end, queue); + }); + }; + }, Pull.flatten()); +}; + +const mkOffsetCounter = () => { + let offset = 0; + return Pull.map((buff) => { + const out = { offset: offset, buff: buff }; + // +1 for the eaten newline + offset += buff.length + 1; + return out; + }); +}; + +const readMessagesBin = (env, id, start, msgHandler, cb) => { + const stream = Fs.createReadStream(mkPath(env, id), { start: start }); + let keepReading = true; + Pull( + ToPull.read(stream), + mkBufferSplit(), + mkOffsetCounter(), + Pull.asyncMap((data, moreCb) => { msgHandler(data, moreCb, ()=>{ keepReading = false; moreCb(); }); }), + Pull.drain(()=>(keepReading), cb) + ); +}; + var checkPath = function (path, callback) { // TODO check if we actually need to use stat at all Fs.stat(path, function (err) { @@ -117,7 +176,8 @@ var checkPath = function (path, callback) { callback(err); return; } - Fs.mkdir(Path.dirname(path), function (err) { + // 511 -> octal 777 + Fs.mkdir(Path.dirname(path), 511, function (err) { if (err && err.code !== 'EEXIST') { callback(err); return; @@ -154,7 +214,28 @@ var flushUnusedChannels = function (env, cb, frame) { cb(); }; -var getChannel = function (env, id, callback) { +var channelBytes = function (env, chanName, cb) { + var path = mkPath(env, chanName); + Fs.stat(path, function (err, stats) { + if (err) { return void cb(err); } + cb(undefined, stats.size); + }); +}; + +/*:: +export type ChainPadServer_ChannelInternal_t = { + atime: number, + writeStream: typeof(process.stdout), + whenLoaded: ?Array<(err:?Error, chan:?ChainPadServer_ChannelInternal_t)=>void>, + onError: Array<(?Error)=>void>, + path: string +}; +*/ +var getChannel = function ( + env, + id, + callback /*:(err:?Error, chan:?ChainPadServer_ChannelInternal_t)=>void*/ +) { if (env.channels[id]) { var chan = env.channels[id]; chan.atime = +new Date(); @@ -178,9 +259,9 @@ var getChannel = function (env, id, callback) { }); } var path = mkPath(env, id); - var channel = env.channels[id] = { + var channel /*:ChainPadServer_ChannelInternal_t*/ = env.channels[id] = { atime: +new Date(), - writeStream: undefined, + writeStream: (undefined /*:any*/), whenLoaded: [ callback ], onError: [ ], path: path @@ -193,6 +274,9 @@ var getChannel = function (env, id, callback) { if (err) { delete env.channels[id]; } + if (!channel.writeStream) { + throw new Error("getChannel() complete called without channel writeStream"); + } whenLoaded.forEach(function (wl) { wl(err, (err) ? undefined : channel); }); }; var fileExists; @@ -211,7 +295,7 @@ var getChannel = function (env, id, callback) { var stream = channel.writeStream = Fs.createWriteStream(path, { flags: 'a' }); env.openFiles++; stream.on('open', waitFor()); - stream.on('error', function (err) { + stream.on('error', function (err /*:?Error*/) { env.openFiles--; // this might be called after this nThen block closes. if (channel.whenLoaded) { @@ -228,20 +312,22 @@ var getChannel = function (env, id, callback) { }); }; -var message = function (env, chanName, msg, cb) { +const messageBin = (env, chanName, msgBin, cb) => { getChannel(env, chanName, function (err, chan) { - if (err) { + if (!chan) { cb(err); return; } + let called = false; var complete = function (err) { - var _cb = cb; - cb = undefined; - if (_cb) { _cb(err); } + if (called) { return; } + called = true; + cb(err); }; chan.onError.push(complete); - chan.writeStream.write(msg + '\n', function () { - chan.onError.splice(chan.onError.indexOf(complete) - 1, 1); + chan.writeStream.write(msgBin, function () { + /*::if (!chan) { throw new Error("Flow unreachable"); }*/ + chan.onError.splice(chan.onError.indexOf(complete), 1); if (!cb) { return; } //chan.messages.push(msg); chan.atime = +new Date(); @@ -250,9 +336,13 @@ var message = function (env, chanName, msg, cb) { }); }; +var message = function (env, chanName, msg, cb) { + messageBin(env, chanName, new Buffer(msg + '\n', 'utf8'), cb); +}; + var getMessages = function (env, chanName, handler, cb) { getChannel(env, chanName, function (err, chan) { - if (err) { + if (!chan) { cb(err); return; } @@ -271,21 +361,43 @@ var getMessages = function (env, chanName, handler, cb) { errorState = true; return void cb(err); } + if (!chan) { throw new Error("impossible, flow checking"); } chan.atime = +new Date(); cb(); }); }); }; -var channelBytes = function (env, chanName, cb) { - var path = mkPath(env, chanName); - Fs.stat(path, function (err, stats) { - if (err) { return void cb(err); } - cb(void 0, stats.size); - }); +/*:: +export type ChainPadServer_MessageObj_t = { buff: Buffer, offset: number }; +export type ChainPadServer_Storage_t = { + readMessagesBin: ( + channelName:string, + start:number, + asyncMsgHandler:(msg:ChainPadServer_MessageObj_t, moreCb:()=>void, abortCb:()=>void)=>void, + cb:(err:?Error)=>void + )=>void, + message: (channelName:string, content:string, cb:(err:?Error)=>void)=>void, + messageBin: (channelName:string, content:Buffer, cb:(err:?Error)=>void)=>void, + getMessages: (channelName:string, msgHandler:(msg:string)=>void, cb:(err:?Error)=>void)=>void, + removeChannel: (channelName:string, cb:(err:?Error)=>void)=>void, + closeChannel: (channelName:string, cb:(err:?Error)=>void)=>void, + flushUnusedChannels: (cb:()=>void)=>void, + getChannelSize: (channelName:string, cb:(err:?Error, size:?number)=>void)=>void, + getChannelMetadata: (channelName:string, cb:(err:?Error|string, data:?any)=>void)=>void, + clearChannel: (channelName:string, (err:?Error)=>void)=>void }; - -module.exports.create = function (conf, cb) { +export type ChainPadServer_Config_t = { + verbose?: boolean, + filePath?: string, + channelExpirationMs?: number, + openFileLimit?: number +}; +*/ +module.exports.create = function ( + conf /*:ChainPadServer_Config_t*/, + cb /*:(store:ChainPadServer_Storage_t)=>void*/ +) { var env = { root: conf.filePath || './datastore', channels: { }, @@ -294,15 +406,22 @@ module.exports.create = function (conf, cb) { openFiles: 0, openFileLimit: conf.openFileLimit || 2048, }; - Fs.mkdir(env.root, function (err) { + // 0x1ff -> 777 + Fs.mkdir(env.root, 0x1ff, function (err) { if (err && err.code !== 'EEXIST') { // TODO: somehow return a nice error throw err; } cb({ + readMessagesBin: (channelName, start, asyncMsgHandler, cb) => { + readMessagesBin(env, channelName, start, asyncMsgHandler, cb); + }, message: function (channelName, content, cb) { message(env, channelName, content, cb); }, + messageBin: (channelName, content, cb) => { + messageBin(env, channelName, content, cb); + }, getMessages: function (channelName, msgHandler, cb) { getMessages(env, channelName, msgHandler, cb); }, @@ -331,4 +450,4 @@ module.exports.create = function (conf, cb) { setInterval(function () { flushUnusedChannels(env, function () { }); }, 5000); -}; +}; \ No newline at end of file diff --git a/www/code/orgmode.js b/www/code/orgmode.js index 09e19966b..3377ebb15 100644 --- a/www/code/orgmode.js +++ b/www/code/orgmode.js @@ -7,12 +7,12 @@ define([ CodeMirror.defineSimpleMode("orgmode", { start: [ {regex: /^(^\*{1,6}\s)(TODO|DOING|WAITING|NEXT){0,1}(CANCELLED|CANCEL|DEFERRED|DONE|REJECTED|STOP|STOPPED){0,1}(.*)$/, token: ["header org-level-star", "header org-todo", "header org-done", "header"]}, - {regex: /(^\+[^\/]*\+)/, token: ["strikethrough"]}, - {regex: /(^\*[^\/]*\*)/, token: ["strong"]}, - {regex: /(^\/[^\/]*\/)/, token: ["em"]}, - {regex: /(^\_[^\/]*\_)/, token: ["link"]}, - {regex: /(^\~[^\/]*\~)/, token: ["comment"]}, - {regex: /(^\=[^\/]*\=)/, token: ["comment"]}, + {regex: /(\+[^\+]+\+)/, token: ["strikethrough"]}, + {regex: /(\*[^\*]+\*)/, token: ["strong"]}, + {regex: /(\/[^\/]+\/)/, token: ["em"]}, + {regex: /(\_[^\_]+\_)/, token: ["link"]}, + {regex: /(\~[^\~]+\~)/, token: ["comment"]}, + {regex: /(\=[^\=]+\=)/, token: ["comment"]}, {regex: /\[\[[^\[\]]*\]\[[^\[\]]*\]\]/, token: "url"}, // links {regex: /\[[xX\s]?\]/, token: 'qualifier'}, // checkbox {regex: /\#\+BEGIN_[A-Z]*/, token: "comment", next: "env"}, // comments diff --git a/www/common/application_config_internal.js b/www/common/application_config_internal.js new file mode 100644 index 000000000..24c46b790 --- /dev/null +++ b/www/common/application_config_internal.js @@ -0,0 +1,115 @@ +/* + * This is an internal configuration file. + * If you want to change some configurable values, use the '/customize/application_config.js' + * file (make a copy from /customize.dist/application_config.js) + */ +define(function() { + var config = {}; + + /* Select the buttons displayed on the main page to create new collaborative sessions + * Existing types : pad, code, poll, slide + */ + config.availablePadTypes = ['drive', 'pad', 'code', 'slide', 'poll', 'whiteboard', 'file', 'todo', 'contacts']; + config.registeredOnlyTypes = ['file', 'contacts']; + + /* Cryptpad apps use a common API to display notifications to users + * by default, notifications are hidden after 5 seconds + * You can change their duration here (measured in milliseconds) + */ + config.notificationTimeout = 5000; + config.disableUserlistNotifications = false; + config.hideLoadingScreenTips = false; + + config.enablePinning = true; + + // Update the default colors available in the whiteboard application + config.whiteboardPalette = [ + '#000000', // black + '#FFFFFF', // white + '#848484', // grey + '#8B4513', // saddlebrown + '#FF0000', // red + '#FF8080', // peach? + '#FF8000', // orange + '#FFFF00', // yellow + '#80FF80', // light green + '#00FF00', // green + '#00FFFF', // cyan + '#008B8B', // dark cyan + '#0000FF', // blue + '#FF00FF', // fuschia + '#FF00C0', // hot pink + '#800080', // purple + ]; + + // Set enableTemplates to false to remove the button allowing users to save a pad as a template + // and remove the template category in CryptDrive + config.enableTemplates = true; + + // Set enableHistory to false to remove the "History" button in all the apps. + config.enableHistory = true; + + /* user passwords are hashed with scrypt, and salted with their username. + this value will be appended to the username, causing the resulting hash + to differ from other CryptPad instances if customized. This makes it + such that anyone who wants to bruteforce common credentials must do so + again on each CryptPad instance that they wish to attack. + + WARNING: this should only be set when your CryptPad instance is first + created. Changing it at a later time will break logins for all existing + users. + */ + config.loginSalt = ''; + config.minimumPasswordLength = 8; + + // Amount of time (ms) before aborting the session when the algorithm cannot synchronize the pad + config.badStateTimeout = 30000; + + // Customize the icon used for each application. + // You can update the colors by making a copy of /customize.dist/src/less2/include/colortheme.less + config.applicationsIcon = { + file: 'fa-file-text-o', + pad: 'fa-file-word-o', + code: 'fa-file-code-o', + slide: 'fa-file-powerpoint-o', + poll: 'fa-calendar', + whiteboard: 'fa-paint-brush', + todo: 'fa-tasks', + contacts: 'fa-users', + }; + + // EXPERIMENTAL: Enabling "displayCreationScreen" may cause UI issues and possible loss of data + config.displayCreationScreen = false; + + // Prevent anonymous users from storing pads in their drive + config.disableAnonymousStore = false; + + // Hide the usage bar in settings and drive + //config.hideUsageBar = true; + + // Disable feedback for all the users and hide the settings part about feedback + //config.disableFeedback = true; + + // Add new options in the share modal (extend an existing tab or add a new tab). + // More info about how to use it on the wiki: + // https://github.com/xwiki-labs/cryptpad/wiki/Application-config#configcustomizeshareoptions + //config.customizeShareOptions = function (hashes, tabs, config) {}; + + // Add code to be executed on every page before loading the user object. `isLoggedIn` (bool) is + // indicating if the user is registered or anonymous. Here you can change the way anonymous users + // work in CryptPad, use an external SSO or even force registration + // *NOTE*: You have to call the `callback` function to continue the loading process + //config.beforeLogin = function(isLoggedIn, callback) {}; + + // Add code to be executed on every page after the user object is loaded (also work for + // unregistered users). This allows you to interact with your users' drive + // *NOTE*: You have to call the `callback` function to continue the loading process + //config.afterLogin = function(api, callback) {}; + + // Disabling the profile app allows you to import the profile informations (display name, avatar) + // from an external source and make sure the users can't change them from CryptPad. + // You can use config.afterLogin to import these values in the users' drive. + //config.disableProfile = true; + + return config; +}); diff --git a/www/common/common-feedback.js b/www/common/common-feedback.js index 728d362cd..6d2c62e30 100644 --- a/www/common/common-feedback.js +++ b/www/common/common-feedback.js @@ -1,4 +1,7 @@ -define(['/customize/messages.js'], function (Messages) { +define([ + '/customize/messages.js', + '/customize/application_config.js' +], function (Messages, AppConfig) { var Feedback = {}; Feedback.init = function (state) { @@ -19,6 +22,7 @@ define(['/customize/messages.js'], function (Messages) { http.send(); }; Feedback.send = function (action, force) { + if (AppConfig.disableFeedback) { return; } if (!action) { return; } if (force !== true) { if (!Feedback.state) { return; } diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index 286fb84d6..0100be3f8 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -953,7 +953,32 @@ define([ }; if (!window.Symbol) { return void displayDefault(); } // IE doesn't have Symbol if (!href) { return void displayDefault(); } + + var centerImage = function ($img, $image, img) { + var w = img.width; + var h = img.height; + if (w>h) { + $image.css('max-height', '100%'); + $img.css('flex-direction', 'column'); + if (cb) { cb($img); } + return; + } + $image.css('max-width', '100%'); + $img.css('flex-direction', 'row'); + if (cb) { cb($img); } + }; + var parsed = Hash.parsePadUrl(href); + if (parsed.type !== "file" || parsed.hashData.type !== "file") { + var $img = $('').appendTo($container); + var img = new Image(); + $(img).attr('src', href); + img.onload = function () { + centerImage($img, $(img), img); + $(img).appendTo($img); + }; + return; + } var secret = Hash.getSecrets('file', parsed.hash); if (secret.keys && secret.channel) { var cryptKey = secret.keys && secret.keys.fileKeyStr; @@ -971,17 +996,7 @@ define([ $img.attr('data-crypto-key', 'cryptpad:' + cryptKey); UIElements.displayMediatagImage(Common, $img, function (err, $image, img) { if (err) { return void console.error(err); } - var w = img.width; - var h = img.height; - if (w>h) { - $image.css('max-height', '100%'); - $img.css('flex-direction', 'column'); - if (cb) { cb($img); } - return; - } - $image.css('max-width', '100%'); - $img.css('flex-direction', 'row'); - if (cb) { cb($img); } + centerImage($img, $image, img); }); }); } @@ -1259,7 +1274,7 @@ define([ $userAdminContent.append($userAccount).append(Util.fixHTML(accountName)); $userAdminContent.append($('
')); } - if (config.displayName) { + if (config.displayName && !AppConfig.disableProfile) { // Hide "Display name:" in read only mode $userName.append(Messages.user_displayName + ': '); $userName.append($displayedName); @@ -1282,14 +1297,14 @@ define([ }); } // Add the change display name button if not in read only mode - if (config.changeNameButtonCls && config.displayChangeName) { + if (config.changeNameButtonCls && config.displayChangeName && !AppConfig.disableProfile) { options.push({ tag: 'a', attributes: {'class': config.changeNameButtonCls}, content: Messages.user_rename }); } - if (accountName) { + if (accountName && !AppConfig.disableProfile) { options.push({ tag: 'a', attributes: {'class': 'cp-toolbar-menu-profile'}, diff --git a/www/common/cryptpad-common.js b/www/common/cryptpad-common.js index 775c725ad..5ec198a44 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -727,6 +727,10 @@ define([ }; Nthen(function (waitFor) { + if (AppConfig.beforeLogin) { + AppConfig.beforeLogin(LocalStore.isLoggedIn(), waitFor()); + } + }).nThen(function (waitFor) { var cfg = { query: onMessage, // TODO temporary, will be replaced by a webworker channel userHash: LocalStore.getUserHash(), @@ -763,6 +767,7 @@ define([ } initFeedback(data.feedback); + initialized = true; })); }).nThen(function (waitFor) { // Load the new pad when the hash has changed @@ -829,6 +834,10 @@ define([ delete sessionStorage.migrateAnonDrive; })); } + }).nThen(function (waitFor) { + if (AppConfig.afterLogin) { + AppConfig.afterLogin(common, waitFor()); + } }).nThen(function () { updateLocalVersion(); f(void 0, env); diff --git a/www/common/outer/async-store.js b/www/common/outer/async-store.js index 82cbee00d..68c217108 100644 --- a/www/common/outer/async-store.js +++ b/www/common/outer/async-store.js @@ -341,7 +341,7 @@ define([ // "priv" is not shared with other users but is needed by the apps priv: { edPublic: store.proxy.edPublic, - friends: store.proxy.friends, + friends: store.proxy.friends || {}, settings: store.proxy.settings, thumbnails: !Util.find(store.proxy, ['settings', 'general', 'disableThumbnails']) } diff --git a/www/common/toolbar3.js b/www/common/toolbar3.js index 03cb9d7c1..c7afbaca8 100644 --- a/www/common/toolbar3.js +++ b/www/common/toolbar3.js @@ -238,55 +238,57 @@ define([ var $nameValue = $('', { 'class': 'cp-toolbar-userlist-name-value' }).text(name).appendTo($nameSpan); - var $button = $('