Merge branch 'admin-network-pane' into staging

pull/1/head
ansuz 4 years ago
commit 0a7e692989

@ -122,28 +122,6 @@ module.exports = {
],
*/
/* We're very proud that CryptPad is available to the public as free software!
* We do, however, still need to pay our bills as we develop the platform.
*
* By default CryptPad will prompt users to consider donating to
* our OpenCollective campaign. We publish the state of our finances periodically
* so you can decide for yourself whether our expenses are reasonable.
*
* You can disable any solicitations for donations by setting 'removeDonateButton' to true,
* but we'd appreciate it if you didn't!
*/
//removeDonateButton: false,
/*
* By default, CryptPad contacts one of our servers once a day.
* This check-in will also send some very basic information about your instance including its
* version and the adminEmail so we can reach you if we are aware of a serious problem.
* We will never sell it or send you marketing mail.
*
* If you want to block this check-in and remain set 'blockDailyCheck' to true.
*/
//blockDailyCheck: false,
/* =====================
* STORAGE
* ===================== */

@ -10,6 +10,7 @@ module.exports.create = function (Env) {
nThen(function (w) {
Decrees.load(Env, w(function (err) {
Env.flushCache();
if (err) {
log.error('DECREES_LOADING', {
error: err.code || err,

@ -317,6 +317,15 @@ var instanceStatus = function (Env, Server, cb) {
maxUploadSize: Env.maxUploadSize,
premiumUploadSize: Env.premiumUploadSize,
consentToContact: Env.consentToContact,
listMyInstance: Env.listMyInstance,
provideAggregateStatistics: Env.provideAggregateStatistics,
removeDonateButton: Env.removeDonateButton,
blockDailyCheck: Env.blockDailyCheck,
updateAvailable: Env.updateAvailable,
});
};

@ -4,9 +4,9 @@ const Quota = module.exports;
//const Util = require("../common-util");
const Keys = require("../keys");
const Package = require('../../package.json');
const Https = require("https");
const Util = require("../common-util");
const Stats = require("../stats");
var validLimitFields = ['limit', 'plan', 'note', 'users', 'origin'];
@ -51,24 +51,66 @@ Quota.applyCustomLimits = function (Env) {
// console.log(Env.limits);
};
var isRemoteVersionNewer = function (local, remote) {
try {
local = local.split('.').map(Number);
remote = remote.split('.').map(Number);
for (var i = 0; i < 3; i++) {
if (remote[i] < local[i]) { return false; }
if (remote[i] > local[i]) { return true; }
}
} catch (err) {
// if anything goes wrong just fall through and return false
// false negatives are better than false positives
}
return false;
};
/*
Env = {
myDomain,
mySubdomain,
adminEmail,
Package.version,
var Assert = require("assert");
[
// remote versions
['4.5.0', '4.5.0', false], // equal semver should not prompt
['4.5.0', '4.5.1', true], // patch versions should prompt
['4.5.0', '4.6.0', true], // minor versions should prompt
['4.5.0', '5.0.0', true], // major versions should prompt
// local
['5.3.1', '4.9.0', false], // newer major should not prompt
['4.7.0', '4.6.0', false], // newer minor should not prompt
['4.7.0', '4.6.1', false], // newer patch should not prompt if other values are greater
].forEach(function (x) {
var result = isRemoteVersionNewer(x[0], x[1]);
Assert.equal(result, x[2]);
});
*/
// check if the remote endpoint reported an available server version
// which is newer than your current version (Env.version)
// if so, set Env.updateAvailable to the URL of its release notes
var checkUpdateAvailability = function (Env, json) {
if (!(json && typeof(json.updateAvailable) === 'string' && typeof(json.version) === 'string')) { return; }
// expects {updateAvailable: 'https://github.com/xwiki-labs/cryptpad/releases/4.7.0', version: '4.7.0'}
// the version string is provided explicitly even though it could be parsed from GitHub's URL
// this will allow old instances to understand responses of arbitrary URLs
// as long as we keep using semver for 'version'
if (!isRemoteVersionNewer(Env.version, json.version)) {
Env.updateAvailable = undefined;
return;
}
Env.updateAvailable = json.updateAvailable;
Env.Log.info('AN_UPDATE_IS_AVAILABLE', {
version: json.version,
updateAvailable: json.updateAvaiable,
});
};
*/
var queryAccountServer = function (Env, cb) {
var done = Util.once(Util.mkAsync(cb));
var body = JSON.stringify({
domain: Env.myDomain,
subdomain: Env.mySubdomain || null,
adminEmail: Env.adminEmail,
version: Package.version
});
var rawBody = Stats.instanceData(Env);
Env.Log.info("SERVER_TELEMETRY", rawBody);
var body = JSON.stringify(rawBody);
var options = {
host: 'accounts.cryptpad.fr',
path: '/api/getauthorized',
@ -92,6 +134,7 @@ var queryAccountServer = function (Env, cb) {
response.on('end', function () {
try {
var json = JSON.parse(str);
checkUpdateAvailability(Env, json);
// don't overwrite the limits with junk data
if (json && json.message === 'EINVAL') { return void cb(); }
done(void 0, json);

@ -34,6 +34,13 @@ SET_MAINTENANCE
SET_ADMIN_EMAIL
SET_SUPPORT_MAILBOX
// COMMUNITY PARTICIPATION AND GOVERNANCE
CONSENT_TO_CONTACT
LIST_MY_INSTANCE
PROVIDE_AGGREGATE_STATISTICS
REMOVE_DONATE_BUTTON
BLOCK_DAILY_CHECK
NOT IMPLEMENTED:
// RESTRICTED REGISTRATION
@ -89,6 +96,21 @@ commands.DISABLE_INTEGRATED_EVICTION = makeBooleanSetter('disableIntegratedEvict
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['DISABLE_INTEGRATED_TASKS', [true]]], console.log)
commands.DISABLE_INTEGRATED_TASKS = makeBooleanSetter('disableIntegratedTasks');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['CONSENT_TO_CONTACT', [true]]], console.log)
commands.CONSENT_TO_CONTACT = makeBooleanSetter('consentToContact');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['LIST_MY_INSTANCE', [true]]], console.log)
commands.LIST_MY_INSTANCE = makeBooleanSetter('listMyInstance');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['PROVIDE_AGGREGATE_STATISTICS', [true]]], console.log)
commands.PROVIDE_AGGREGATE_STATISTICS = makeBooleanSetter('provideAggregateStatistics');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['REMOVE_DONATE_BUTTON', [true]]], console.log)
commands.REMOVE_DONATE_BUTTON = makeBooleanSetter('removeDonateButton');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['BLOCK_DAILY_CHECK', [true]]], console.log)
commands.BLOCK_DAILY_CHECK = makeBooleanSetter('blockDailyCheck');
/*
var isNonNegativeNumber = function (n) {
return !(typeof(n) !== 'number' || isNaN(n) || n < 0);

@ -10,10 +10,21 @@ const Core = require("./commands/core");
const Quota = require("./commands/quota");
const Util = require("./common-util");
const Package = require("../package.json");
module.exports.create = function (config) {
var canonicalizeOrigin = function (s) {
if (typeof(s) === 'undefined') { return; }
return (s || '').trim().replace(/\/+$/, '');
};
module.exports.create = function (config) {
const Env = {
version: Package.version,
httpUnsafeOrigin: canonicalizeOrigin(config.httpUnsafeOrigin),
httpSafeOrigin: canonicalizeOrigin(config.httpSafeOrigin),
removeDonateButton: config.removeDonateButton,
OFFLINE_MODE: false,
FRESH_KEY: '',
FRESH_MODE: true,
@ -97,6 +108,11 @@ module.exports.create = function (config) {
allowSubscriptions: config.allowSubscriptions === true,
blockDailyCheck: config.blockDailyCheck === true,
consentToContact: false,
listMyInstance: false,
provideAggregateStatistics: false,
updateAvailable: undefined,
myDomain: config.myDomain,
mySubdomain: config.mySubdomain, // only exists for the accounts integration
customLimits: {},

@ -0,0 +1,65 @@
/*jshint esversion: 6 */
const Stats = module.exports;
Stats.instanceData = function (Env) {
var data = {
version: Env.version,
domain: Env.myDomain,
subdomain: Env.mySubdomain,
httpUnsafeOrigin: Env.httpUnsafeOrigin,
httpSafeOrigin: Env.httpSafeOrigin,
adminEmail: Env.adminEmail,
consentToContact: Boolean(Env.consentToContact),
};
/* We reserve the right to choose not to include instances
in our public directory at our discretion.
The following details will be included in your telemetry
as factors that may contribute to that decision.
These values are publicly available via /api/config
posting them to our server just makes it easier for us.
*/
if (Env.listMyInstance) {
// clearly indicate that you want to be listed
data.listMyInstance = Env.listMyInstance;
// you should have enabled your admin panel
data.adminKeys = Env.admins.length > 0;
// we expect that you enable your support mailbox
data.supportMailbox = Boolean(Env.supportMailbox);
// do you allow registration?
data.restrictRegistration = Boolean(Env.restrictRegistration);
// have you removed the donate button?
data.removeDonateButton = Boolean(Env.removeDonateButton);
// after how long do you consider a document to be inactive?
data.inactiveTime = Env.inactiveTime;
// how much storage do you offer to registered users?
data.defaultStorageLimit = Env.defaultStorageLimit;
// what size file upload do you permit
data.maxUploadSize = Env.maxUploadSize;
// how long do you retain inactive accounts?
data.accountRetentionTime = Env.accountRetentionTime;
// how long do you retain archived data?
//data.archiveRetentionTime = Env.archiveRetentionTime,
}
// we won't consider instances for public listings
// unless they opt to provide more info about themselves
if (!Env.provideAggregateStatistics) { return data; }
return data;
};

@ -4,7 +4,6 @@
var Express = require('express');
var Http = require('http');
var Fs = require('fs');
var Package = require('./package.json');
var Path = require("path");
var nThen = require("nthen");
var Util = require("./lib/common-util");
@ -16,10 +15,6 @@ var Env = require("./lib/env").create(config);
var app = Express();
var canonicalizeOrigin = function (s) {
return (s || '').trim().replace(/\/+$/, '');
};
var fancyURL = function (domain, path) {
try {
if (domain && path) { return new URL(path, domain).href; }
@ -30,15 +25,10 @@ var fancyURL = function (domain, path) {
(function () {
// you absolutely must provide an 'httpUnsafeOrigin'
if (typeof(config.httpUnsafeOrigin) !== 'string') {
if (typeof(Env.httpUnsafeOrigin) !== 'string') {
throw new Error("No 'httpUnsafeOrigin' provided");
}
config.httpUnsafeOrigin = canonicalizeOrigin(config.httpUnsafeOrigin);
if (typeof(config.httpSafeOrigin) === 'string') {
config.httpSafeOrigin = canonicalizeOrigin(config.httpSafeOrigin);
}
// fall back to listening on a local address
// if httpAddress is not a string
if (typeof(config.httpAddress) !== 'string') {
@ -50,7 +40,7 @@ var fancyURL = function (domain, path) {
config.httpPort = 3000;
}
if (typeof(config.httpSafeOrigin) !== 'string') {
if (typeof(Env.httpSafeOrigin) !== 'string') {
Env.NO_SANDBOX = true;
if (typeof(config.httpSafePort) !== 'number') {
config.httpSafePort = config.httpPort + 1;
@ -76,7 +66,7 @@ var setHeaders = (function () {
}
// next define the base Content Security Policy (CSP) headers
if (typeof(config.contentSecurity) === 'string') {
if (typeof(config.contentSecurity) === 'string') { // XXX deprecate this
headers['Content-Security-Policy'] = config.contentSecurity;
if (!/;$/.test(headers['Content-Security-Policy'])) { headers['Content-Security-Policy'] += ';' }
if (headers['Content-Security-Policy'].indexOf('frame-ancestors') === -1) {
@ -87,14 +77,14 @@ var setHeaders = (function () {
}
} else {
// use the default CSP headers constructed with your domain
headers['Content-Security-Policy'] = Default.contentSecurity(config.httpUnsafeOrigin);
headers['Content-Security-Policy'] = Default.contentSecurity(Env.httpUnsafeOrigin);
}
const padHeaders = Util.clone(headers);
if (typeof(config.padContentSecurity) === 'string') {
padHeaders['Content-Security-Policy'] = config.padContentSecurity;
} else {
padHeaders['Content-Security-Policy'] = Default.padContentSecurity(config.httpUnsafeOrigin);
padHeaders['Content-Security-Policy'] = Default.padContentSecurity(Env.httpUnsafeOrigin);
}
if (Object.keys(headers).length) {
return function (req, res) {
@ -151,7 +141,7 @@ app.head(/^\/common\/feedback\.html/, function (req, res, next) {
app.use('/blob', function (req, res, next) {
if (req.method === 'HEAD') {
Express.static(Path.join(__dirname, (config.blobPath || './blob')), {
Express.static(Path.join(__dirname, Env.paths.blob), {
setHeaders: function (res, path, stat) {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Headers', 'Content-Length');
@ -191,13 +181,13 @@ var mainPagePattern = new RegExp('^\/(' + mainPages.join('|') + ').html$');
app.get(mainPagePattern, Express.static(__dirname + '/customize'));
app.get(mainPagePattern, Express.static(__dirname + '/customize.dist'));
app.use("/blob", Express.static(Path.join(__dirname, (config.blobPath || './blob')), {
app.use("/blob", Express.static(Path.join(__dirname, Env.paths.blob), {
maxAge: Env.DEV_MODE? "0d": "365d"
}));
app.use("/datastore", Express.static(Path.join(__dirname, (config.filePath || './datastore')), {
app.use("/datastore", Express.static(Path.join(__dirname, Env.paths.data), {
maxAge: "0d"
}));
app.use("/block", Express.static(Path.join(__dirname, (config.blockPath || '/block')), {
app.use("/block", Express.static(Path.join(__dirname, Env.paths.block), {
maxAge: "0d",
}));
@ -252,12 +242,12 @@ var serveConfig = makeRouteCache(function (host) {
'var obj = ' + JSON.stringify({
requireConf: {
waitSeconds: 600,
urlArgs: 'ver=' + Package.version + cacheString(),
urlArgs: 'ver=' + Env.version + cacheString(),
},
removeDonateButton: (config.removeDonateButton === true),
allowSubscriptions: (config.allowSubscriptions === true),
removeDonateButton: (Env.removeDonateButton === true),
allowSubscriptions: (Env.allowSubscriptions === true),
websocketPath: config.externalWebsocketURL,
httpUnsafeOrigin: config.httpUnsafeOrigin,
httpUnsafeOrigin: Env.httpUnsafeOrigin,
adminEmail: Env.adminEmail,
adminKeys: Env.admins,
inactiveTime: Env.inactiveTime,
@ -265,10 +255,10 @@ var serveConfig = makeRouteCache(function (host) {
defaultStorageLimit: Env.defaultStorageLimit,
maxUploadSize: Env.maxUploadSize,
premiumUploadSize: Env.premiumUploadSize,
restrictRegistration: Env.restrictRegistration, // FIXME see the race condition in env.js
restrictRegistration: Env.restrictRegistration,
}, null, '\t'),
'obj.httpSafeOrigin = ' + (function () {
if (config.httpSafeOrigin) { return '"' + config.httpSafeOrigin + '"'; }
if (Env.httpSafeOrigin) { return '"' + Env.httpSafeOrigin + '"'; }
if (config.httpSafePort) {
return "(function () { return window.location.origin.replace(/\:[0-9]+$/, ':" +
config.httpSafePort + "'); }())";
@ -332,16 +322,16 @@ nThen(function (w) {
var ps = port === 80? '': ':' + port;
var roughAddress = 'http://' + hostName + ps;
var betterAddress = fancyURL(config.httpUnsafeOrigin);
var betterAddress = fancyURL(Env.httpUnsafeOrigin);
if (betterAddress) {
console.log('Serving content for %s via %s.\n', betterAddress, roughAddress);
} else {
console.log('Serving content via %s.\n', roughAddress);
}
if (!Array.isArray(config.adminKeys)) {
if (!Env.admins.length) {
console.log("Your instance is not correctly configured for safe use in production.\nSee %s for more information.\n",
fancyURL(config.httpUnsafeOrigin, '/checkup/') || 'https://your-domain.com/checkup/'
fancyURL(Env.httpUnsafeOrigin, '/checkup/') || 'https://your-domain.com/checkup/'
);
}
});

@ -85,15 +85,27 @@ define([
'performance': [ // Msg.admin_cat_performance
'cp-admin-refresh-performance',
'cp-admin-performance-profiling',
]
],
'network': [ // Msg.admin_cat_network
'cp-admin-update-available',
'cp-admin-checkup',
'cp-admin-block-daily-check',
//'cp-admin-provide-aggregate-statistics',
'cp-admin-list-my-instance',
'cp-admin-consent-to-contact',
'cp-admin-remove-donate-button',
],
};
var create = {};
var keyToCamlCase = function (key) {
return key.replace(/-([a-z])/g, function (g) { return g[1].toUpperCase(); });
};
var makeBlock = function (key, addButton) { // Title, Hint, maybeButton
// Convert to camlCase for translation keys
var safeKey = key.replace(/-([a-z])/g, function (g) { return g[1].toUpperCase(); });
var safeKey = keyToCamlCase(key);
var $div = $('<div>', {'class': 'cp-admin-' + key + ' cp-sidebarlayout-element'});
$('<label>').text(Messages['admin_'+safeKey+'Title'] || key).appendTo($div);
$('<span>', {'class': 'cp-sidebarlayout-description'})
@ -260,7 +272,7 @@ define([
var $div = makeBlock(key); // Msg.admin_registrationHint, .admin_registrationTitle, .admin_registrationButton
var state = APP.instanceStatus.restrictRegistration;
var $cbox = $(UI.createCheckbox('cp-settings-userfeedback',
var $cbox = $(UI.createCheckbox('cp-settings-' + key,
Messages.admin_registrationTitle,
state, { label: { class: 'noTitle' } }));
var spinner = UI.makeSpinner($cbox);
@ -289,6 +301,55 @@ define([
return $div;
};
var makeAdminCheckbox = function (data) {
return function () {
var state = data.getState();
var key = data.key;
var $div = makeBlock(key);
var labelKey = 'admin_' + keyToCamlCase(key) + 'Label';
var titleKey = 'admin_' + keyToCamlCase(key) + 'Title';
var $cbox = $(UI.createCheckbox('cp-admin-' + key,
Messages[labelKey] || Messages[titleKey],
state, { label: { class: 'noTitle' } }));
var spinner = UI.makeSpinner($cbox);
var $checkbox = $cbox.find('input').on('change', function() {
spinner.spin();
var val = $checkbox.is(':checked') || false;
$checkbox.attr('disabled', 'disabled');
data.query(val, function (state) {
spinner.done();
$checkbox[0].checked = state;
$checkbox.removeAttr('disabled');
});
});
$cbox.appendTo($div);
return $div;
};
};
// Msg.admin_registrationHint, .admin_registrationTitle, .admin_registrationButton
create['registration'] = makeAdminCheckbox({
key: 'registration',
getState: function () {
return APP.instanceStatus.restrictRegistration;
},
query: function (val, setState) {
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'ADMIN_DECREE',
data: ['RESTRICT_REGISTRATION', [val]]
}, function (e, response) {
if (e || response.error) {
UI.warn(Messages.error);
console.error(e, response);
}
APP.updateStatus(function () {
setState(APP.instanceStatus.restrictRegistration);
});
});
},
});
create['email'] = function () {
var key = 'email';
var $div = makeBlock(key, true); // Msg.admin_emailHint, Msg.admin_emailTitle, Msg.admin_emailButton
@ -1663,6 +1724,135 @@ define([
return $div;
};
create['update-available'] = function () { // Messages.admin_updateAvailableTitle.admin_updateAvailableHint.admin_updateAvailableLabel.admin_updateAvailableButton
if (!APP.instanceStatus.updateAvailable) { return; }
var $div = makeBlock('update-available', true);
var updateURL = 'https://github.com/xwiki-labs/cryptpad/releases/latest';
if (typeof(APP.instanceStatus.updateAvailable) === 'string') {
updateURL = APP.instanceStatus.updateAvailable;
}
$div.find('button').click(function () {
common.openURL(updateURL);
});
return $div;
};
create['checkup'] = function () {
var $div = makeBlock('checkup', true); // Messages.admin_checkupButton.admin_checkupHint.admin_checkupTitle
$div.find('button').click(function () {
common.openURL('/checkup/');
});
return $div;
};
create['consent-to-contact'] = makeAdminCheckbox({ // Messages.admin_consentToContactTitle.admin_consentToContactHint.admin_consentToContactLabel
key: 'consent-to-contact',
getState: function () {
return APP.instanceStatus.consentToContact;
},
query: function (val, setState) {
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'ADMIN_DECREE',
data: ['CONSENT_TO_CONTACT', [val]]
}, function (e, response) {
if (e || response.error) {
UI.warn(Messages.error);
console.error(e, response);
}
APP.updateStatus(function () {
setState(APP.instanceStatus.consentToContact);
});
});
},
});
create['list-my-instance'] = makeAdminCheckbox({ // Messages.admin_listMyInstanceTitle.admin_listMyInstanceHint.admin_listMyInstanceLabel
key: 'list-my-instance',
getState: function () {
return APP.instanceStatus.listMyInstance;
},
query: function (val, setState) {
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'ADMIN_DECREE',
data: ['LIST_MY_INSTANCE', [val]]
}, function (e, response) {
if (e || response.error) {
UI.warn(Messages.error);
console.error(e, response);
}
APP.updateStatus(function () {
setState(APP.instanceStatus.listMyInstance);
});
});
},
});
create['provide-aggregate-statistics'] = makeAdminCheckbox({ // Messages.admin_provideAggregateStatisticsTitle.admin_provideAggregateStatisticsHint.admin_provideAggregateStatisticsLabel
key: 'provide-aggregate-statistics',
getState: function () {
return APP.instanceStatus.provideAggregateStatistics;
},
query: function (val, setState) {
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'ADMIN_DECREE',
data: ['PROVIDE_AGGREGATE_STATISTICS', [val]]
}, function (e, response) {
if (e || response.error) {
UI.warn(Messages.error);
console.error(e, response);
}
APP.updateStatus(function () {
setState(APP.instanceStatus.provideAggregateStatistics);
});
});
},
});
create['remove-donate-button'] = makeAdminCheckbox({ // Messages.admin_removeDonateButtonTitle.admin_removeDonateButtonHint.admin_removeDonateButtonLabel
key: 'remove-donate-button',
getState: function () {
return APP.instanceStatus.removeDonateButton;
},
query: function (val, setState) {
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'ADMIN_DECREE',
data: ['REMOVE_DONATE_BUTTON', [val]]
}, function (e, response) {
if (e || response.error) {
UI.warn(Messages.error);
console.error(e, response);
}
APP.updateStatus(function () {
setState(APP.instanceStatus.removeDonateButton);
});
});
},
});
create['block-daily-check'] = makeAdminCheckbox({ // Messages.admin_blockDailyCheckTitle.admin_blockDailyCheckHint.admin_blockDailyCheckLabel
key: 'block-daily-check',
getState: function () {
return APP.instanceStatus.blockDailyCheck;
},
query: function (val, setState) {
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'ADMIN_DECREE',
data: ['BLOCK_DAILY_CHECK', [val]]
}, function (e, response) {
if (e || response.error) {
UI.warn(Messages.error);
console.error(e, response);
}
APP.updateStatus(function () {
setState(APP.instanceStatus.blockDailyCheck);
});
});
},
});
var hideCategories = function () {
APP.$rightside.find('> div').hide();
};
@ -1680,6 +1870,7 @@ define([
support: 'fa fa-life-ring',
broadcast: 'fa fa-bullhorn',
performance: 'fa fa-heartbeat',
network: 'fa fa-sitemap', // or fa-university ?
};
var createLeftside = function () {

Loading…
Cancel
Save