diff --git a/config.example.js b/config.example.js index b40b9506c..78d275f92 100644 --- a/config.example.js +++ b/config.example.js @@ -49,6 +49,20 @@ var baseCSP = [ module.exports = { + /* ===================== + * Admin + * ===================== */ + + /* + * CryptPad now contains an administration panel. Its access is restricted to specific + * users using the following list. + * To give access to the admin panel to a user account, just add their user id, + * which can be found on the settings page for registered users. + * Entries should be strings separated by a comma. + */ + adminKeys: [ + //"https://my.awesome.website/user/#/1/cryptpad-user1/YZgXQxKR0Rcb6r6CmxHPdAGLVludrAF2lEnkbx1vVOo=", + ], /* ===================== * Infra setup diff --git a/customize.dist/src/less2/include/colortheme.less b/customize.dist/src/less2/include/colortheme.less index fce9686a6..abbe07955 100644 --- a/customize.dist/src/less2/include/colortheme.less +++ b/customize.dist/src/less2/include/colortheme.less @@ -131,6 +131,10 @@ @colortheme_kanban-color: #000; @colortheme_kanban-warn: #e6385d; +@colortheme_admin-bg: #7c0404; +@colortheme_admin-color: #FFF; +@colortheme_admin-warn: #ffae00; + // Sidebar layout (profile / settings) @colortheme_sidebar-active: #fff; @colortheme_sidebar-left-bg: #eee; diff --git a/customize.dist/src/less2/include/icon-colors.less b/customize.dist/src/less2/include/icon-colors.less index e6c07579f..5c4909628 100644 --- a/customize.dist/src/less2/include/icon-colors.less +++ b/customize.dist/src/less2/include/icon-colors.less @@ -20,6 +20,7 @@ .cp-icon-color-ooslide { color: @colortheme_ooslide-bg; } .cp-icon-color-sheet { color: @colortheme_oocell-bg; } .cp-icon-color-kanban { color: @colortheme_kanban-bg; } + .cp-icon-color-admin { color: @colortheme_admin-bg; } .cp-border-color-pad { border-color: @colortheme_pad-bg !important; } .cp-border-color-code { border-color: @colortheme_code-bg !important; } @@ -37,5 +38,6 @@ .cp-border-color-ooslide { border-color: @colortheme_ooslide-bg !important; } .cp-border-color-sheet { border-color: @colortheme_oocell-bg !important; } .cp-border-color-kanban { border-color: @colortheme_kanban-bg !important; } + .cp-border-color-admin { border-color: @colortheme_admin-bg !important; } } diff --git a/package.json b/package.json index 22000d101..1a84f2c65 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "sortify": "^1.0.4", "stream-to-pull-stream": "^1.7.2", "tweetnacl": "~0.12.2", - "ws": "^1.0.1" + "ws": "^1.0.1", + "get-folder-size": "^2.0.1" }, "devDependencies": { "flow-bin": "^0.59.0", diff --git a/rpc.js b/rpc.js index 7a0deb343..a078f3abf 100644 --- a/rpc.js +++ b/rpc.js @@ -15,6 +15,7 @@ const Package = require('./package.json'); const Pinned = require('./pinned'); const Saferphore = require("saferphore"); const nThen = require("nthen"); +const getFolderSize = require("get-folder-size"); var RPC = module.exports; @@ -1484,6 +1485,101 @@ var isNewChannel = function (Env, channel, cb) { }); }; +var getDiskUsage = function (Env, cb) { + var data = {}; + nThen(function (waitFor) { + getFolderSize('./', waitFor(function(err, info) { + data.total = info; + })); + getFolderSize(Env.paths.pin, waitFor(function(err, info) { + data.pin = info; + })); + getFolderSize(Env.paths.blob, waitFor(function(err, info) { + data.blob = info; + })); + getFolderSize(Env.paths.staging, waitFor(function(err, info) { + data.blobstage = info; + })); + getFolderSize(Env.paths.block, waitFor(function(err, info) { + data.block = info; + })); + getFolderSize(Env.paths.data, waitFor(function(err, info) { + data.datastore = info; + })); + }).nThen(function () { + cb (void 0, data); + }); +}; +var getRegisteredUsers = function (Env, cb) { + var dir = Env.paths.pin; + var folders; + var users = 0; + nThen(function (waitFor) { + Fs.readdir(dir, waitFor(function (err, list) { + if (err) { + waitFor.abort(); + return void cb(err); + } + folders = list; + })); + }).nThen(function (waitFor) { + folders.forEach(function (f) { + var dir = Env.paths.pin + '/' + f; + Fs.readdir(dir, waitFor(function (err, list) { + if (err) { return; } + users += list.length; + })); + }); + }).nThen(function () { + cb(void 0, users); + }); +}; +var getActiveSessions = function (Env, ctx, cb) { + var total = ctx.users ? Object.keys(ctx.users).length : '?'; + + var ips = []; + Object.keys(ctx.users).forEach(function (u) { + var user = ctx.users[u]; + var socket = user.socket; + var conn = socket.upgradeReq.connection; + if (ips.indexOf(conn.remoteAddress) === -1) { + ips.push(conn.remoteAddress); + } + }); + + cb (void 0, [total, ips.length]); +}; + +var adminCommand = function (Env, ctx, publicKey, config, data, cb) { + var admins = []; + try { + admins = (config.adminKeys || []).map(function (k) { + k = k.replace(/\/+$/, ''); + var s = k.split('/'); + return s[s.length-1]; + }); + } catch (e) { console.error("Can't parse admin keys. Please update or fix your config.js file!"); } + if (admins.indexOf(publicKey) === -1) { + return void cb("FORBIDDEN"); + } + // Handle commands here + switch (data[0]) { + case 'ACTIVE_SESSIONS': + return getActiveSessions(Env, ctx, cb); + case 'ACTIVE_PADS': + return cb(void 0, ctx.channels ? Object.keys(ctx.channels).length : '?'); + case 'REGISTERED_USERS': + return getRegisteredUsers(Env, cb); + case 'DISK_USAGE': + return getDiskUsage(Env, cb); + case 'FLUSH_CACHE': + config.flushCache(); + return cb(void 0, true); + default: + return cb('UNHANDLED_ADMIN_COMMAND'); + } +}; + var isUnauthenticatedCall = function (call) { return [ 'GET_FILE_SIZE', @@ -1516,6 +1612,7 @@ var isAuthenticatedCall = function (call) { 'REMOVE_PINS', 'WRITE_LOGIN_BLOCK', 'REMOVE_LOGIN_BLOCK', + 'ADMIN', ].indexOf(call) !== -1; }; @@ -1587,6 +1684,7 @@ RPC.create = function ( var blobPath = paths.blob = keyOrDefaultString('blobPath', './blob'); var blobStagingPath = paths.staging = keyOrDefaultString('blobStagingPath', './blobstage'); paths.block = keyOrDefaultString('blockPath', './block'); + paths.data = keyOrDefaultString('filePath', './datastore'); var isUnauthenticateMessage = function (msg) { return msg && msg.length === 2 && isUnauthenticatedCall(msg[0]); @@ -1872,6 +1970,14 @@ RPC.create = function ( } Respond(e); }); + case 'ADMIN': + return void adminCommand(Env, ctx, safeKey, config, msg[1], function (e, result) { + if (e) { + WARN(e, result); + return void Respond(e); + } + Respond(void 0, result); + }); default: return void Respond('UNSUPPORTED_RPC_CALL', msg); } diff --git a/server.js b/server.js index 002b3fa82..57a9d2413 100644 --- a/server.js +++ b/server.js @@ -54,6 +54,10 @@ if (FRESH_MODE) { console.log("FRESH MODE ENABLED"); FRESH_KEY = +new Date(); } +config.flushCache = function () { + FRESH_KEY = +new Date(); +}; + const clone = (x) => (JSON.parse(JSON.stringify(x))); @@ -167,6 +171,14 @@ if (config.privKeyAndCertFiles) { app.get('/api/config', function(req, res){ var host = req.headers.host.replace(/\:[0-9]+/, ''); + var admins = []; + try { + admins = (config.adminKeys || []).map(function (k) { + k = k.replace(/\/+$/, ''); + var s = k.split('/'); + return s[s.length-1]; + }); + } catch (e) { console.error("Can't parse admin keys"); } res.setHeader('Content-Type', 'text/javascript'); res.send('define(function(){\n' + [ 'var obj = ' + JSON.stringify({ @@ -180,6 +192,7 @@ app.get('/api/config', function(req, res){ websocketURL:'ws' + ((useSecureWebsockets) ? 's' : '') + '://' + host + ':' + websocketPort + '/cryptpad_websocket', httpUnsafeOrigin: config.httpUnsafeOrigin, + adminKeys: admins, }, null, '\t'), 'obj.httpSafeOrigin = ' + (function () { if (config.httpSafeOrigin) { return '"' + config.httpSafeOrigin + '"'; } diff --git a/www/common/common-constants.js b/www/common/common-constants.js index cfe9b4bcc..9eb3c4075 100644 --- a/www/common/common-constants.js +++ b/www/common/common-constants.js @@ -17,6 +17,6 @@ define(function () { // Sub plan: 'CryptPad_plan', // Apps - criticalApps: ['profile', 'settings', 'debug'] + criticalApps: ['profile', 'settings', 'debug', 'admin'] }; }); diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index 0d9841448..947323fca 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -1634,6 +1634,14 @@ define([ content: h('span', Messages.settingsButton) }); } + // Add administration panel link if the user is an admin + if (priv.edPublic && Array.isArray(Config.adminKeys) && Config.adminKeys.indexOf(priv.edPublic) !== -1) { + options.push({ + tag: 'a', + attributes: {'class': 'cp-toolbar-menu-admin fa fa-cogs'}, + content: h('span', Messages.adminPage || 'Admin') + }); + } // Add login or logout button depending on the current status if (accountName) { options.push({ @@ -1729,6 +1737,13 @@ define([ window.parent.location = origin+'/settings/'; } }); + $userAdmin.find('a.cp-toolbar-menu-admin').click(function () { + if (padType) { + window.open(origin+'/admin/'); + } else { + window.parent.location = origin+'/admin/'; + } + }); $userAdmin.find('a.cp-toolbar-menu-profile').click(function () { if (padType) { window.open(origin+'/profile/'); diff --git a/www/common/cryptpad-common.js b/www/common/cryptpad-common.js index 831b9faa3..674dbb905 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -615,6 +615,11 @@ define([ }); }; + // Admin + common.adminRpc = function (data, cb) { + postMessage("ADMIN_RPC", data, cb); + }; + // Network common.onNetworkDisconnect = Util.mkEvent(); common.onNetworkReconnect = Util.mkEvent(); diff --git a/www/common/outer/async-store.js b/www/common/outer/async-store.js index 59612e882..06ab0f089 100644 --- a/www/common/outer/async-store.js +++ b/www/common/outer/async-store.js @@ -947,6 +947,14 @@ define([ } }; + // Admin + Store.adminRpc = function (clientId, data, cb) { + store.rpc.adminRpc(data, function (err, res) { + if (err) { return void cb({error: err}); } + cb(res); + }); + }; + ////////////////////////////////////////////////////////////////// /////////////////////// PAD ////////////////////////////////////// ////////////////////////////////////////////////////////////////// diff --git a/www/common/outer/store-rpc.js b/www/common/outer/store-rpc.js index 6f2682d63..49bb7c960 100644 --- a/www/common/outer/store-rpc.js +++ b/www/common/outer/store-rpc.js @@ -77,6 +77,8 @@ define([ DRIVE_USEROBJECT: Store.userObjectCommand, // Settings, DELETE_ACCOUNT: Store.deleteAccount, + // Admin + ADMIN_RPC: Store.adminRpc, }; Rpc.query = function (cmd, data, cb) { diff --git a/www/common/pinpad.js b/www/common/pinpad.js index 721f8a94b..c31062718 100644 --- a/www/common/pinpad.js +++ b/www/common/pinpad.js @@ -58,6 +58,18 @@ define([ rpc.send('UNPIN', channels, cb); }; + // Get data for the admin panel + exp.adminRpc = function (obj, cb) { + if (!obj.cmd) { + setTimeout(function () { + cb('[TypeError] admin rpc expects a command'); + }); + return; + } + var params = [obj.cmd, obj.data]; + rpc.send('ADMIN', params, cb); + }; + // ask the server what it thinks your hash is exp.getServerHash = function (cb) { rpc.send('GET_HASH', edPublic, function (e, hash) { diff --git a/www/common/sframe-common-outer.js b/www/common/sframe-common-outer.js index 9b6891caf..b74a743d8 100644 --- a/www/common/sframe-common-outer.js +++ b/www/common/sframe-common-outer.js @@ -409,6 +409,10 @@ define([ setDocumentTitle(); }); + sframeChan.on('EV_SET_HASH', function (hash) { + window.location.hash = hash; + }); + Cryptpad.autoStore.onStoreRequest.reg(function (data) { sframeChan.event("EV_AUTOSTORE_DISPLAY_POPUP", data); }); diff --git a/www/common/sframe-common.js b/www/common/sframe-common.js index 8a3353133..17783f460 100644 --- a/www/common/sframe-common.js +++ b/www/common/sframe-common.js @@ -228,6 +228,10 @@ define([ ctx.sframeChan.event('EV_SET_TAB_TITLE', newTitle); }; + funcs.setHash = function (hash) { + ctx.sframeChan.event('EV_SET_HASH', hash); + }; + funcs.setLoginRedirect = function (cb) { cb = cb || $.noop; ctx.sframeChan.query('Q_SET_LOGIN_REDIRECT', null, cb); diff --git a/www/settings/inner.js b/www/settings/inner.js index ddc8c0745..9457338db 100644 --- a/www/settings/inner.js +++ b/www/settings/inner.js @@ -1511,6 +1511,7 @@ define([ return; } active = key; + common.setHash(key); $categories.find('.cp-leftside-active').removeClass('cp-leftside-active'); $category.addClass('cp-leftside-active'); showCategories(categories[key]);