// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors // // SPDX-License-Identifier: AGPL-3.0-or-later const Quota = module.exports; //const Util = require("../common-util"); const Keys = require("../keys"); const Https = require("https"); const Http = require("http"); const Util = require("../common-util"); const Stats = require("../stats"); const Admin = require("./admin-rpc.js"); const nThen = require('nthen'); var validLimitFields = ['limit', 'plan', 'note', 'users', 'origin']; Quota.isValidLimit = function (o) { var valid = o && typeof(o) === 'object' && typeof(o.limit) === 'number' && typeof(o.plan) === 'string' && typeof(o.note) === 'string' && // optionally contains a 'users' array (Array.isArray(o.users) || typeof(o.users) === 'undefined') && // check that the object contains only the expected fields !Object.keys(o).some(function (k) { return validLimitFields.indexOf(k) === -1; }); return valid; }; Quota.applyCustomLimits = function (Env) { // DecreedLimits > customLimits > serverLimits; // FIXME perform an integrity check on shared limits // especially relevant because we use Env.limits // when considering whether to archive inactive accounts // read custom limits from the Environment (taken from config) var customLimits = (function (custom) { var limits = {}; Object.keys(custom).forEach(function (k) { var unsafeKey = Keys.canonicalize(k); if (!unsafeKey) { return; } limits[unsafeKey] = custom[k]; }); return limits; }(Env.customLimits || {})); Env.limits = Env.limits || {}; Object.keys(customLimits).forEach(function (k) { if (!Quota.isValidLimit(customLimits[k])) { return; } Env.limits[k] = customLimits[k]; }); // 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; }; /* 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/cryptpad/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 rawBody = Stats.instanceData(Env); let send = () => { Env.Log.info("SERVER_TELEMETRY", rawBody); var body = JSON.stringify(rawBody); var options = { host: 'accounts.cryptpad.fr', path: '/api/getauthorized', method: 'POST', headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } }; var req = Https.request(options, function (response) { if (!('' + response.statusCode).match(/^2\d\d$/)) { return void cb('SERVER ERROR ' + response.statusCode); } var str = ''; response.on('data', function (chunk) { str += chunk; }); response.on('end', function () { try { var json = JSON.parse(str); checkUpdateAvailability(Env, json); done(void 0, json); } catch (e) { done(e); } }); }); req.on('error', function () { done(); }); req.end(body); }; if (Env.provideAggregateStatistics) { let stats = {}; nThen(waitFor => { Admin.getRegisteredUsers(Env, null, waitFor((err, data) => { if (err) { return; } stats.registered = data.users; if (Env.lastPingRegisteredUsers) { stats.usersDiff = stats.registered - Env.lastPingRegisteredUsers; } Env.lastPingRegisteredUsers = stats.registered; })); }).nThen(() => { if (Env.maxConcurrentWs) { stats.maxConcurrentWs = Env.maxConcurrentWs; Env.maxConcurrentWs = 0; } if (Env.maxConcurrentUniqueWs) { stats.maxConcurrentUniqueIPs = Env.maxConcurrentUniqueWs; Env.maxConcurrentUniqueWs = 0; } if (Env.maxConcurrentRegUsers) { stats.maxConcurrentRegUsers = Env.maxConcurrentRegUsers; Env.maxConcurrentRegUsers = 0; } if (Env.maxActiveChannels) { stats.maxConcurrentChannels = Env.maxActiveChannels; Env.maxActiveChannels = 0; } rawBody.statistics = stats; send(); }); return; } send(); }; Quota.shouldContactServer = function (Env) { return !(Env.blockDailyCheck === true || ( typeof(Env.blockDailyCheck) === 'undefined' && Env.adminEmail === false ) ); }; Quota.pingAccountsDaily = function (Env, _cb) { var cb = Util.mkAsync(_cb); if (!Quota.shouldContactServer(Env)) { return void cb(); } queryAccountServer(Env, function (err) { cb(err); }); }; var queryQuotaServer = function (Env, cb) { var done = Util.once(Util.mkAsync(cb)); var rawBody = Stats.instanceData(Env); Env.Log.info("QUOTA_UPDATE", rawBody); var body = JSON.stringify(rawBody); var options = { host: undefined, path: '/api/getquota', method: 'POST', headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } }; const accountsOrigin = Env.accounts_api; var H = Https; if (typeof(accountsOrigin) === 'string') { try { let url = new URL(accountsOrigin); if (!['https:', 'http:'].includes(url.protocol)) { throw new Error("INVALID_PROTOCOL"); } if (url.protocol === 'http:') { H = Http; } let port = Number(url.port); if (port && typeof(port) === 'number') { options.port = port; } options.host = url.hostname; Env.Log.info("USING_CUSTOM_ACCOUNTS_API", { value: accountsOrigin, }); } catch (err) { Env.Log.error("INVALID_CUSTOM_QUOTA_API", { error: err.message, value: accountsOrigin, }); } } var req = H.request(options, function (response) { if (!('' + response.statusCode).match(/^2\d\d$/)) { return void cb('SERVER ERROR ' + response.statusCode); } var str = ''; response.on('data', function (chunk) { str += chunk; }); response.on('end', function () { try { var json = JSON.parse(str); // don't overwrite the limits with junk data if (json && json.message === 'EINVAL') { return void done(); } done(void 0, json); } catch (e) { done(e); } }); }); req.on('error', function (e) { Quota.applyCustomLimits(Env); done(e); }); req.end(body); }; Quota.queryQuotaServer = function (Env, cb) { Env.batchAccountQuery('', cb, function (done) { queryQuotaServer(Env, done); }); }; Quota.updateCachedLimits = function (Env, _cb) { var cb = Util.mkAsync(_cb); if (!Env.accounts_api) { Quota.applyCustomLimits(Env); return void cb(); } Quota.queryQuotaServer(Env, function (err, json) { if (err) { return void cb(err); } if (!json) { return void cb(); } for (var k in json) { if (k.length === 44 && json[k]) { json[k].origin = 'remote'; } } Env.limits = json; Quota.applyCustomLimits(Env); cb(); }); }; // The limits object contains storage limits for all the publicKey that have paid // To each key is associated an object containing the 'limit' value and a 'note' explaining that limit Quota.getUpdatedLimit = function (Env, safeKey, cb) { Quota.updateCachedLimits(Env, function (err) { if (err) { return void cb(err); } var limit = Env.limits[safeKey]; if (limit && typeof(limit.limit) === 'number') { return void cb(void 0, [limit.limit, limit.plan, limit.note]); } return void cb(void 0, [Env.defaultStorageLimit, '', '']); }); };