diff --git a/config/config.example.js b/config/config.example.js index d05b985af..69f0b1e91 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -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 * ===================== */ diff --git a/lib/api.js b/lib/api.js index 7152f75b9..277d085fa 100644 --- a/lib/api.js +++ b/lib/api.js @@ -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, diff --git a/lib/commands/admin-rpc.js b/lib/commands/admin-rpc.js index 9ab86bdf2..ab48a1086 100644 --- a/lib/commands/admin-rpc.js +++ b/lib/commands/admin-rpc.js @@ -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, }); }; diff --git a/lib/commands/quota.js b/lib/commands/quota.js index b779d8e56..1bf9203f6 100644 --- a/lib/commands/quota.js +++ b/lib/commands/quota.js @@ -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); diff --git a/lib/decrees.js b/lib/decrees.js index eed840057..960ece1ef 100644 --- a/lib/decrees.js +++ b/lib/decrees.js @@ -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); diff --git a/lib/env.js b/lib/env.js index 389f059f6..6f1717c09 100644 --- a/lib/env.js +++ b/lib/env.js @@ -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: {}, diff --git a/lib/stats.js b/lib/stats.js new file mode 100644 index 000000000..d1da0e202 --- /dev/null +++ b/lib/stats.js @@ -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; +}; + diff --git a/server.js b/server.js index d92540399..72779c5f5 100644 --- a/server.js +++ b/server.js @@ -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/' ); } }); diff --git a/www/admin/inner.js b/www/admin/inner.js index 1e3485a9e..f8bcad681 100644 --- a/www/admin/inner.js +++ b/www/admin/inner.js @@ -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 = $('
', {'class': 'cp-admin-' + key + ' cp-sidebarlayout-element'}); $('