Merge branch '2025.3-test' into workerapimerge

This commit is contained in:
yflory 2025-01-28 11:43:42 +01:00
commit ccedfc9e58
33 changed files with 1810 additions and 976 deletions

View File

@ -135,6 +135,24 @@ define(req, function(AppConfig, Default, Language) {
return text;
}
};
// XXX
Messages.admin_cat_admins = "Administrators";
Messages.admin_admin = "Admin";
Messages.admin_listAdminsTitle = "Current administrators";
Messages.admin_listAdminsHint = "View and remove administrators";
Messages.admin_addAdminsTitle = "Add administrators";
Messages.admin_addAdminsHint = "Add administrators from their public key or from your contacts list";
Messages.admin_addAdminsAdd = "Promote a contact to admin";
Messages.admin_addKeyLabel = "Add an admin using their public key";
Messages.admin_listName = "Admin name";
Messages.admin_listKey = "Admin key";
Messages.admin_listAction = "Remove admin rights";
Messages.admin_listHardcoded = "Admin added into config.js. Can only be removed by editing the config file.";
Messages.admin_listConfirm = "Are you sure you want to remove the admin rights of this user?";
return Messages;
});

View File

@ -7,6 +7,7 @@
@import (reference) "/customize/src/less2/include/colortheme-all.less";
@import (reference) "/customize/src/less2/include/leftside-menu.less";
@import (reference) "/customize/src/less2/include/browser.less";
@import (reference) "/customize/src/less2/include/variables.less";
@sidebar_block-width: 25rem;;
@sidebar_base-margin: 0.5rem;
@ -121,6 +122,12 @@
flex-flow: column;
align-items: baseline;
}
.cp-usergrid-container {
margin-bottom: 0px !important;
.cp-usergrid-grid {
margin-bottom: -3px;
}
}
label {
margin-bottom: 0;
}
@ -128,6 +135,9 @@
font-family: inherit;
max-width: @sidebar_block-width;
}
input:invalid {
border: 1px solid red;
}
[type="color"] {
width: @sidebar_block-width/5;
padding: 3px;
@ -214,7 +224,7 @@
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
border-left: 0px;
height: 40px;
height: @variables_input-height;
margin: 0 !important;
}
}
@ -236,6 +246,12 @@
margin-bottom: 0;
}
}
.cp-sidebarlayout-description-item {
display: block;
color: @cp_sidebar-hint;
margin-top: @sidebar_base-margin;
margin-bottom: 0;
}
label.noTitle {
display: inline-flex;
.fa {

View File

@ -27,6 +27,31 @@ nThen(function (w) {
console.error(err);
}
}));
}).nThen(function () {
if (Env.proofsMigrated) { return; }
const { Worker } = require('node:worker_threads');
const Admin = require("./commands/admin-rpc");
const worker = new Worker('./scripts/migrations/migrate-blob-proofs.js');
worker.on('message', message => {
if (message === 'READY') {
log.info('BLOB_PROOFS_MIGRATION');
return void worker.postMessage({
start: 1,
});
}
if (message === 'MIGRATED') {
return void log.info('BLOB_PROOFS_DELETION');
}
if (message === 'CLEANED') {
log.info('BLOB_PROOFS_MIGRATED');
Admin.sendDecree(Env, null, function (err) {
if (err) { return void log.error('BLOB_PROOF', err); }
Env.flushCache();
}, ['PROOFS_MIGRATED', ['PROOFS_MIGRATED', 1]], 'server');
}
});
}).nThen(function (w) {
let admins = Env.admins || [];

View File

@ -13,6 +13,7 @@ const Metadata = require("./commands/metadata");
const Meta = require("./metadata");
const Logger = require("./log");
const plugins = require("./plugin-manager");
const HK = require('./hk-util');
let SSOUtils = plugins.SSO && plugins.SSO.utils;
@ -53,7 +54,13 @@ const init = (cb) => {
Env.computeMetadata = function (channel, cb) {
const ref = {};
const lineHandler = Meta.createLineHandler(ref, (err) => { console.log(err); });
return void Env.store.readChannelMetadata(channel, lineHandler, function (err) {
let f = Env.store.readChannelMetadata;
if (channel.length === HK.BLOB_ID_LENGTH) {
f = Env.blobStore.readMetadata;
}
return void f(channel, lineHandler, function (err) {
if (err) {
// stream errors?
return void cb(err);
@ -132,9 +139,12 @@ COMMANDS.start = (edPublic, blockId, reason) => {
n = n((w) => {
// Blobs
if (Env.blobStore.isFileId(chanId)) {
return void Env.blobStore.isOwnedBy(safeKey, chanId, w((err, owned) => {
if (err || !owned) { return; }
blobsToArchive.push(chanId);
return Env.computeMetadata(chanId, w((e, md) => {
if (e || !md) { return; }
if (md && md.owners
&& md.owners.includes(edPublic)) {
blobsToArchive.push(chanId);
}
}));
}
// Pads

View File

@ -18,9 +18,11 @@ const MFA = require("../storage/mfa");
const ArchiveAccount = require('../archive-account');
const { Worker } = require('node:worker_threads');
const Fse = require("fs-extra");
const Fs = require("fs");
const config = require("../load-config");
const Keys = require("../keys");
var Admin = module.exports;
var getFileDescriptorCount = function (Env, server, cb) {
@ -409,7 +411,7 @@ var getChannelMetadata = function (Env, Server, cb, data) {
};
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['RESTRICT_REGISTRATION', [true]]], console.log)
var adminDecree = function (Env, Server, cb, data, unsafeKey) {
var adminDecree = Admin.sendDecree = function (Env, Server, cb, data, unsafeKey) {
var value = data[1];
if (!Array.isArray(value)) { return void cb('INVALID_DECREE'); }
@ -468,7 +470,29 @@ var setLastEviction = function (Env, Server, cb, data, unsafeKey) {
};
// CryptPad_AsyncStore.rpc.send('ADMIN', ['INSTANCE_STATUS], console.log)
const getAdminsData = (Env) => {
return Env.adminsData.map(str => {
// str is either a full public key or just the ed part
const edPublic = Keys.canonicalize(str);
const hardcoded = Array.isArray(config?.adminKeys) &&
config.adminKeys.some(key => {
return Keys.canonicalize(key) === edPublic;
});
if (str.length === 44) {
return { edPublic, first: true, hardcoded };
}
let name;
try {
const parsed = Keys.parseUser(str);
name = parsed.user;
} catch (e) {}
return {
edPublic, hardcoded, name
};
});
};
var instanceStatus = function (Env, Server, cb) {
cb(void 0, {
appsToDisable: Env.appsToDisable,
@ -514,6 +538,8 @@ var instanceStatus = function (Env, Server, cb) {
instanceName: Env.instanceName,
instanceNotice: Env.instanceNotice,
enforceMFA: Env.enforceMFA,
admins: getAdminsData(Env)
});
};
@ -1138,6 +1164,19 @@ Admin.command = function (Env, safeKey, data, _cb, Server) {
var command = commands[data[0]];
Object.keys(Env.plugins || {}).forEach(name => {
let plugin = Env.plugins[name];
if (!plugin.addAdminCommands) { return; }
try {
let c = plugin.addAdminCommands(Env);
Object.keys(c || {}).forEach(cmd => {
if (typeof(c[cmd]) !== "function") { return; }
if (commands[cmd]) { return; }
commands[cmd] = c[cmd];
});
} catch (e) {}
});
if (typeof(command) === 'function') {
return void command(Env, Server, cb, data, unsafeKey);
}

View File

@ -13,7 +13,8 @@ Data.getMetadataRaw = function (Env, channel /* channelName */, _cb) {
const cb = Util.once(Util.mkAsync(_cb));
if (!Core.isValidId(channel)) { return void cb('INVALID_CHAN'); }
if (channel.length !== HK.STANDARD_CHANNEL_LENGTH &&
channel.length !== HK.ADMIN_CHANNEL_LENGTH) { return cb("INVALID_CHAN_LENGTH"); }
channel.length !== HK.ADMIN_CHANNEL_LENGTH &&
channel.length !== HK.BLOB_ID_LENGTH) { return cb("INVALID_CHAN_LENGTH"); }
// return synthetic metadata for admin broadcast channels as a safety net
// in case anybody manages to write metadata
@ -79,6 +80,7 @@ Data.setMetadata = function (Env, safeKey, data, cb, Server) {
var channel = data.channel;
var command = data.command;
// XXX BLOBMD allow blobs
if (!channel || !Core.isValidId(channel)) { return void cb ('INVALID_CHAN'); }
if (!command || typeof (command) !== 'string') { return void cb('INVALID_COMMAND'); }
if (Meta.commands.indexOf(command) === -1) { return void cb('UNSUPPORTED_COMMAND'); }
@ -140,6 +142,7 @@ Data.setMetadata = function (Env, safeKey, data, cb, Server) {
cb(void 0, metadata);
return void next();
}
// XXX BLOBMD use correct store for blobs
Env.msgStore.writeMetadata(channel, JSON.stringify(line), function (e) {
if (e) {
cb(e);
@ -155,6 +158,7 @@ Data.setMetadata = function (Env, safeKey, data, cb, Server) {
// update the cached metadata
metadata_cache[channel] = metadata;
Env.checkCache(channel); // XXX ???
// it's easy to check if the channel is restricted
const isRestricted = metadata.restricted;

140
lib/decrees-core.js Normal file
View File

@ -0,0 +1,140 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
var Decrees = module.exports;
var Util = require("./common-util");
var Fs = require("fs");
var Path = require("path");
var readFileBin = require("./stream-file").readFileBin;
var Schedule = require("./schedule");
var Fse = require("fs-extra");
var nThen = require("nthen");
const Utils = Decrees.Utils = {};
var isString = (str) => {
return typeof(str) === "string";
};
var isInteger = function (n) {
return !(typeof(n) !== 'number' || isNaN(n) || (n % 1) !== 0);
};
Utils.args_isBoolean = function (args) {
return !(!Array.isArray(args) || typeof(args[0]) !== 'boolean');
};
Utils.args_isString = function (args) {
return !(!Array.isArray(args) || !isString(args[0]));
};
Utils.args_isInteger = function (args) {
return !(!Array.isArray(args) || !isInteger(args[0]));
};
Utils.args_isPositiveInteger = function (args) {
return Array.isArray(args) && isInteger(args[0]) && args[0] > 0;
};
Decrees.create = (name, commands) => {
// [<command>, <args>, <author>, <time>]
const handleCommand = function (Env, line) {
var command = line[0];
var args = line[1];
if (typeof(commands[command]) !== 'function') {
throw new Error("DECREE_UNSUPPORTED_COMMAND");
}
var outcome = commands[command](Env, args);
if (outcome) {
// trigger Env change event...
Env.envUpdated.fire();
}
return outcome;
};
const createLineHandler = function (Env) {
var Log = Env.Log;
var index = -1;
return function (err, line) {
index++;
if (err) {
// Log the error and bail out
return void Log.error("DECREE_LINE_ERR", {
error: err.message,
index: index,
line: line,
});
}
if (Array.isArray(line)) {
try {
return void handleCommand(Env, line);
} catch (err2) {
return void Log.error("DECREE_COMMAND_ERR", {
error: err2.message,
index: index,
line: line,
});
}
}
Log.error("DECREE_HANDLER_WEIRD_LINE", {
line: line,
index: index,
});
};
};
const load = function (Env, _cb) {
Env.scheduleDecree = Env.scheduleDecree || Schedule();
var cb = Util.once(Util.mkAsync(function (err) {
if (err && err.code !== 'ENOENT') {
return void _cb(err);
}
_cb();
}));
Env.scheduleDecree.blocking('', function (unblock) {
var done = Util.once(Util.both(cb, unblock));
nThen(function (w) {
// ensure that the path to the decree log exists
Fse.mkdirp(Env.paths.decree, w(function (err) {
if (!err) { return; }
w.abort();
done(err);
}));
}).nThen(function () {
var decreeName = Path.join(Env.paths.decree, name);
var stream = Fs.createReadStream(decreeName, {start: 0});
var handler = createLineHandler(Env);
readFileBin(stream, function (msgObj, next) {
var text = msgObj.buff.toString('utf8');
try {
handler(void 0, JSON.parse(text));
} catch (err) {
handler(err, text);
}
next();
}, function (err) {
done(err);
});
});
});
};
const write = function (Env, decree, _cb) {
var path = Path.join(Env.paths.decree, name);
Env.scheduleDecree.ordered('', function (next) {
var cb = Util.both(Util.mkAsync(_cb), next);
Fs.appendFile(path, JSON.stringify(decree) + '\n', cb);
});
};
return {
handleCommand,
load,
write
};
};

View File

@ -1,9 +1,91 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
// SPDX-FileCopyrightText: 2025 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
var Decrees = module.exports;
var Core = require("./commands/core");
const Core = require("./commands/core");
const DecreesCore = require("./decrees-core");
const config = require('./load-config');
const Quota = require("./commands/quota");
const Keys = require("./keys");
const DECREE_NAME = 'decree.ndjson';
const {
args_isBoolean,
args_isString,
args_isInteger,
args_isPositiveInteger
} = DecreesCore.Utils;
// Toggles a simple boolean
const makeBooleanSetter = function (attr) {
return function (Env, args) {
if (!args_isBoolean(args)) {
throw new Error('INVALID_ARGS');
}
var bool = args[0];
if (bool === Env[attr]) { return false; }
Env[attr] = bool;
return true;
};
};
const default_validator = function () { return true; };
const makeGenericSetter = function (attr, validator) {
validator = validator || default_validator;
return function (Env, args) {
if (!validator(args)) {
throw new Error("INVALID_ARGS");
}
var value = args[0];
if (value === Env[attr]) { return false; }
Env[attr] = value;
return true;
};
};
const makeIntegerSetter = function (attr) {
return makeGenericSetter(attr, args_isInteger);
};
const makeTranslation = function (attr) {
return function (Env, args) {
if (!Array.isArray(args)) { throw new Error("INVALID_ARGS"); }
var value = args[0];
var state = Env[attr];
if (typeof(value) === 'string') {
if (state.default === value) { return false; }
state.default = value;
return true;
}
if (value && typeof(value) === 'object') {
var changed = false;
Object.keys(value).forEach(function (lang) {
if (state[lang] === value[lang]) { return; }
state[lang] = value[lang];
changed = true;
});
return changed;
}
return false;
};
};
/* commands have a simple API:
* they receive the global Env and the arguments to be applied
* if the arguments are invalid the operation will not be applied
* the command throws
* if the arguments are valid but do not result in a change, the operation is redundant.
* return false
* if the arguments are valid and will result in a change, the operation should be applied
* apply it
* return true to indicate that it was applied
*/
const commands = {};
/* Admin decrees which modify global server state
@ -75,33 +157,26 @@ RM_ADMIN_KEY
*/
var commands = {};
/* commands have a simple API:
* they receive the global Env and the arguments to be applied
* if the arguments are invalid the operation will not be applied
* the command throws
* if the arguments are valid but do not result in a change, the operation is redundant.
* return false
* if the arguments are valid and will result in a change, the operation should be applied
* apply it
* return true to indicate that it was applied
*/
var args_isBoolean = function (args) {
return !(!Array.isArray(args) || typeof(args[0]) !== 'boolean');
// Maintenance: Empty string or an object with a start and end time
const isNumber = function (value) {
return typeof(value) === "number" && !isNaN(value);
};
const args_isMaintenance = function (args) {
return Array.isArray(args) && args[0] &&
(args[0] === "" || (isNumber(args[0].end) && isNumber(args[0].start)));
};
// Toggles a simple boolean
var makeBooleanSetter = function (attr) {
// we anticipate that we'll add language-specific surveys in the future
// whenever that happens we can relax validation a bit to support more formats
const makeBroadcastSetter = function (attr, validation) {
return function (Env, args) {
if (!args_isBoolean(args)) {
if ((validation && !validation(args)) && !args_isString(args)) {
throw new Error('INVALID_ARGS');
}
var bool = args[0];
if (bool === Env[attr]) { return false; }
Env[attr] = bool;
var str = args[0];
if (str === Env[attr]) { return false; }
Env[attr] = str;
Env.broadcastCache = {};
return true;
};
};
@ -139,49 +214,6 @@ 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);
};
*/
var default_validator = function () { return true; };
var makeGenericSetter = function (attr, validator) {
validator = validator || default_validator;
return function (Env, args) {
if (!validator(args)) {
throw new Error("INVALID_ARGS");
}
var value = args[0];
if (value === Env[attr]) { return false; }
Env[attr] = value;
return true;
};
};
var isString = (str) => {
return typeof(str) === "string";
};
var isInteger = function (n) {
return !(typeof(n) !== 'number' || isNaN(n) || (n % 1) !== 0);
};
var args_isString = function (args) {
return !(!Array.isArray(args) || !isString(args[0]));
};
var args_isInteger = function (args) {
return !(!Array.isArray(args) || !isInteger(args[0]));
};
var makeIntegerSetter = function (attr) {
return makeGenericSetter(attr, args_isInteger);
};
var arg_isPositiveInteger = function (args) {
return Array.isArray(args) && isInteger(args[0]) && args[0] > 0;
};
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_LOGO_MIME', ['image/png']]], console.log)
commands.SET_LOGO_MIME = makeGenericSetter('logoMimeType', args_isString);
@ -192,7 +224,7 @@ commands.SET_ACCENT_COLOR = makeGenericSetter('accentColor', args_isString);
commands.ENABLE_PROFILING = makeBooleanSetter('enableProfiling');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_PROFILING_WINDOW', [10000]]], console.log)
commands.SET_PROFILING_WINDOW = makeGenericSetter('profilingWindow', arg_isPositiveInteger);
commands.SET_PROFILING_WINDOW = makeGenericSetter('profilingWindow', args_isPositiveInteger);
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_MAX_UPLOAD_SIZE', [50 * 1024 * 1024]]], console.log)
commands.SET_MAX_UPLOAD_SIZE = makeIntegerSetter('maxUploadSize');
@ -246,30 +278,6 @@ commands.SET_SUPPORT_KEYS = function (Env, args) {
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_PURPOSE', ["development"]]], console.log)
commands.SET_INSTANCE_PURPOSE = makeGenericSetter('instancePurpose', args_isString);
var makeTranslation = function (attr) {
return function (Env, args) {
if (!Array.isArray(args)) { throw new Error("INVALID_ARGS"); }
var value = args[0];
var state = Env[attr];
if (typeof(value) === 'string') {
if (state.default === value) { return false; }
state.default = value;
return true;
}
if (value && typeof(value) === 'object') {
var changed = false;
Object.keys(value).forEach(function (lang) {
if (state[lang] === value[lang]) { return; }
state[lang] = value[lang];
changed = true;
});
return changed;
}
return false;
};
};
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_JURISDICTION', ['France']]], console.log)
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_JURISDICTION', [{default:'France',de:'Frankreich'}]]], console.log)
commands.SET_INSTANCE_JURISDICTION = makeTranslation('instanceJurisdiction');
@ -286,30 +294,6 @@ commands.SET_INSTANCE_DESCRIPTION = makeTranslation('instanceDescription');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_NOTICE', [{default:'Our hosting costs have increased during the pandemic. Please consider donating!',fr:'Nos coûts d'hébergement ont augmenté pendant la pandémie. Veuillez envisager de faire un don !']]], console.log)
commands.SET_INSTANCE_NOTICE = makeTranslation('instanceNotice');
// Maintenance: Empty string or an object with a start and end time
var isNumber = function (value) {
return typeof(value) === "number" && !isNaN(value);
};
var args_isMaintenance = function (args) {
return Array.isArray(args) && args[0] &&
(args[0] === "" || (isNumber(args[0].end) && isNumber(args[0].start)));
};
// we anticipate that we'll add language-specific surveys in the future
// whenever that happens we can relax validation a bit to support more formats
var makeBroadcastSetter = function (attr, validation) {
return function (Env, args) {
if ((validation && !validation(args)) && !args_isString(args)) {
throw new Error('INVALID_ARGS');
}
var str = args[0];
if (str === Env[attr]) { return false; }
Env[attr] = str;
Env.broadcastCache = {};
return true;
};
};
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_LAST_BROADCAST_HASH', [hash]]], console.log)
commands.SET_LAST_BROADCAST_HASH = makeBroadcastSetter('lastBroadcastHash');
@ -320,10 +304,6 @@ commands.SET_SURVEY_URL = makeBroadcastSetter('surveyURL');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_MAINTENANCE', [""]]], console.log)
commands.SET_MAINTENANCE = makeBroadcastSetter('maintenance', args_isMaintenance);
var Quota = require("./commands/quota");
var Keys = require("./keys");
var Util = require("./common-util");
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_QUOTA', ['[user@box:3000/VzeS4vP1DF+tXGuq1i50DKYuBL+09Yqy8kGxoUKRzhA=]', { limit: 2 * 1024 * 1024 * 1024, plan: 'buddy', note: "you're welcome" } ] ] ], console.log)
commands.SET_QUOTA = function (Env, args) {
if (!Array.isArray(args) || args.length !== 2) {
@ -387,11 +367,53 @@ commands.ADD_ADMIN_KEY = function (Env, args) {
Env.admins = Env.admins || [];
var key = Keys.canonicalize(args[0]);
if (!key) { throw new Error("INVALID_KEY"); }
Env.admins.push(key);
if (Env.admins.includes(key)) { // Nothing to change
return false;
}
Env.admins.push(key);
Env.adminsData.push(args[0]);
return true;
};
commands.RM_ADMIN_KEY = function (Env, args) {
if (!Array.isArray(args) || args.length !== 1 || !args[0]) {
throw new Error("INVALID_ARGS");
}
const key = Keys.canonicalize(args[0]);
if (!key) { throw new Error("INVALID_KEY"); }
Env.admins = Env.admins || [];
if (!Env.admins.includes(key)) { // Nothing to change
return false;
}
// NOTE prevent removing config.js hardcoded admin keys
if (Array.isArray(config?.adminKeys) && config.adminKeys.includes(key)) {
throw new Error("CANT_REMOVE_CONFIG");
}
let idx = Env.admins.indexOf(key);
if (idx < 0) { return false; } // should never happen
if (Env.admins.length === 1) { throw new Error("CANT_REMOVE_LAST_ADMIN"); }
Env.admins.splice(idx, 1);
Env.adminsData = Env.adminsData.filter(str => {
const ed = Keys.canonicalize(str);
if (!ed) { return true; }
return ed !== key;
});
return true;
};
commands.PROOFS_MIGRATED = function (Env, args) {
if (args !== 1) {
throw new Error("INVALID_ARGS");
}
Env.proofsMigrated = true;
return true;
};
@ -406,107 +428,6 @@ commands.SET_BEARER_SECRET = function (Env, args) {
return true;
};
// [<command>, <args>, <author>, <time>]
var handleCommand = Decrees.handleCommand = function (Env, line) {
var command = line[0];
var args = line[1];
if (typeof(commands[command]) !== 'function') {
throw new Error("DECREE_UNSUPPORTED_COMMAND");
}
module.exports = DecreesCore.create(DECREE_NAME, commands);
var outcome = commands[command](Env, args);
if (outcome) {
// trigger Env change event...
Env.envUpdated.fire();
}
return outcome;
};
Decrees.createLineHandler = function (Env) {
var Log = Env.Log;
var index = -1;
return function (err, line) {
index++;
if (err) {
// Log the error and bail out
return void Log.error("DECREE_LINE_ERR", {
error: err.message,
index: index,
line: line,
});
}
if (Array.isArray(line)) {
try {
return void handleCommand(Env, line);
} catch (err2) {
return void Log.error("DECREE_COMMAND_ERR", {
error: err2.message,
index: index,
line: line,
});
}
}
Log.error("DECREE_HANDLER_WEIRD_LINE", {
line: line,
index: index,
});
};
};
var Fs = require("fs");
var Path = require("path");
var readFileBin = require("./stream-file").readFileBin;
var Schedule = require("./schedule");
var Fse = require("fs-extra");
var nThen = require("nthen");
Decrees.load = function (Env, _cb) {
Env.scheduleDecree = Env.scheduleDecree || Schedule();
var cb = Util.once(Util.mkAsync(function (err) {
if (err && err.code !== 'ENOENT') {
return void _cb(err);
}
_cb();
}));
Env.scheduleDecree.blocking('', function (unblock) {
var done = Util.once(Util.both(cb, unblock));
nThen(function (w) {
// ensure that the path to the decree log exists
Fse.mkdirp(Env.paths.decree, w(function (err) {
if (!err) { return; }
w.abort();
done(err);
}));
}).nThen(function () {
var decreeName = Path.join(Env.paths.decree, 'decree.ndjson');
var stream = Fs.createReadStream(decreeName, {start: 0});
var handler = Decrees.createLineHandler(Env);
readFileBin(stream, function (msgObj, next) {
var text = msgObj.buff.toString('utf8');
try {
handler(void 0, JSON.parse(text));
} catch (err) {
handler(err, text);
}
next();
}, function (err) {
done(err);
});
});
});
};
Decrees.write = function (Env, decree, _cb) {
var path = Path.join(Env.paths.decree, 'decree.ndjson');
Env.scheduleDecree.ordered('', function (next) {
var cb = Util.both(Util.mkAsync(_cb), next);
Fs.appendFile(path, JSON.stringify(decree) + '\n', cb);
});
};

View File

@ -239,7 +239,7 @@ module.exports.create = function (config) {
evictionReport: {},
commandTimers: {},
sso: config.sso,
sso: plugins?.SSO?.config || {},
enforceMFA: config.enforceMFA,
onlyOffice: {
@ -372,6 +372,7 @@ module.exports.create = function (config) {
Core.DEFAULT_LIMIT;
try {
Env.adminsData = (config.adminKeys || []).slice();
Env.admins = (config.adminKeys || []).map(function (k) {
try {
return Keys.canonicalize(k);
@ -415,6 +416,7 @@ const BAD = [
'limits',
'customLimits',
'scheduleDecree',
'plugins',
'httpServer',

View File

@ -51,7 +51,6 @@ var evictArchived = function (Env, cb) {
var report = {
// archivedChannelsRemoved,
// archivedAccountsRemoved,
// archivedBlobProofsRemoved,
// archivedBlobsRemoved,
// totalChannels,
@ -237,37 +236,6 @@ var evictArchived = function (Env, cb) {
store.listArchivedChannels(handler, w(done));
};
var removeArchivedBlobProofs = function (w) {
if (typeof(Env.archiveRetentionTime) !== "number") { return; }
// Iterate over archive blob ownership proofs and remove them
// if they are older than the specified retention time
var removed = 0;
blobs.list.archived.proofs(function (err, item, next) {
next = Util.mkAsync(next, THROTTLE_FACTOR);
if (err) {
Log.error("EVICT_BLOB_LIST_ARCHIVED_PROOF_ERROR", err);
return void next();
}
if (item && item.ctime > retentionTime) { return void next(); }
if (Env.DRY_RUN) {
removed++;
return void Log.info("EVICT_ARCHIVED_BLOB_PROOF_DRY_RUN", item, next);
}
blobs.remove.archived.proof(item.safeKey, item.blobId, (function (err) {
if (err) {
Log.error("EVICT_ARCHIVED_BLOB_PROOF_ERROR", item);
return void next();
}
Log.info("EVICT_ARCHIVED_BLOB_PROOF", item);
removed++;
next();
}));
}, w(function () {
report.archivedBlobProofsRemoved = removed;
Log.info('EVICT_ARCHIVED_BLOB_PROOFS_REMOVED', removed);
}));
};
var removeArchivedBlobs = function (w) {
if (typeof(Env.archiveRetentionTime) !== "number") { return; }
// Iterate over archived blobs and remove them
@ -303,7 +271,6 @@ var evictArchived = function (Env, cb) {
nThen(loadStorage)
.nThen(migrateIncorrectBlobs)
.nThen(removeArchivedChannels)
.nThen(removeArchivedBlobProofs)
.nThen(removeArchivedBlobs)
.nThen(function () {
cb(void 0, report);
@ -315,7 +282,6 @@ module.exports = function (Env, cb) {
var report = {
// archivedChannelsRemoved,
// archivedAccountsRemoved,
// archivedBlobProofsRemoved,
// archivedBlobsRemoved,
// totalChannels,
@ -612,92 +578,45 @@ module.exports = function (Env, cb) {
if (pinnedDocs.test(item.blobId)) { return void next(); }
if (activeDocs.test(item.blobId)) { return void next(); }
// This seems redundant because we're already checking the bloom filter
// but we can't implement a 'fast mode' for the iterator
// unless we address this race condition with this last-minute double-check
if (item.mtime > inactiveTime) { return void next(); }
if (Env.DRY_RUN) {
removed++;
return void Log.info("EVICT_ARCHIVE_BLOB_DRY_RUN", {
item: item,
}, next);
}
blobs.archive.blob(item.blobId, 'INACTIVE', function (err) {
if (err) {
return Log.error("EVICT_ARCHIVE_BLOB_ERROR", {
error: err,
// NOTE: fast mode allows us to skip getStats for
// the pinned and active channels
nThen(function (w) {
// double check that the channel really is inactive before archiving it
// because it might have been created after the initial activity scan
blobs.getStats(item.blobId, w(function (err, newerItem) {
if (err) { return; }
if (newerItem && getNewestTime(newerItem) > retentionTime) {
// it's actually active, so don't archive it.
w.abort();
cb();
}
// else fall through to the archival
}));
}).nThen(function () {
if (Env.DRY_RUN) {
removed++;
return void Log.info("EVICT_ARCHIVE_BLOB_DRY_RUN", {
item: item,
}, next);
}
removed++;
Log.info("EVICT_ARCHIVE_BLOB", {
item: item,
}, next);
blobs.archive.blob(item.blobId, 'INACTIVE', function (err) {
if (err) {
return Log.error("EVICT_ARCHIVE_BLOB_ERROR", {
error: err,
item: item,
}, next);
}
removed++;
Log.info("EVICT_ARCHIVE_BLOB", {
item: item,
}, next);
});
});
}, w(function () {
report.totalBlobs = total;
report.activeBlobs = total - removed;
Log.info('EVICT_BLOBS_REMOVED', removed, w());
}));
};
var archiveInactiveBlobProofs = function (w) {
// iterate over blob proofs and remove them
// if they don't correspond to a pinned or active file
var removed = 0;
var total = 0;
Log.info("EVICT_ARCHIVE_INACTIVE_BLOB_PROOFS_START", {});
blobs.list.proofs(function (err, item, next) {
next = Util.mkAsync(next, THROTTLE_FACTOR);
if (err) {
return void Log.error("EVICT_BLOB_LIST_PROOFS_ERROR", err, next);
}
if (!item) {
return void Log.error('EVICT_BLOB_LIST_PROOFS_NO_ITEM', item, next);
}
total++;
if (total % PROGRESS_FACTOR === 0) {
Log.info('EVICT_BLOB_PROOF_PROGRESS', {
proofs: total,
});
}
if (pinnedDocs.test(item.blobId)) { return void next(); }
if (item.mtime > inactiveTime) { return void next(); }
nThen(function (w) {
blobs.size(item.blobId, w(function (err, size) {
if (err && err === 'ENOENT') { return; } // XXX delete the proof
if (err) {
w.abort();
return void Log.error("EVICT_BLOB_LIST_PROOFS_ERROR", err, next);
}
if (size !== 0) {
w.abort();
next();
}
}));
}).nThen(function () {
if (Env.DRY_RUN) {
removed++;
return void Log.info("EVICT_BLOB_PROOF_LONELY_DRY_RUN", item, next);
}
blobs.remove.proof(item.safeKey, item.blobId, function (err) {
if (err) {
return Log.error("EVICT_BLOB_PROOF_LONELY_ERROR", item, next);
}
removed++;
return Log.info("EVICT_BLOB_PROOF_LONELY", item, next);
});
});
}, w(function () {
Log.info("EVICT_BLOB_PROOFS_REMOVED", {
removed,
total,
}, w());
}));
}), true);
};
var archiveInactiveChannels = function (w) {
@ -802,7 +721,6 @@ module.exports = function (Env, cb) {
// (documents which are not in either bloom filter)
.nThen(archiveInactiveBlobs)
.nThen(archiveInactiveBlobProofs)
.nThen(archiveInactiveChannels)
.nThen(function () {
var runningTime = report.runningTime = msSinceStart();

View File

@ -42,6 +42,8 @@ const ADMIN_CHANNEL_LENGTH = HK.ADMIN_CHANNEL_LENGTH = 33;
// with a 34 character id
const EPHEMERAL_CHANNEL_LENGTH = HK.EPHEMERAL_CHANNEL_LENGTH = 34;
HK.BLOB_ID_LENGTH = 48;
// Temporary channels are archived X ms after everyone has left them
const TEMPORARY_CHANNEL_LIFETIME = 30 * 1000;

View File

@ -584,15 +584,16 @@ var makeRouteCache = function (template, cacheName) {
};
};
const ssoList = Env.sso && Env.sso.enabled && Array.isArray(Env.sso.list) &&
Env.sso.list.map(function (obj) { return obj.name; }) || [];
const ssoCfg = (SSOUtils && ssoList.length) ? {
force: (Env.sso && Env.sso.enforced && 1) || 0,
password: (Env.sso && Env.sso.cpPassword && (Env.sso.forceCpPassword ? 2 : 1)) || 0,
list: ssoList
} : false;
var serveConfig = makeRouteCache(function () {
// NOTE: we may extract JSON from this config using slice(27, -5)
const ssoList = Env.sso && Env.sso.enabled && Array.isArray(Env.sso.list) &&
Env.sso.list.map(function (obj) { return obj.name; }) || [];
const ssoCfg = (SSOUtils && ssoList.length) ? {
force: (Env.sso && Env.sso.enforced && 1) || 0,
password: (Env.sso && Env.sso.cpPassword && (Env.sso.forceCpPassword ? 2 : 1)) || 0,
list: ssoList
} : false;
return [
'define(function(){',
'return ' + JSON.stringify({

View File

@ -46,11 +46,4 @@ if (!isPositiveNumber(config.premiumUploadSize) || config.premiumUploadSize < co
delete config.premiumUploadSize;
}
config.sso = {};
try {
config.sso = require("../config/sso");
} catch (e) {
//console.log("SSO config not found");
}
module.exports = config;

View File

@ -33,6 +33,7 @@ var createLineHandler = Pins.createLineHandler = function (ref, errorHandler) {
// it's a weird API but it's faster than unpinning manually
var pins = ref.pins = {};
ref.index = 0;
ref.first = 0;
ref.latest = 0; // the latest message (timestamp in ms)
ref.surplus = 0; // how many lines exist behind a reset
@ -58,7 +59,7 @@ var createLineHandler = Pins.createLineHandler = function (ref, errorHandler) {
return sanitized;
};
return function (line) {
return function (line, i) {
ref.index++;
if (!Boolean(line)) { return; }
@ -74,6 +75,7 @@ var createLineHandler = Pins.createLineHandler = function (ref, errorHandler) {
}
if (typeof(l[2]) === 'number') {
if (!ref.first) { ref.first = l[2]; }
ref.latest = l[2]; // date
}
@ -109,6 +111,11 @@ var createLineHandler = Pins.createLineHandler = function (ref, errorHandler) {
default:
errorHandler("PIN_LINE_UNSUPPORTED_COMMAND", l);
}
if (i === 0) { // First line when using Pins.load
if (l[0] === 'PIN' || ref.block) { ref.user = true; } // teams always start with RESET
}
};
};

View File

@ -10,6 +10,9 @@ var BlobStore = module.exports;
var nThen = require("nthen");
var Semaphore = require("saferphore");
var Util = require("../common-util");
const PERMISSIVE = 511;
const readFileBin = require("../stream-file").readFileBin;
const BLOB_LENGTH = 48;
@ -31,37 +34,30 @@ var prependArchive = function (Env, path) {
return Path.join(Env.archivePath, 'blob', relativePathToBlob);
};
// /blob/<safeKeyPrefix>/<safeKey>/<blobPrefix>/<blobId>
// /blob/<blobPrefix>/<blobId>
var makeBlobPath = function (Env, blobId) {
return Path.join(Env.blobPath, blobId.slice(0, 2), blobId);
};
var makeActivityPath = function (Env, blobId) {
return makeBlobPath(Env, blobId) + '.activity';
};
// /blob/<blobPrefix>/<blobId>.metadata.ndjson
var mkMetadataPath = function (Env, blobId) {
return Path.join(Env.blobPath, blobId.slice(0, 2), blobId) + '.metadata.ndjson';
};
// /blobstate/<safeKeyPrefix>/<safeKey>
var makeStagePath = function (Env, safeKey) {
return Path.join(Env.blobStagingPath, safeKey.slice(0, 2), safeKey);
};
// /blob/<safeKeyPrefix>/<safeKey>/<blobPrefix>/<blobId>
var makeProofPath = function (Env, safeKey, blobId) {
return Path.join(Env.blobPath, safeKey.slice(0, 3), safeKey, blobId.slice(0, 2), blobId);
};
var mkPlaceholderPath = function (Env, blobId) {
return makeBlobPath(Env, blobId) + '.placeholder';
};
var parseProofPath = function (path) {
var parts = path.split('/');
return {
blobId: parts[parts.length -1],
safeKey: parts[parts.length - 3],
};
};
// Placeholder for deleted files
var addPlaceholder = function (Env, blobId, reason, cb) {
if (!reason) { return cb(); }
@ -120,6 +116,18 @@ var isFile = function (filePath, cb) {
});
};
// PROOFS
// DEPRECATED, keep for compatibility
// /blob/<safeKeyPrefix>/<safeKey>/<blobPrefix>/<blobId>
var makeProofPath = function (Env, safeKey, blobId) {
return Path.join(Env.blobPath, safeKey.slice(0, 3), safeKey, blobId.slice(0, 2), blobId);
};
// isOwnedBy(id, safeKey)
var isOwnedBy = function (Env, safeKey, blobId, cb) {
var proofPath = makeProofPath(Env, safeKey, blobId);
isFile(proofPath, cb);
};
var makeFileStream = function (full, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
Fse.mkdirp(Path.dirname(full), function (e) {
@ -190,6 +198,76 @@ var getActivity = function (Env, blobId, cb) {
});
};
// destroyStream && createIdleStreamCollector
// copied from lib/storage/file.js
// see comments there
const STREAM_CLOSE_TIMEOUT = 120000;
const STREAM_DESTROY_TIMEOUT = 30000;
const destroyStream = function (stream) {
if (!stream) { return; }
try {
stream.close();
if (stream.closed && stream.fd === null) { return; }
} catch (err) {
console.error(err);
}
setTimeout(function () {
try { stream.destroy(); } catch (err) { console.error(err); }
}, STREAM_DESTROY_TIMEOUT);
};
const createIdleStreamCollector = function (stream) {
var collector = Util.once(Util.mkAsync(Util.bake(destroyStream, [stream])));
collector.keepAlive = Util.throttle(collector, STREAM_CLOSE_TIMEOUT);
collector.keepAlive();
return collector;
};
// writeMetadata appends to the dedicated log of metadata amendments
var writeMetadata = function (env, channelId, data, cb) {
var path = mkMetadataPath(env, channelId);
Fse.mkdirp(Path.dirname(path), PERMISSIVE, function (err) {
if (err && err.code !== 'EEXIST') { return void cb(err); }
Fs.appendFile(path, data + '\n', cb);
});
};
var archiveMetadata = (Env, blobId, cb) => {
var path = mkMetadataPath(Env, blobId);
var archivePath = prependArchive(Env, path);
// XXX eviction clean lone md files
// if we fail to delete the metadata file, it can still be removed later by the eviction script
Fse.move(path, archivePath, { overwrite: true }, cb);
};
var restoreMetadata = function (Env, blobId, cb) {
var path = mkMetadataPath(Env, blobId);
var archivePath = prependArchive(Env, path);
Fse.move(archivePath, path, cb);
};
var readBlobMetadata = function (env, blobId, handler, _cb) {
var metadataPath = mkMetadataPath(env, blobId);
var stream = Fs.createReadStream(metadataPath, {start: 0});
const collector = createIdleStreamCollector(stream);
var cb = Util.both(_cb, collector);
readFileBin(stream, function (msgObj, readMore) {
collector.keepAlive();
var line = msgObj.buff.toString('utf8');
try {
var parsed = JSON.parse(line);
handler(null, parsed);
} catch (err) {
handler(err, line);
}
readMore();
}, function (err) {
// ENOENT => there is no metadata log
if (!err || err.code === 'ENOENT') { return void cb(); }
// otherwise stream errors?
cb(err);
});
};
/********** METHODS **************/
var upload = function (Env, safeKey, content, cb) {
@ -313,6 +391,9 @@ var tryId = function (path, cb) {
};
// owned_upload_complete
let unescapeKeyCharacters = function (key) {
return key.replace(/\-/g, '/');
};
var owned_upload_complete = function (Env, safeKey, id, cb) {
closeBlobstage(Env, safeKey);
if (!isValidId(id)) {
@ -325,12 +406,9 @@ var owned_upload_complete = function (Env, safeKey, id, cb) {
}
var finalPath = makeBlobPath(Env, id);
let unsafeKey = unescapeKeyCharacters(safeKey);
var finalOwnPath = makeProofPath(Env, safeKey, id);
// the user wants to move it into blob and create a empty file with the same id
// in their own space:
// /blob/safeKeyPrefix/safeKey/blobPrefix/blobID
// the user wants to move it into blob and create a metadata log with an owner
nThen(function (w) {
// make the requisite directory structure using Mkdirp
@ -340,12 +418,6 @@ var owned_upload_complete = function (Env, safeKey, id, cb) {
return void cb(e.code);
}
}));
Fse.mkdirp(Path.dirname(finalOwnPath), w(function (e /*, path */) {
if (e) { // does not throw error if the directory already existed
w.abort();
return void cb(e.code);
}
}));
}).nThen(function (w) {
// make sure the id does not collide with another
tryId(finalPath, w(function (e) {
@ -355,8 +427,11 @@ var owned_upload_complete = function (Env, safeKey, id, cb) {
}
}));
}).nThen(function (w) {
// Create the empty file proving ownership
Fs.writeFile(finalOwnPath, '', w(function (e) {
// Write the metadata
let md = JSON.stringify({
owners: [unsafeKey]
});
writeMetadata(Env, id, md, w((e) => {
if (e) {
w.abort();
return void cb(e.code);
@ -367,12 +442,6 @@ var owned_upload_complete = function (Env, safeKey, id, cb) {
// move the existing file to its new path
Fse.move(oldPath, finalPath, w(function (e) {
if (e) {
// if there's an error putting the file into its final location...
// ... you should remove the ownership file
Fs.unlink(finalOwnPath, function () {
// but if you can't, it's not catestrophic
// we can clean it up later
});
w.abort();
return void cb(e.code);
}
@ -393,24 +462,12 @@ var remove = function (Env, blobId, cb) {
clearActivity(Env, blobId, () => {});
};
// removeProof
var removeProof = function (Env, safeKey, blobId, cb) {
var proofPath = makeProofPath(Env, safeKey, blobId);
Fs.unlink(proofPath, cb);
};
// isOwnedBy(id, safeKey)
var isOwnedBy = function (Env, safeKey, blobId, cb) {
var proofPath = makeProofPath(Env, safeKey, blobId);
isFile(proofPath, cb);
};
// archiveBlob
var archiveBlob = function (Env, blobId, reason, cb) {
var blobPath = makeBlobPath(Env, blobId);
var archivePath = prependArchive(Env, blobPath);
Fse.move(blobPath, archivePath, { overwrite: true }, cb);
archiveMetadata(Env, blobId, () => {});
archiveActivity(Env, blobId, () => {});
addPlaceholder(Env, blobId, reason, () => {});
};
@ -426,29 +483,11 @@ var restoreBlob = function (Env, blobId, cb) {
var blobPath = makeBlobPath(Env, blobId);
var archivePath = prependArchive(Env, blobPath);
Fse.move(archivePath, blobPath, cb);
restoreMetadata(Env, blobId, () => {});
restoreActivity(Env, blobId, () => {});
clearPlaceholder(Env, blobId, () => {});
};
// archiveProof
var archiveProof = function (Env, safeKey, blobId, cb) {
var proofPath = makeProofPath(Env, safeKey, blobId);
var archivePath = prependArchive(Env, proofPath);
Fse.move(proofPath, archivePath, { overwrite: true }, cb);
};
var removeArchivedProof = function (Env, safeKey, blobId, cb) {
var archivedPath = prependArchive(Env, makeProofPath(Env, safeKey, blobId));
Fs.unlink(archivedPath, cb);
};
// restoreProof
var restoreProof = function (Env, safeKey, blobId, cb) {
var proofPath = makeProofPath(Env, safeKey, blobId);
var archivePath = prependArchive(Env, proofPath);
Fse.move(archivePath, proofPath, cb);
};
var makeWalker = function (n, handleChild, done) {
if (!n || typeof(n) !== 'number' || n < 2) { n = 2; }
@ -480,7 +519,7 @@ var makeWalker = function (n, handleChild, done) {
}
if (!stats.isDirectory()) {
w.abort();
if (/\.activity$/.test(path)) {
if (/\.activity$/.test(path)) {
// NOTE: some activity files were created for deleted blobs due to
// a bug. We're going to detect them here in order to be able to clean
// them.
@ -513,46 +552,6 @@ var makeWalker = function (n, handleChild, done) {
return recurse;
};
var listProofs = function (root, handler, cb) {
Fs.readdir(root, function (err, dir) {
if (err) { return void cb(err); }
var walk = makeWalker(20, function (err, path, next, loneActivity) {
if (loneActivity) { return void next(); }
// path is the path to a child node on the filesystem
// next handles the next job in a queue
// iterate over proofs
// check for presence of corresponding files
Fs.stat(path, function (err, stats) {
if (err) {
return void handler(err, void 0, next);
}
var parsed = parseProofPath(path);
handler(void 0, {
path: path,
blobId: parsed.blobId,
safeKey: parsed.safeKey,
atime: stats.atime,
ctime: stats.ctime,
mtime: stats.mtime,
}, next);
});
}, function () {
// called when there are no more directories or children to process
cb();
});
dir.forEach(function (d) {
// ignore directories that aren't 3 characters long...
if (d.length !== 3) { return; }
walk(Path.join(root, d));
});
});
};
var getActivityStat = function (path, base, cb) {
var suffix = base ? '' : '.activity';
Fs.stat(path+suffix, function (err, stats) {
@ -560,32 +559,92 @@ var getActivityStat = function (path, base, cb) {
cb(err, stats);
});
};
var listBlobs = function (root, handler, cb) {
// iterate over files
Fs.readdir(root, function (err, dir) {
if (err) { return void cb(err); }
var walk = makeWalker(20, function (err, path, next, loneActivity) {
if (loneActivity) { return void next(); }
getActivityStat(path, false, function (err, stats) {
if (err) {
return void handler(err, void 0, next);
}
var getStats = function (Env, blobId, cb) {
var path = makeBlobPath(Env, blobId);
getActivityStat(path, false, cb);
};
handler(void 0, {
blobId: Path.basename(path),
atime: stats.atime,
ctime: stats.ctime,
mtime: stats.mtime,
}, next);
});
}, function () {
cb();
});
let blobRegex = /^[0-9a-fA-F]{48}(\.metadata)*(\.ndjson)*$/;
var listBlobs = function (root, handler, fast, cb) {
var dirList = [];
dir.forEach(function (d) {
if (d.length !== 2) { return; }
walk(Path.join(root, d));
nThen(function (w) {
// the root of your datastore contains nested directories...
Fs.readdir(root, w(function (err, list) {
if (err) {
w.abort();
// TODO check if we normally return strings or errors
return void cb(err);
}
dirList = list;
}));
}).nThen(function (waitFor) {
// search inside the nested directories
// stream it so you don't put unnecessary data in memory
var n = nThen;
dirList.forEach(function (dir) {
if (dir.length !== 2) { return; }
// Handle one directory at a time to save some memory
n = n(function (w) {
// do twenty things at a time
var sema = Semaphore.create(20);
var nestedDirPath = Path.join(root, dir);
Fs.readdir(nestedDirPath, w(function (err, list) {
if (err) { return void handler(err); } // Is this correct?
list.forEach(function (item) {
// ignore hidden files
if (/^\./.test(item)) { return; }
// ignore anything that isn't channel or metadata
if (!blobRegex.test(item)) { return; }
var isLonelyMetadata = false;
var blobName;
// if the current file is not the channel data, then it must be metadata
if (!/^[0-9a-fA-F]{48}$/.test(item)) {
blobName = item.replace(/\.metadata\.ndjson/, '');
// check if blob already exists
if (list.indexOf(blobName) !== -1) { return; }
// otherwise set a flag indicating that we should
// handle the metadata on its own
isLonelyMetadata = true;
} else {
blobName = item;
}
if (blobName.length !== 48) { return; }
sema.take(function (give) {
var next = w(give());
if (fast) {
return void handler(void 0, {
blobId: blobName
}, next);
}
var filePath = Path.join(nestedDirPath, blobName);
if (isLonelyMetadata) {
// Set time to 0 to delete this
// lonely metadata file
return void handler(void 0, {
blobId: blobName,
mtime: 0,
atime: 0,
ctime: 0
}, next);
}
return void getActivityStat(filePath, false, (err, data) => {
data.blobId = blobName;
handler(err, data, next);
});
});
});
}));
}).nThen;
});
n(waitFor());
}).nThen(function () {
cb();
});
};
@ -673,6 +732,20 @@ BlobStore.create = function (config, _cb) {
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
isOwnedBy(Env, safeKey, blobId, cb);
},
readMetadata: (blobId, handler, cb) => {
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
readBlobMetadata(Env, blobId, handler, cb);
},
writeMetadata: (blobId, data, cb) => {
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
writeMetadata(Env, blobId, data, cb);
},
hasMetadata: (blobId, _cb) => {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
var path = mkMetadataPath(Env, blobId);
isFile(path, cb);
},
remove: {
blob: function (blobId, _cb) {
@ -680,24 +753,12 @@ BlobStore.create = function (config, _cb) {
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
remove(Env, blobId, cb);
},
proof: function (safeKey, blobId, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
removeProof(Env, safeKey, blobId, cb);
},
archived: {
blob: function (blobId, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
removeArchivedBlob(Env, blobId, cb);
},
proof: function (safeKey, blobId, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
removeArchivedProof(Env, safeKey, blobId, cb);
},
},
loneActivity: function (_cb) {
var cb = Util.once(Util.mkAsync(_cb));
@ -711,12 +772,6 @@ BlobStore.create = function (config, _cb) {
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
archiveBlob(Env, blobId, reason, cb);
},
proof: function (safeKey, blobId, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
archiveProof(Env, safeKey, blobId, cb);
},
},
restore: {
@ -725,12 +780,6 @@ BlobStore.create = function (config, _cb) {
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
restoreBlob(Env, blobId, cb);
},
proof: function (safeKey, blobId, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
restoreProof(Env, safeKey, blobId, cb);
},
},
isBlobAvailable: function (blobId, _cb) {
@ -781,24 +830,21 @@ BlobStore.create = function (config, _cb) {
if (!isValidId(id)) { return void cb("INVALID_ID"); }
getActivity(Env, id, cb);
},
getStats: function (id, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidId(id)) { return void cb("INVALID_ID"); }
getStats(Env, id, cb);
},
list: {
blobs: function (handler, _cb) {
blobs: function (handler, _cb, fast) {
var cb = Util.once(Util.mkAsync(_cb));
listBlobs(Env.blobPath, handler, cb);
},
proofs: function (handler, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
listProofs(Env.blobPath, handler, cb);
listBlobs(Env.blobPath, handler, fast, cb);
},
archived: {
proofs: function (handler, _cb) {
blobs: function (handler, _cb, fast) {
var cb = Util.once(Util.mkAsync(_cb));
listProofs(prependArchive(Env, Env.blobPath), handler, cb);
},
blobs: function (handler, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
listBlobs(prependArchive(Env, Env.blobPath), handler, cb);
listBlobs(prependArchive(Env, Env.blobPath), handler, fast, cb);
},
}
},

View File

@ -158,6 +158,12 @@ const isValidOffsetNumber = function (n) {
return typeof(n) === 'number' && n >= 0;
};
const updateEnv = data => {
const {value} = data;
let env = Util.tryParse(value) || {};
Env.proofsMigrated = env?.proofsMigrated;
};
const computeIndexFromOffset = function (channelName, offset, cb) {
let cpIndex = [];
let messageBuf = [];
@ -338,7 +344,13 @@ const computeMetadata = function (data, cb) {
const ref = {};
const lineHandler = Meta.createLineHandler(ref, Env.Log.error);
monitoringIncrement('computeMetadata');
return void store.readChannelMetadata(data.channel, lineHandler, function (err) {
let f = store.readChannelMetadata;
if (data.channel.length === HK.BLOB_ID_LENGTH) {
f = blobStore.readMetadata;
}
return void f(data.channel, lineHandler, function (err) {
if (err) {
// stream errors?
return void cb(err);
@ -558,16 +570,33 @@ const removeOwnedBlob = function (data, cb) {
if (typeof(data.safeKey) !== 'string') { return void cb("INVALID_KEY"); }
const blobId = data.blobId;
const safeKey = Util.escapeKeyCharacters(data.safeKey);
const unsafeKey = Util.unescapeKeyCharacters(data.safeKey);
const reason = data.reason || 'ARCHIVE_OWNED';
nThen(function (w) {
// check if you have permissions
blobStore.isOwnedBy(safeKey, blobId, w(function (err, owned) {
if (err || !owned) {
computeMetadata({channel: blobId}, w((err, meta) => {
if (err || !meta) {
w.abort();
return void cb("INSUFFICIENT_PERMISSIONS");
}
let owners = meta.owners;
if (!owners && !Env.proofsMigrated) {
// Check old proofs during migration
blobStore.isOwnedBy(safeKey, blobId, w((e, owned) => {
if (e || !owned) {
w.abort();
return void cb("INSUFFICIENT_PERMISSIONS");
}
}));
return;
}
if (!owners || !owners.includes(unsafeKey)) {
w.abort();
return void cb("INSUFFICIENT_PERMISSIONS");
}
// Owned, continue
}));
}).nThen(function (w) {
// remove the blob
@ -581,20 +610,8 @@ const removeOwnedBlob = function (data, cb) {
w.abort();
return void cb(err);
}
}));
}).nThen(function () {
// archive the proof
blobStore.archive.proof(safeKey, blobId, function (err) {
Env.Log.info("ARCHIVAL_PROOF_REMOVAL_BY_OWNER_RPC", {
safeKey: safeKey,
blobId: blobId,
status: err? String(err): 'SUCCESS',
});
if (err) {
return void cb("E_PROOF_REMOVAL");
}
cb(void 0, 'OK');
});
}));
});
};
@ -692,6 +709,7 @@ const getLastChannelTime = function (data, cb) {
};
const COMMANDS = {
ENV_UPDATE: updateEnv,
COMPUTE_INDEX: computeIndex,
COMPUTE_METADATA: computeMetadata,
GET_OLDER_HISTORY: getOlderHistory,
@ -847,6 +865,9 @@ process.on('message', function (data) {
};
if (!ready) {
if (data.env) {
updateEnv({value:data.env});
}
return void init(data.config, function (err) {
if (err) { return void cb(Util.serializeError(err)); }
ready = true;

View File

@ -9,6 +9,7 @@ const { fork } = require('child_process');
const Workers = module.exports;
const PID = process.pid;
const Block = require("../storage/block");
const Environment = require('../env');
const DB_PATH = 'lib/workers/db-worker';
const MAX_JOBS = 16;
@ -256,6 +257,7 @@ Workers.initialize = function (Env, config, _cb) {
pid: PID,
txid: txid,
config: config,
env: Environment.serialize(Env)
});
worker.on('message', function (res) {
@ -342,7 +344,8 @@ Workers.initialize = function (Env, config, _cb) {
type: 'broadcast',
pid: PID,
command: data.command,
txid: data.txid
txid: data.txid,
value: data.value
});
});
return workers;

16
package-lock.json generated
View File

@ -45,7 +45,7 @@
"notp": "^2.0.3",
"nthen": "0.1.8",
"open-sans-fontface": "^1.4.0",
"openid-client": "^5.4.2",
"openid-client": "^5.7.0",
"pako": "^2.1.0",
"prompt-confirm": "^2.0.4",
"pull-stream": "^3.6.1",
@ -3549,9 +3549,9 @@
}
},
"node_modules/jose": {
"version": "4.15.5",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz",
"integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==",
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
@ -4316,11 +4316,11 @@
"integrity": "sha512-d1VXrt1qPScsZnDHbZTOf1SmUnanr3KQgQM6+ye6KoFgrLo8a8mkX/J/ZJ2+w7vf0sCC02lRia5SAiaz0JPEog=="
},
"node_modules/openid-client": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz",
"integrity": "sha512-PtrWsY+dXg6y8mtMPyL/namZSYVz8pjXz3yJiBNZsEdCnu9miHLB4ELVC85WvneMKo2Rg62Ay7NkuCpM0bgiLQ==",
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"dependencies": {
"jose": "^4.15.1",
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"

View File

@ -48,7 +48,7 @@
"notp": "^2.0.3",
"nthen": "0.1.8",
"open-sans-fontface": "^1.4.0",
"openid-client": "^5.4.2",
"openid-client": "^5.7.1",
"pako": "^2.1.0",
"prompt-confirm": "^2.0.4",
"pull-stream": "^3.6.1",

View File

@ -0,0 +1,198 @@
// SPDX-FileCopyrightText: 2025 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const { parentPort } = require('node:worker_threads');
const Path = require('node:path');
const Fs = require('node:fs');
const nThen = require("nthen");
const Semaphore = require("saferphore");
const Logger = require("../../lib/log");
const BlobStorage = require("../../lib/storage/blob");
let config = require("../../lib/load-config");
const blobPath = config.blobPath || './blob';
let Log = {};
// NOTE: in cleaning mode, we DON'T migrate
// (we suppose data has already been migrated)
const start = (clean, dry, cb) => {
const DRY_RUN = dry;
let dirList = [];
let blobStore;
nThen(w => {
Logger.create(config, w(function (_log) {
Log = _log;
}));
}).nThen(w => {
config.getSession = function () {};
BlobStorage.create(config, w(function (err, _store) {
if (err) {
w.abort();
return void Log.error("ERR_BLOB_STORE", err);
}
blobStore = _store;
}));
}).nThen(w => {
Fs.readdir(blobPath, w((err, list) => {
if (err) {
w.abort();
return void Log.error("ERR_READING_ROOT", err);
}
dirList = list;
}));
}).nThen(() => {
let n = nThen;
dirList.forEach(dir => {
if (dir.length !== 3) { return; }
// ./blob/abc
const nestedDirPath = Path.join(blobPath, dir);
if (clean) {
n = n(ww => {
Log.info("REMOVING_DIR", nestedDirPath);
if (DRY_RUN) { return; }
Fs.rm(nestedDirPath, {
recursive: true, force: true
}, ww(err => {
if (err) {
Log.error("ERR_REMOVE_DIR", {
path: nestedDirPath,
err
});
}
}));
}).nThen;
return;
}
n = n(w => {
// One user at a time
const sema = Semaphore.create(1);
let nestedDirList = [];
nThen(ww => {
Fs.readdir(nestedDirPath, ww((err, list) => {
if (err) {
w.abort();
ww.abort();
return Log.error("ERR_READING_DIR", {
path: nestedDirPath,
err
});
}
nestedDirList = list;
}));
}).nThen(ww => {
nestedDirList.forEach(key => {
// ./blob/abc/abcdefg...
const keyPath = Path.join(nestedDirPath, key);
sema.take(give => {
let edPublic = key.replace(/\-/g, '/');
let md = JSON.stringify({ owners: [edPublic] });
Log.info("START_USER", edPublic);
Fs.readdir(keyPath, ww((err, list) => {
if (err) {
w.abort();
ww.abort();
return Log.error("ERR_READING_DIR", {
path: keyPath,
err
});
}
let blobs = [];
nThen(www => {
list.forEach(dir => {
// ./blob/abc/abcdefg.../01
const path = Path.join(keyPath, dir);
Fs.readdir(path, www((err, blobsList) => {
if (err) {
w.abort();
ww.abort();
www.abort();
return Log.error("ERR_READING_DIR", {
path, err
});
}
Array.prototype.push.apply(blobs, blobsList);
}));
});
}).nThen(www => {
// migrate 20 blobs at a time for a given user
const sema = Semaphore.create(20);
blobs.forEach(blobId => {
sema.take(ggive => {
blobStore.isBlobAvailable(blobId, www((err, blobExists) => {
blobStore.hasMetadata(blobId, www((err, exists) => {
// If blob is not available or metadata already
// exists, don't write md file
if (!blobExists || exists) { return void ggive(); }
Log.info('WRITE_METADATA', blobId);
if (DRY_RUN) { return void ggive(); }
blobStore.writeMetadata(blobId, md, www(e => {
if (e) {
w.abort();
ww.abort();
www.abort();
return Log.error("ERR_WRITING_MD", { blobId });
}
ggive();
}));
}));
}));
});
});
}).nThen(ww(give(() => {
Log.info("END_USER", edPublic);
})));
}));
});
});
}).nThen(w());
}).nThen;
});
n(() => {
Log.info("DONE");
cb();
});
});
};
if (parentPort) {
// Loaded as worker script
config = JSON.parse(JSON.stringify(config));
config.logToStdout = false;
parentPort.on('message', (message) => {
let parsed = message; //JSON.parse(message);
if (!parsed?.start) { return; }
// Migrate
start(false, false, () => {
parentPort.postMessage('MIGRATED');
// If success, clean
start(true, false, () => {
parentPort.postMessage('CLEANED');
});
});
});
parentPort.postMessage('READY');
} else if (require.main === module) {
// Loaded from command-line
let dry = false;
let clean = false;
process.argv.forEach(key => {
if (key === '--dry') {
dry = true;
return;
}
if (key === '--clean') {
clean = true;
return;
}
});
start(clean, dry, () => {
process.exit(0);
});
}

132
scripts/user-statistics.js Normal file
View File

@ -0,0 +1,132 @@
// SPDX-FileCopyrightText: 2025 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const nThen = require("nthen");
const Semaphore = require("saferphore");
const Logger = require("../lib/log");
const Pins = require("../lib/pins");
const config = require("../lib/load-config");
const BlobStorage = require("../lib/storage/blob");
const Store = require("../lib/storage/file");
const Fs = require('node:fs');
const Quota = require("../lib/commands/quota");
const Environment = require('../lib/env');
const Env = Environment.create(config);
const CSV = true;
config.logPath = false;
config.logToStdout = true;
const start = () => {
let time = +new Date();
let Log = {};
let all = {};
let blobStore, store;
nThen(w => {
Logger.create(config, w(function (_log) {
Env.Log = Log = _log;
}));
}).nThen(w => {
config.getSession = function () {};
Store.create(config, w(function (err, _store) {
if (err) {
w.abort();
return void Log.error("ERR_PAD_STORE", err);
}
store = _store;
}));
BlobStorage.create(config, w(function (err, _store) {
if (err) {
w.abort();
return void Log.error("ERR_BLOB_STORE", err);
}
blobStore = _store;
}));
}).nThen(w => {
Quota.updateCachedLimits(Env, w((err) => {
if (err) {
return Env.Log.warn('UPDATE_QUOTA_ERR', err);
}
Env.Log.info('QUOTA_UPDATED', {});
}));
}).nThen(w => {
Env.Log.info('START_LOADING_PINS');
const handlePinLog = (content, id, next) => {
const sema = Semaphore.create(20);
const data = all[id] = {
size: 0,
n_pads: 0,
n_blobs: 0,
n_total: 0,
first: content.first,
last: content.latest
};
if (!content.user) {
data.maybeTeam = true;
}
nThen(ww => {
Object.keys(content.pins).forEach(id => {
sema.take(give => {
let addSize = ww(give((e, s) => {
if (typeof(s) !== "number") {
return; // XXX
}
data.size += s;
data.n_total++;
if (id.length === 32) {
data.n_pads++;
} else {
data.n_blobs++;
}
}));
if (id.length === 32) { // PAD
return store.getChannelSize(id, addSize);
}
blobStore.size(id, addSize);
});
});
}).nThen(() => {
let key = id.replace(/-/g, '/');
if (Env.limits[key]) {
let sub = Env.limits[key];
data.premium = sub?.plan;
}
Env.Log.info('PIN_LOG_HANDLED', key);
next();
});
};
Pins.load(w(() => {
let duration = +new Date() - time;
Env.Log.info('ALL_PINS_LOADED', duration);
}), {
pinPath: config.pinPath,
handler: handlePinLog,
});
}).nThen(() => {
if (!CSV) { return console.log(all); }
let csv = `"User key","Premium plan","Bytes","Number pads","Number blobs","First activity","Last activity","May be a team"\n`;
Object.keys(all).sort((a,b) => {
return all[b].size - all[a].size;
}).forEach(k => {
const data = all[k];
k = k.replace(/-/g, '/');
let first = new Date(data.first).toISOString().slice(0,10);
let last = new Date(data.last).toISOString().slice(0,10);
let plan = data.premium || '';
let t = String(!!data.maybeTeam);
csv += `"${k}","${plan}","${data.size}","${data.n_pads}","${data.n_blobs}","${first}","${last}","${t}"\n`;
});
let filename = `../${new Date().toISOString().slice(0,10)}-stats.csv`;
Fs.writeFile(filename, csv, err => {
if (err) {
console.error(err);
} else {
console.log('CSV available at', filename);
}
});
});
};
start();

View File

@ -190,7 +190,15 @@ nThen(function (w) {
var throttledEnvChange = Util.throttle(function () {
Env.Log.info('WORKER_ENV_UPDATE', 'Updating HTTP workers with latest state');
broadcast('ENV_UPDATE', Environment.serialize(Env));
let serialized = Environment.serialize(Env);
broadcast('ENV_UPDATE', serialized);
if (Env.broadcastWorkerCommand) {
Env.broadcastWorkerCommand({
command: 'ENV_UPDATE',
value: serialized,
txid: Util.uid()
});
}
}, 250); // NOTE: changing this value will impact lib/commands/admin-rpc.js#adminDecree callback
var throttledCacheFlush = Util.throttle(function () {

View File

@ -61,6 +61,11 @@
}
}
}
&[data-item="add-admins"] {
.cp-sidebar-form:not(:last-child) {
margin-bottom: 1em;
}
}
}
}
}

View File

@ -67,7 +67,7 @@ define([
'description',
'email',
'jurisdiction',
'flush-cache'
'flush-cache',
]
},
'customize': { // Msg.admin_cat_customize
@ -77,6 +77,13 @@ define([
'color',
]
},
'admins': {
icon: 'fa fa-users',
content: [
'list-admins',
'add-admins'
]
},
'broadcast' : { // Msg.admin_cat_broadcast
icon: 'fa fa-bullhorn',
content : [
@ -94,7 +101,7 @@ define([
]
},
'apps': { // Msg.admin_cat_apps
icon: 'fa fa-wrench',
icon: 'fa fa-wrench',
content: [
'apps',
]
@ -226,6 +233,194 @@ define([
cb(button);
});
const evRefreshAdmins = Util.mkEvent();
sidebar.addItem('list-admins', cb => {
const metadataMgr = common.getMetadataMgr();
const privateData = metadataMgr.getPrivateData();
const removeAdmin = (edPublic, _cb) => {
const cb = Util.mkAsync(_cb);
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'ADMIN_DECREE',
data: ['RM_ADMIN_KEY', [edPublic]]
}, function (e, response) {
if (e || response.error) {
UI.warn(Messages.error);
console.error(e, response);
return void cb('ERROR');
}
if (typeof(cb) === "function") { cb(); }
});
};
const header = [
Messages.admin_listName,
Messages.admin_listKey,
Messages.admin_listAction
];
var list = blocks.table(header, []);
list.setAttribute('id', 'cp-admin-table');
let div = blocks.block([list]);
div.setAttribute('id', 'cp-admin-table-container');
const refreshTable = () => {
const admins = APP.instanceStatus.admins || [];
const newRows = admins.map(obj => {
let { name, edPublic, hardcoded } = obj;
name = name || Messages.admin_admin;
let button = blocks.button('danger','fa-ban', Messages.admin_usersRemove);
let $b = $(button);
Util.onClickEnter($b, () => {
$b.prop('disabled', 'disabled');
UI.confirm(Messages.admin_listConfirm, yes => {
if (!yes) { return $b.prop('disabled', false); }
removeAdmin(edPublic, err => {
$b.prop('disabled', false);
if (err) { return; }
APP.updateStatus(function () {
evRefreshAdmins.fire();
});
});
});
});
let note = Messages.admin_listHardcoded;
let action = hardcoded ? note : button;
return [name, edPublic, action];
});
list.updateContent(newRows);
};
refreshTable();
evRefreshAdmins.reg(() => {
refreshTable();
});
cb(div);
});
sidebar.addItem('add-admins', cb => {
const content = blocks.block();
const metadataMgr = common.getMetadataMgr();
const privateData = metadataMgr.getPrivateData();
const $div = $(content);
const addAdmin = (data, _cb) => {
const cb = Util.mkAsync(_cb);
const { ed, name } = data;
const key = Hash.getPublicSigningKeyString(privateData.origin, name, ed);
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'ADMIN_DECREE',
data: ['ADD_ADMIN_KEY', [key]]
}, function (e, response) {
if (e || response.error) {
UI.warn(Messages.error);
console.error(e, response);
return void cb('ERROR');
}
if (typeof(cb) === "function") { cb(); }
});
};
const keyInput = blocks.input({
placeholder: Messages.admin_accountMetadataPlaceholder
});
const keyLabel = blocks.labelledInput(Messages.admin_addKeyLabel, keyInput);
const keyButton = blocks.button('primary', 'fa-plus', Messages.tag_add);
const keyForm = blocks.form([keyLabel], blocks.nav([keyButton]));
const $keyInput = $(keyInput).on('input', () => {
let val = $keyInput.val().trim();
if (!val) {
keyInput.setCustomValidity('');
return;
}
let key = Keys.canonicalize(val);
if (keyInput.setCustomValidity) {
if (!key) {
const msg = Messages.admin_invalKey;
keyInput.setCustomValidity(msg);
} else {
keyInput.setCustomValidity('');
}
}
});
const $keyBtn = $(keyButton);
Util.onClickEnter($keyBtn, () => {
let val = $keyInput.val().trim();
let key = Keys.canonicalize(val);
if (!key) { return; }
// We have a valid key
let name = Messages.admin_admin;
try {
let parsed = Keys.parseUser(val);
name = parsed.user;
} catch (e) {}
$keyBtn.prop('disabled', 'disabled');
addAdmin({ ed:key, name }, (err) => {
$keyBtn.prop('disabled', false);
if (!err) { $keyInput.val(''); }
// refresh
APP.updateStatus(function () {
evRefreshAdmins.fire();
});
});
});
const drawContacts = () => {
$div.empty();
const members = {};
const admins = APP.instanceStatus.admins || [];
admins.forEach(obj => {
const { edPublic, name, hardcoded, first } = obj;
members[edPublic] = { name, hardcoded, first };
});
// Remove admins from contacts list
const friends = Util.clone(common.getFriends(false));
Object.keys(friends).forEach((curve) => {
const ed = friends[curve]?.edPublic;
if (members[ed]) { delete friends[curve]; }
});
let contactsGrid = UIElements.getUserGrid(Messages.admin_addAdminsAdd, {
common: common,
list: true,
large: true,
data: friends
}, function () {});
let addBtn = blocks.button('primary', 'fa-plus', Messages.tag_add);
Util.onClickEnter($(addBtn), () => {
var $sel = $(contactsGrid.div).find('.cp-usergrid-user.cp-selected');
nThen((waitFor) => {
$sel.each((i, el) => {
const $el = $(el);
let ed = $el.attr('data-ed');
let name = $el.attr('data-name');
if (!ed || !name) {
console.error('Missing data on selected user', el);
return void UI.warn(Messages.error);
}
addAdmin({ed, name}, waitFor());
});
}).nThen(() => {
APP.updateStatus(function () {
evRefreshAdmins.fire();
drawContacts();
});
});
});
evRefreshAdmins.reg(() => {
drawContacts();
});
const list = blocks.form([
//currentList.div,
contactsGrid.div,
], blocks.nav([addBtn]));
$div.append([keyForm, list]);
};
drawContacts();
cb(content);
});
var isHex = s => !/[^0-9a-f]/.test(s);
var sframeCommand = function (command, data, cb) {
@ -3928,7 +4123,7 @@ define([
// EXTENSION_POINT:ADMIN_ITEM
let utils = {
h, Util, Hash, UIElements
$, h, Util, Hash, UIElements, UI, APP
};
common.getExtensionsSync('ADMIN_ITEM').forEach(ext => {
if (!ext || !ext.id || typeof(ext.getContent) !== "function") {
@ -3952,13 +4147,19 @@ define([
sidebar.makeLeftside(categories);
};
var updateStatus = APP.updateStatus = function (cb) {
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'INSTANCE_STATUS',
}, function (e, data) {
if (e) { console.error(e); return void cb(e); }
if (!Array.isArray(data)) { return void cb('EINVAL'); }
APP.instanceStatus = data[0];
console.log("Status", APP.instanceStatus);
nThen(w => {
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'INSTANCE_STATUS',
}, w(function (e, data) {
if (e) { console.error(e); return void cb(e); }
if (!Array.isArray(data)) { return void cb('EINVAL'); }
APP.instanceStatus = data[0];
console.log("Status", APP.instanceStatus);
}));
require([`/api/config?${+new Date()}`], w(ApiConfig => {
APP.instanceConfig = ApiConfig;
}));
}).nThen(() => {
cb();
});
};

View File

@ -23,6 +23,7 @@ define([
'/customize/application_config.js',
'/components/nthen/index.js',
'/components/tweetnacl/nacl-fast.min.js'
], function (Config, Broadcast, Messages, Util, Hash, Cache,
Messaging, Constants, Feedback, Visible, UserObject, LocalStore, Channel, Block,
Cred, Login, Store, AppConfig, nThen) {
@ -229,7 +230,6 @@ define([
n = n(function (waitFor) {
require([
'/api/broadcast?'+ (+new Date()),
'/components/tweetnacl/nacl-fast.min.js'
], waitFor(function (Broadcast) {
nacl = window.nacl;
theirs = nacl.util.decodeBase64(Broadcast.curvePublic);
@ -1582,7 +1582,6 @@ define([
require([
'/common/media-tag.js',
'/common/outer/upload.js',
'/components/tweetnacl/nacl-fast.min.js'
], waitFor(function (_MT, _Upload) {
MediaTag = _MT;
Upload = _Upload;
@ -2453,6 +2452,17 @@ define([
window.RTCPeerConnection);
};
common.getAnonymousKeys = function (formSeed, channel) {
var array = window.nacl.util.decodeBase64(formSeed + channel);
var hash = window.nacl.hash(array);
var secretKey = window.nacl.util.encodeBase64(hash.subarray(32));
var publicKey = Hash.getCurvePublicFromPrivate(secretKey);
return {
curvePrivate: secretKey,
curvePublic: publicKey,
};
};
common.ready = (function () {
var env = {};
var initialized = false;

View File

@ -156,6 +156,35 @@ define([
return box;
};
// opts.values = { key1:label1, key2:label2 }
blocks.radio = (key, state, opts, onChange) => {
if (!opts?.values) {
return void console.error('NO_VALUES');
}
let all = Object.keys(opts.values).map(k => {
let v = opts.values[k];
let r = UI.createRadio(
`cp-${app}-${key}`,
`cp-${app}-${key}-${k}`,
v, state === k, {
input: { value: k },
label: { class: 'noTitle' }
}
);
if (typeof(onChange) === "function"){
$(r).find('input').on('change', function() {
onChange(k);
});
}
return r;
});
let block = h('div.cp-sidebar-flex-block', all);
if (opts && opts.spinner) {
block.spinner = UI.makeSpinner($(block));
}
return block;
};
blocks.table = function (header, entries) {
const table = h('table.cp-sidebar-table');
if (header) {
@ -255,6 +284,13 @@ define([
return box;
};
blocks.hintItem = (hint, item) => {
return blocks.form([
h('span.cp-sidebarlayout-description-item', hint),
item
]);
};
return blocks;
};

View File

@ -10,12 +10,13 @@ define([
'/common/common-interface.js',
'/common/hyperscript.js',
'/common/common-feedback.js',
'/common/userObject.js',
'/common/inner/cache.js',
'/customize/messages.js',
'/components/nthen/index.js',
'/components/saferphore/index.js',
'/components/jszip/dist/jszip.min.js',
], function ($, FileCrypto, Hash, Util, UI, h, Feedback,
], function ($, FileCrypto, Hash, Util, UI, h, Feedback, UO,
Cache, Messages, nThen, Saferphore, JsZip) {
var saveAs = window.saveAs;
@ -169,6 +170,7 @@ define([
data: fData
});
}
var href;
var parsed;
if (!fData.channel) {
@ -176,7 +178,7 @@ define([
parsed = {};
parsed['hashData'] = {type: 'link'};
} else {
href = (fData.href && fData.href.indexOf('#') !== -1) ? fData.href : fData.roHref;
href = UO.getHref(fData, ctx.currentCryptor);
parsed = Hash.parsePadUrl(href);
}
if (['pad', 'file', 'link'].indexOf(parsed.hashData.type) === -1) { return; }
@ -314,20 +316,27 @@ define([
if (typeof el === "object" && el.metadata !== true) { // if folder
var fName = getUnique(sanitize(k), '', existingNames);
existingNames.push(fName.toLowerCase());
ctx.currentCryptor = undefined;
return void makeFolder(ctx, el, zip.folder(fName), fd, sd);
}
if (ctx.data.sharedFolders[el]) { // if shared folder
let staticData = ctx.sf[el].static;
let obj = ctx.data.sharedFolders[el];
let parsed = Hash.parsePadUrl(obj.href || obj.roHref);
var secret = Hash.getSecrets('drive', parsed.hash, obj.password);
let cryptor = secret.keys?.secondaryKey ? UO.createCryptor(secret.keys?.secondaryKey)
: undefined;
ctx.currentCryptor = cryptor;
var sfData = ctx.sf[el].metadata;
var sfName = getUnique(sanitize((sfData && sfData.title) || 'Folder'), '', existingNames);
existingNames.push(sfName.toLowerCase());
let staticData = ctx.sf[el].static;
return void makeFolder(ctx, ctx.sf[el].root, zip.folder(sfName), ctx.sf[el].filesData, staticData);
}
var fData = fd[el] || (sd && sd[el]);
if (fData) {
addFile(ctx, zip, fData, existingNames);
return;
}
}
});
};

View File

@ -123,6 +123,7 @@ define([
var password, newPadPassword, newPadPasswordForce;
var initialPathInDrive;
var burnAfterReading;
var Handler;
var currentPad = window.CryptPad_location = {
app: '',
@ -156,10 +157,11 @@ define([
'/common/user-object.js',
'optional!/api/instance',
'/common/pad-types.js',
'/form/command-handler.js'
], waitFor(function (_CpNfOuter, _Cryptpad, _Crypto, _Cryptget, _SFrameChannel,
_SecureIframe, _UnsafeIframe, _OOIframe, _Notifier, _Hash, _Util, _Realtime, _Notify,
_Constants, _Feedback, _LocalStore, _Block, _Cache, _AppConfig, /* _Test,*/ _UserObject,
_Instance, _PadTypes) {
_Instance, _PadTypes, _Handler) {
CpNfOuter = _CpNfOuter;
Cryptpad = _Cryptpad;
Crypto = Utils.Crypto = _Crypto;
@ -183,6 +185,7 @@ define([
Utils.Block = _Block;
Utils.PadTypes = _PadTypes;
AppConfig = _AppConfig;
Handler = _Handler;
//Test = _Test;
if (localStorage.CRYPTPAD_URLARGS !== ApiConfig.requireConf.urlArgs) {
@ -2072,6 +2075,8 @@ define([
}
});
Handler.formCommandHandlers(sframeChan, Utils, nThen, Cryptpad);
var integrationSave = function () {};
if (cfg.integration) {
sframeChan.on('Q_INTEGRATION_SAVE', function (obj, cb) {

View File

@ -73,7 +73,7 @@ const factory = (AppConfig = {}, Util, Hash,
// Href exists and is not encrypted: return href
return pad.href;
}
if (pad.href) {
if (pad.href && cryptor) {
// Href exists and is encrypted
var d = cryptor.decrypt(pad.href);
// If we can decrypt, return the decrypted value, otherwise continue and return roHref

386
www/form/command-handler.js Normal file
View File

@ -0,0 +1,386 @@
// SPDX-FileCopyrightText: 2025 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
define([
'/components/tweetnacl/nacl-fast.min.js'
], function () {
var Handler = {};
var Nacl = window.nacl;
Handler.formCommandHandlers = function(sframeChan, Utils, nThen, Cryptpad) {
sframeChan.on('EV_EXPORT_SHEET', function (data) {
if (!data || !Array.isArray(data.content)) { return; }
sessionStorage.CP_formExportSheet = JSON.stringify(data);
var href = Utils.Hash.hashToHref('', 'sheet');
var a = window.open(href);
if (!a) { sframeChan.event('EV_POPUP_BLOCKED'); }
delete sessionStorage.CP_formExportSheet;
});
var u8_concat = function (A) {
var length = 0;
A.forEach(function (a) { length += a.length; });
var total = new Uint8Array(length);
var offset = 0;
A.forEach(function (a) {
total.set(a, offset);
offset += a.length;
});
return total;
};
var anonProof = function (channel, theirPub, anonKeys) {
var u8_plain = Nacl.util.decodeUTF8(channel);
var u8_nonce = Nacl.randomBytes(Nacl.box.nonceLength);
var u8_cipher = Nacl.box(
u8_plain,
u8_nonce,
Nacl.util.decodeBase64(theirPub),
Nacl.util.decodeBase64(anonKeys.curvePrivate)
);
var u8_bundle = u8_concat([
u8_nonce, // 24 uint8s
u8_cipher, // arbitrary length
]);
return {
key: anonKeys.curvePublic,
proof: Nacl.util.encodeBase64(u8_bundle)
};
};
var deleteLines = false; // "false" to support old forms
sframeChan.on("Q_FETCH_MY_ANSWERS", function (data, cb) {
var answers = [];
var myKeys;
nThen(function (w) {
Cryptpad.getFormKeys(w(function (keys) {
myKeys = keys;
}));
Cryptpad.getFormAnswer({channel: data.channel}, w(function (obj) {
if (!obj || obj.error) {
if (obj && obj.error === "ENODRIVE") {
var answered = JSON.parse(localStorage.CP_formAnswered || "[]");
if (answered.indexOf(data.channel) !== -1) {
cb({error:'EANSWERED'});
} else {
cb();
}
return void w.abort();
}
w.abort();
return void cb(obj);
}
// Get the latest edit per uid
var temp = {};
obj.forEach(function (ans) {
var uid = ans.uid || '000';
temp[uid] = ans;
});
answers = Object.values(temp);
}));
Cryptpad.getPadMetadata({channel: data.channel}, w(function (md) {
if (md && md.deleteLines) { deleteLines = true; }
}));
}).nThen(function () {
var n = nThen;
var err;
var all = {};
answers.forEach(function (answer) {
n = n(function(waitFor) {
var finalKeys = myKeys;
if (answer.anonymous) {
if (!myKeys.formSeed) {
err = 'ANONYMOUS_ERROR';
console.error('ANONYMOUS_ERROR', answer);
return;
}
finalKeys = Cryptpad.getAnonymousKeys(myKeys.formSeed, data.channel, Utils);
}
Cryptpad.getHistoryRange({
channel: data.channel,
lastKnownHash: answer.hash,
toHash: answer.hash,
}, waitFor(function (obj) {
if (obj && obj.error) { err = obj.error; return; }
var messages = obj.messages;
if (!messages.length) {
// TODO delete from drive.forms?
return;
}
if (obj.lastKnownHash !== answer.hash) { return; }
try {
var res = Utils.Crypto.Mailbox.openOwnSecretLetter(messages[0].msg, {
validateKey: data.validateKey,
ephemeral_private: Nacl.util.decodeBase64(answer.curvePrivate),
my_private: Nacl.util.decodeBase64(finalKeys.curvePrivate),
their_public: Nacl.util.decodeBase64(data.publicKey)
});
var parsed = JSON.parse(res.content);
parsed._isAnon = answer.anonymous;
parsed._time = messages[0].time;
if (deleteLines) { parsed._hash = answer.hash; }
var uid = parsed._uid || '000';
if (all[uid] && !all[uid]._isAnon) { parsed._isAnon = false; }
all[uid] = parsed;
} catch (e) {
err = e;
}
}));
}).nThen;
});
n(function () {
if (err) { return void cb({error: err}); }
cb(all);
});
});
});
var u8_slice = function (A, start, end) {
return new Uint8Array(Array.prototype.slice.call(A, start, end));
};
var checkAnonProof = function (proofObj, channel, curvePrivate) {
var pub = proofObj.key;
var proofTxt = proofObj.proof;
try {
var u8_bundle = Nacl.util.decodeBase64(proofTxt);
var u8_nonce = u8_slice(u8_bundle, 0, Nacl.box.nonceLength);
var u8_cipher = u8_slice(u8_bundle, Nacl.box.nonceLength);
var u8_plain = Nacl.box.open(
u8_cipher,
u8_nonce,
Nacl.util.decodeBase64(pub),
Nacl.util.decodeBase64(curvePrivate)
);
return channel === Nacl.util.encodeUTF8(u8_plain);
} catch (e) {
console.error(e);
return false;
}
};
sframeChan.on('Q_FORM_FETCH_ANSWERS', function (data, _cb) {
var formHref = data.href;
var cb = Utils.Util.once(_cb);
var myKeys = {};
var myFormKeys;
var accessKeys;
var CPNetflux, Pinpad;
var network;
var noDriveAnswered = false;
nThen(function (w) {
require([
'chainpad-netflux',
'/common/pinpad.js',
], w(function (_CPNetflux, _Pinpad) {
CPNetflux = _CPNetflux;
Pinpad = _Pinpad;
}));
var personalDrive = !Cryptpad.initialTeam || Cryptpad.initialTeam === -1;
Cryptpad.getAccessKeys(w(function (_keys) {
if (!Array.isArray(_keys)) { return; }
accessKeys = _keys;
_keys.some(function (_k) {
if ((personalDrive && !_k.id) || Cryptpad.initialTeam === Number(_k.id)) {
myKeys = _k;
return true;
}
});
}));
Cryptpad.getFormKeys(w(function (keys) {
if (!keys.curvePublic && !keys.formSeed) {
// No drive mode
var answered = JSON.parse(localStorage.CP_formAnswered || "[]");
noDriveAnswered = answered.indexOf(data.channel) !== -1;
}
myFormKeys = keys;
}));
Cryptpad.makeNetwork(w(function (err, nw) {
network = nw;
}));
Cryptpad.getPadMetadata({channel: data.channel}, w(function (md) {
if (md && md.deleteLines) { deleteLines = true; }
}));
}).nThen(function () {
if (!network) { return void cb({error: "E_CONNECT"}); }
if (myFormKeys.formSeed) {
myFormKeys = Cryptpad.getAnonymousKeys(myFormKeys.formSeed, data.channel, Utils);
}
var keys;
var privateKey, publicKey;
var formData;
if (data.drive) {
var secret = Utils.Hash.getSecrets('form', formHref, data.password);
keys = secret && secret.keys;
formData = Utils.Hash.getFormData(secret);
} else {
formData = Utils.Hash.getFormData(Utils.secret);
keys = Utils.secret && Utils.secret.keys;
}
privateKey = formData.form_private;
publicKey = formData.form_public;
var curvePrivate = privateKey || data.privateKey;
if (!curvePrivate) { return void cb({error: 'EFORBIDDEN'}); }
var crypto = Utils.Crypto.Mailbox.createEncryptor({
curvePrivate: curvePrivate,
curvePublic: publicKey || data.publicKey,
validateKey: data.validateKey
});
var config = {
network: network,
channel: data.channel,
noChainPad: true,
validateKey: keys.secondaryValidateKey,
owners: [myKeys.edPublic],
crypto: crypto,
metadata: {
deleteLines: true
}
//Cache: Utils.Cache // TODO enable cache for form responses when the cache stops evicting old answers
};
var results = {};
config.onError = function (info) {
cb({ error: info.type });
};
config.onRejected = function (data, cb) {
if (!Array.isArray(data) || !data.length || data[0].length !== 16) {
return void cb(true);
}
if (!Array.isArray(accessKeys)) { return void cb(true); }
network.historyKeeper = data[0];
nThen(function (waitFor) {
accessKeys.forEach(function (obj) {
Pinpad.create(network, obj, waitFor(function (e) {
if (e) { console.error(e); }
}));
});
}).nThen(function () {
cb();
});
};
config.onReady = function () {
var myKey;
// If we have submitted an anonymous answer, retrieve it
if (myFormKeys.curvePublic && results[myFormKeys.curvePublic]) {
myKey = myFormKeys.curvePublic;
}
cb({
noDriveAnswered: noDriveAnswered,
myKey: myKey,
results: results
});
network.disconnect();
};
config.onMessage = function (msg, peer, vKey, isCp, hash, senderCurve, cfg) {
var parsed = Utils.Util.tryParse(msg);
if (!parsed) { return; }
var uid = parsed._uid || '000';
// If we have a "non-anonymous" answer, it may be the edition of a
// previous anonymous answer. Check if a previous anonymous answer exists
// with the same uid and delete it.
if (parsed._proof) {
var check = checkAnonProof(parsed._proof, data.channel, curvePrivate);
var theirAnonKey = parsed._proof.key;
if (check && results[theirAnonKey] && results[theirAnonKey][uid]) {
delete results[theirAnonKey][uid];
}
}
parsed._time = cfg && cfg.time;
if (deleteLines) { parsed._hash = hash; }
if (data.cantEdit && results[senderCurve]
&& results[senderCurve][uid]) { return; }
results[senderCurve] = results[senderCurve] || {};
results[senderCurve][uid] = {
msg: parsed,
hash: hash,
time: cfg && cfg.time
};
};
CPNetflux.start(config);
});
});
var noDriveSeed = Utils.Hash.createChannelId();
sframeChan.on("Q_FORM_SUBMIT", function (data, cb) {
var box = data.mailbox;
var myKeys;
nThen(function (w) {
Cryptpad.getFormKeys(w(function (keys) {
// If formSeed doesn't exists, it means we're probably in noDrive mode.
// We can create a seed in localStorage.
if (!keys.formSeed) {
// No drive mode
keys = { formSeed: noDriveSeed };
}
myKeys = keys;
}));
}).nThen(function () {
var myAnonymousKeys;
if (data.anonymous) {
if (!myKeys.formSeed) { return void cb({ error: "ANONYMOUS_ERROR" }); }
myKeys = Cryptpad.getAnonymousKeys(myKeys.formSeed, box.channel, Utils);
} else {
myAnonymousKeys = Cryptpad.getAnonymousKeys(myKeys.formSeed, box.channel, Utils);
}
var keys = Utils.secret && Utils.secret.keys;
myKeys.signingKey = keys.secondarySignKey;
var ephemeral_keypair = Nacl.box.keyPair();
var ephemeral_private = Nacl.util.encodeBase64(ephemeral_keypair.secretKey);
myKeys.ephemeral_keypair = ephemeral_keypair;
if (myAnonymousKeys) {
var proof = anonProof(box.channel, box.publicKey, myAnonymousKeys);
data.results._proof = proof;
}
var crypto = Utils.Crypto.Mailbox.createEncryptor(myKeys);
var uid = data.results._uid || Utils.Util.uid();
data.results._uid = uid;
var text = JSON.stringify(data.results);
var ciphertext = crypto.encrypt(text, box.publicKey);
var hash = ciphertext.slice(0,64);
Cryptpad.anonRpcMsg("WRITE_PRIVATE_MESSAGE", [
box.channel,
ciphertext
], function (err, response) {
Cryptpad.storeFormAnswer({
uid: uid,
channel: box.channel,
hash: hash,
curvePrivate: ephemeral_private,
anonymous: Boolean(data.anonymous)
}, function () {
var res = data.results;
res._isAnon = data.anonymous;
res._time = +new Date();
if (deleteLines) { res._hash = hash; }
cb({
error: err,
response: response,
results: res
});
});
});
});
});
sframeChan.on("Q_FORM_DELETE_ALL_ANSWERS", function (data, cb) {
if (!data || !data.channel) { return void cb({error: 'EINVAL'}); }
Cryptpad.clearOwnedChannel(data, cb);
});
sframeChan.on("Q_FORM_DELETE_ANSWER", function (data, cb) {
if (!deleteLines) {
return void cb({error: 'EFORBIDDEN'});
}
Cryptpad.deleteFormAnswers(data, cb);
});
sframeChan.on("Q_FORM_MUTE", function (data, cb) {
if (!Utils.secret) { return void cb({error: 'EINVAL'}); }
Cryptpad.muteChannel(Utils.secret.channel, data.muted, cb);
});
};
return Handler;
});

View File

@ -93,7 +93,11 @@ define([
data[id] = TYPES[type].exportCSV(msg[key], form[key]);
return;
}
data[id] = msg[key];
if (type === 'date') {
data[id] = new Date(msg[key]).toISOString();
} else {
data[id] = msg[key];
}
});
r.push(data);
});
@ -115,7 +119,6 @@ define([
var form = content.form;
var questions = [Messages.form_poll_time, Messages.share_formView];
order.forEach(function (key) {
var obj = form[key];
if (!obj) { return; }
@ -162,12 +165,49 @@ define([
return csv;
};
Export.main = function (content, cb) {
var json = Util.clone(content || {});
delete json.answers;
cb(new Blob([JSON.stringify(json, 0, 2)], {
type: 'application/json;charset=utf-8'
}));
var getFullOrder = function (content) {
var order = content.order.slice();
var getSections = function (content) {
var uids = Object.keys(content.form).filter(function (uid) {
return content.form[uid].type === 'section';
});
return uids;
};
getSections(content).forEach(function (uid) {
var block = content.form[uid];
if (!block.opts || !Array.isArray(block.opts.questions)) { return; }
var idx = order.indexOf(uid);
if (idx === -1) { return; }
idx++;
block.opts.questions.forEach(function (el, i) {
order.splice(idx+i, 0, el);
});
});
return order;
};
Export.main = function (content, cb, ext, sframeChan, parsed) {
if (sframeChan && content.form) {
var _answers = content["answers"];
_answers['href'] = parsed.hash;
_answers['password'] = parsed.password;
_answers['drive'] = true;
var answers;
sframeChan.query("Q_FORM_FETCH_ANSWERS", _answers, function (err, obj) {
answers = obj && obj.results;
var types = {input: {}, textarea: {}, radio: {}, multiradio: {}, date: {}, checkbox: {}, multicheck: {}, sort: {}, poll: {}};
var arr = Export.results(content, answers, types, getFullOrder(content), "json");
cb(new Blob([arr], {
type: 'application/json;charset=utf-8'
}));
});
} else {
var json = Util.clone(content || {});
delete json.answers;
cb(new Blob([JSON.stringify(json, 0, 2)], {
type: 'application/json;charset=utf-8'
}));
}
};
return Export;

View File

@ -4345,12 +4345,9 @@ define([
var refreshPage = APP.refreshPage = function (current, direction) {
$page.empty();
if (!current || current < 1) { current = 1; }
var checkPages = checkEmptyPages();
var shownContent = checkPages[0];
var shownPages = checkPages[1];
var shownLength = shownContent.length;
if (pgcontent[(current - 1)] && pgcontent[current-1].empty) {
if (direction === 'next') {
current++;
@ -4367,14 +4364,6 @@ define([
}
}
var state = h('span', Messages._getKey('form_page', [shownPages.indexOf(_content[current-1])+1, shownLength]));
evOnChange.reg(function(){
var checkPages = checkEmptyPages();
var shownContent = checkPages[0];
var shownPages = checkPages[1];
var shownLength = shownContent.length;
$(state).text(Messages._getKey('form_page', [shownPages.indexOf(_content[current-1])+1, shownLength]));
});
var left = h('button.btn.btn-secondary.cp-prev', [
h('i.fa.fa-arrow-left'),
]);
@ -4382,9 +4371,40 @@ define([
h('i.fa.fa-arrow-right'),
]);
var togglePageArrows = function(checkPages) {
var shownContent = checkPages[0];
var shownPages = checkPages[1];
if (shownPages.indexOf(_content[current-1])+1 === shownContent.length) {
$(right).css('visibility', 'hidden');
} else {
$(right).css('visibility', 'visible');
}
if (current === 1) {$(left).css('visibility', 'hidden');}
$container.find('.cp-form-page').hide();
$($container.find('.cp-form-page').get(current-1)).show();
if (current < shownContent.length) {
$container.find('.cp-form-send-container').hide();
} else {
$container.find('.cp-form-send-container').show();
}
};
var state = h('span', Messages._getKey('form_page', [shownPages.indexOf(_content[current-1])+1, shownContent.length]));
evOnChange.reg(function(){
var checkPages = checkEmptyPages();
togglePageArrows(checkPages);
var shownContent = checkPages[0];
var shownPages = checkPages[1];
$(state).text(Messages._getKey('form_page', [shownPages.indexOf(_content[current-1])+1, shownContent.length]));
});
if (shownPages.indexOf(_content[current-1])+1 === shownContent.length) { $(right).css('visibility', 'hidden'); }
if (current === 1) { $(left).css('visibility', 'hidden'); }
togglePageArrows(checkPages);
$(left).click(function () {
refreshPage(current - 1, 'prev');
});
@ -4392,14 +4412,6 @@ define([
refreshPage(current + 1, 'next');
});
$page.append([left, state, right]);
$container.find('.cp-form-page').hide();
$($container.find('.cp-form-page').get(current-1)).show();
if (current !== pages) {
$container.find('.cp-form-send-container').hide();
} else {
$container.find('.cp-form-send-container').show();
}
};
setTimeout(refreshPage);
}

View File

@ -8,9 +8,7 @@ define([
'/api/config',
'/common/dom-ready.js',
'/common/sframe-common-outer.js',
'/components/tweetnacl/nacl-fast.min.js',
], function (nThen, ApiConfig, DomReady, SFCommonO) {
var Nacl = window.nacl;
var href, hash;
// Loaded in load #2
@ -21,7 +19,6 @@ define([
href = obj.href;
hash = obj.hash;
}).nThen(function (/*waitFor*/) {
var privateKey, publicKey;
var channels = {};
var getPropChannels = function () {
return channels;
@ -41,11 +38,11 @@ define([
var validateKey = keys.secondaryValidateKey;
meta.form_answerValidateKey = validateKey;
publicKey = meta.form_public = formData.form_public;
privateKey = meta.form_private = formData.form_private;
meta.form_public = formData.form_public;
meta.form_private = formData.form_private;
meta.form_auditorHash = formData.form_auditorHash;
};
var addRpc = function (sframeChan, Cryptpad, Utils) {
var addRpc = function (sframeChan, Cryptpad) {
sframeChan.on('EV_FORM_PIN', function (data) {
channels.answersChannel = data.channel;
Cryptpad.changeMetadata();
@ -57,377 +54,6 @@ define([
});
});
});
sframeChan.on('EV_EXPORT_SHEET', function (data) {
if (!data || !Array.isArray(data.content)) { return; }
sessionStorage.CP_formExportSheet = JSON.stringify(data);
var href = Utils.Hash.hashToHref('', 'sheet');
var a = window.open(href);
if (!a) { sframeChan.event('EV_POPUP_BLOCKED'); }
delete sessionStorage.CP_formExportSheet;
});
var getAnonymousKeys = function (formSeed, channel) {
var array = Nacl.util.decodeBase64(formSeed + channel);
var hash = Nacl.hash(array);
var secretKey = Nacl.util.encodeBase64(hash.subarray(32));
var publicKey = Utils.Hash.getCurvePublicFromPrivate(secretKey);
return {
curvePrivate: secretKey,
curvePublic: publicKey,
};
};
var u8_slice = function (A, start, end) {
return new Uint8Array(Array.prototype.slice.call(A, start, end));
};
var u8_concat = function (A) {
var length = 0;
A.forEach(function (a) { length += a.length; });
var total = new Uint8Array(length);
var offset = 0;
A.forEach(function (a) {
total.set(a, offset);
offset += a.length;
});
return total;
};
var anonProof = function (channel, theirPub, anonKeys) {
var u8_plain = Nacl.util.decodeUTF8(channel);
var u8_nonce = Nacl.randomBytes(Nacl.box.nonceLength);
var u8_cipher = Nacl.box(
u8_plain,
u8_nonce,
Nacl.util.decodeBase64(theirPub),
Nacl.util.decodeBase64(anonKeys.curvePrivate)
);
var u8_bundle = u8_concat([
u8_nonce, // 24 uint8s
u8_cipher, // arbitrary length
]);
return {
key: anonKeys.curvePublic,
proof: Nacl.util.encodeBase64(u8_bundle)
};
};
var checkAnonProof = function (proofObj, channel, curvePrivate) {
var pub = proofObj.key;
var proofTxt = proofObj.proof;
try {
var u8_bundle = Nacl.util.decodeBase64(proofTxt);
var u8_nonce = u8_slice(u8_bundle, 0, Nacl.box.nonceLength);
var u8_cipher = u8_slice(u8_bundle, Nacl.box.nonceLength);
var u8_plain = Nacl.box.open(
u8_cipher,
u8_nonce,
Nacl.util.decodeBase64(pub),
Nacl.util.decodeBase64(curvePrivate)
);
return channel === Nacl.util.encodeUTF8(u8_plain);
} catch (e) {
console.error(e);
return false;
}
};
var deleteLines = false; // "false" to support old forms
sframeChan.on('Q_FORM_FETCH_ANSWERS', function (data, _cb) {
var cb = Utils.Util.once(_cb);
var myKeys = {};
var myFormKeys;
var accessKeys;
var CPNetflux, Pinpad;
var network;
var noDriveAnswered = false;
nThen(function (w) {
require([
'chainpad-netflux',
'/common/pinpad.js',
], w(function (_CPNetflux, _Pinpad) {
CPNetflux = _CPNetflux;
Pinpad = _Pinpad;
}));
var personalDrive = !Cryptpad.initialTeam || Cryptpad.initialTeam === -1;
Cryptpad.getAccessKeys(w(function (_keys) {
if (!Array.isArray(_keys)) { return; }
accessKeys = _keys;
_keys.some(function (_k) {
if ((personalDrive && !_k.id) || Cryptpad.initialTeam === Number(_k.id)) {
myKeys = _k;
return true;
}
});
}));
Cryptpad.getFormKeys(w(function (keys) {
if (!keys.curvePublic && !keys.formSeed) {
// No drive mode
var answered = JSON.parse(localStorage.CP_formAnswered || "[]");
noDriveAnswered = answered.indexOf(data.channel) !== -1;
}
myFormKeys = keys;
}));
Cryptpad.makeNetwork(w(function (err, nw) {
network = nw;
}));
Cryptpad.getPadMetadata({channel: data.channel}, w(function (md) {
if (md && md.deleteLines) { deleteLines = true; }
}));
}).nThen(function () {
if (!network) { return void cb({error: "E_CONNECT"}); }
if (myFormKeys.formSeed) {
myFormKeys = getAnonymousKeys(myFormKeys.formSeed, data.channel);
}
var keys = Utils.secret && Utils.secret.keys;
var curvePrivate = privateKey || data.privateKey;
if (!curvePrivate) { return void cb({error: 'EFORBIDDEN'}); }
var crypto = Utils.Crypto.Mailbox.createEncryptor({
curvePrivate: curvePrivate,
curvePublic: publicKey || data.publicKey,
validateKey: data.validateKey
});
var config = {
network: network,
channel: data.channel,
noChainPad: true,
validateKey: keys.secondaryValidateKey,
owners: [myKeys.edPublic],
crypto: crypto,
metadata: {
deleteLines: true
}
//Cache: Utils.Cache // TODO enable cache for form responses when the cache stops evicting old answers
};
var results = {};
config.onError = function (info) {
cb({ error: info.type });
};
config.onRejected = function (data, cb) {
if (!Array.isArray(data) || !data.length || data[0].length !== 16) {
return void cb(true);
}
if (!Array.isArray(accessKeys)) { return void cb(true); }
network.historyKeeper = data[0];
nThen(function (waitFor) {
accessKeys.forEach(function (obj) {
Pinpad.create(network, obj, waitFor(function (e) {
if (e) { console.error(e); }
}));
});
}).nThen(function () {
cb();
});
};
config.onReady = function () {
var myKey;
// If we have submitted an anonymous answer, retrieve it
if (myFormKeys.curvePublic && results[myFormKeys.curvePublic]) {
myKey = myFormKeys.curvePublic;
}
cb({
noDriveAnswered: noDriveAnswered,
myKey: myKey,
results: results
});
network.disconnect();
};
config.onMessage = function (msg, peer, vKey, isCp, hash, senderCurve, cfg) {
var parsed = Utils.Util.tryParse(msg);
if (!parsed) { return; }
var uid = parsed._uid || '000';
// If we have a "non-anonymous" answer, it may be the edition of a
// previous anonymous answer. Check if a previous anonymous answer exists
// with the same uid and delete it.
if (parsed._proof) {
var check = checkAnonProof(parsed._proof, data.channel, curvePrivate);
var theirAnonKey = parsed._proof.key;
if (check && results[theirAnonKey] && results[theirAnonKey][uid]) {
delete results[theirAnonKey][uid];
}
}
parsed._time = cfg && cfg.time;
if (deleteLines) { parsed._hash = hash; }
if (data.cantEdit && results[senderCurve]
&& results[senderCurve][uid]) { return; }
results[senderCurve] = results[senderCurve] || {};
results[senderCurve][uid] = {
msg: parsed,
hash: hash,
time: cfg && cfg.time
};
};
CPNetflux.start(config);
});
});
sframeChan.on("Q_FETCH_MY_ANSWERS", function (data, cb) {
var answers = [];
var myKeys;
nThen(function (w) {
Cryptpad.getFormKeys(w(function (keys) {
myKeys = keys;
}));
Cryptpad.getFormAnswer({channel: data.channel}, w(function (obj) {
if (!obj || obj.error) {
if (obj && obj.error === "ENODRIVE") {
var answered = JSON.parse(localStorage.CP_formAnswered || "[]");
if (answered.indexOf(data.channel) !== -1) {
cb({error:'EANSWERED'});
} else {
cb();
}
return void w.abort();
}
w.abort();
return void cb(obj);
}
// Get the latest edit per uid
var temp = {};
obj.forEach(function (ans) {
var uid = ans.uid || '000';
temp[uid] = ans;
});
answers = Object.values(temp);
}));
Cryptpad.getPadMetadata({channel: data.channel}, w(function (md) {
if (md && md.deleteLines) { deleteLines = true; }
}));
}).nThen(function () {
var n = nThen;
var err;
var all = {};
answers.forEach(function (answer) {
n = n(function(waitFor) {
var finalKeys = myKeys;
if (answer.anonymous) {
if (!myKeys.formSeed) {
err = 'ANONYMOUS_ERROR';
console.error('ANONYMOUS_ERROR', answer);
return;
}
finalKeys = getAnonymousKeys(myKeys.formSeed, data.channel);
}
Cryptpad.getHistoryRange({
channel: data.channel,
lastKnownHash: answer.hash,
toHash: answer.hash,
}, waitFor(function (obj) {
if (obj && obj.error) { err = obj.error; return; }
var messages = obj.messages;
if (!messages.length) {
// TODO delete from drive.forms?
return;
}
if (obj.lastKnownHash !== answer.hash) { return; }
try {
var res = Utils.Crypto.Mailbox.openOwnSecretLetter(messages[0].msg, {
validateKey: data.validateKey,
ephemeral_private: Nacl.util.decodeBase64(answer.curvePrivate),
my_private: Nacl.util.decodeBase64(finalKeys.curvePrivate),
their_public: Nacl.util.decodeBase64(data.publicKey)
});
var parsed = JSON.parse(res.content);
parsed._isAnon = answer.anonymous;
parsed._time = messages[0].time;
if (deleteLines) { parsed._hash = answer.hash; }
var uid = parsed._uid || '000';
if (all[uid] && !all[uid]._isAnon) { parsed._isAnon = false; }
all[uid] = parsed;
} catch (e) {
err = e;
}
}));
}).nThen;
});
n(function () {
if (err) { return void cb({error: err}); }
cb(all);
});
});
});
var noDriveSeed = Utils.Hash.createChannelId();
sframeChan.on("Q_FORM_SUBMIT", function (data, cb) {
var box = data.mailbox;
var myKeys;
nThen(function (w) {
Cryptpad.getFormKeys(w(function (keys) {
// If formSeed doesn't exists, it means we're probably in noDrive mode.
// We can create a seed in localStorage.
if (!keys.formSeed) {
// No drive mode
keys = { formSeed: noDriveSeed };
}
myKeys = keys;
}));
}).nThen(function () {
var myAnonymousKeys;
if (data.anonymous) {
if (!myKeys.formSeed) { return void cb({ error: "ANONYMOUS_ERROR" }); }
myKeys = getAnonymousKeys(myKeys.formSeed, box.channel);
} else {
myAnonymousKeys = getAnonymousKeys(myKeys.formSeed, box.channel);
}
var keys = Utils.secret && Utils.secret.keys;
myKeys.signingKey = keys.secondarySignKey;
var ephemeral_keypair = Nacl.box.keyPair();
var ephemeral_private = Nacl.util.encodeBase64(ephemeral_keypair.secretKey);
myKeys.ephemeral_keypair = ephemeral_keypair;
if (myAnonymousKeys) {
var proof = anonProof(box.channel, box.publicKey, myAnonymousKeys);
data.results._proof = proof;
}
var crypto = Utils.Crypto.Mailbox.createEncryptor(myKeys);
var uid = data.results._uid || Utils.Util.uid();
data.results._uid = uid;
var text = JSON.stringify(data.results);
var ciphertext = crypto.encrypt(text, box.publicKey);
var hash = ciphertext.slice(0,64);
Cryptpad.anonRpcMsg("WRITE_PRIVATE_MESSAGE", [
box.channel,
ciphertext
], function (err, response) {
Cryptpad.storeFormAnswer({
uid: uid,
channel: box.channel,
hash: hash,
curvePrivate: ephemeral_private,
anonymous: Boolean(data.anonymous)
}, function () {
var res = data.results;
res._isAnon = data.anonymous;
res._time = +new Date();
if (deleteLines) { res._hash = hash; }
cb({
error: err,
response: response,
results: res
});
});
});
});
});
sframeChan.on("Q_FORM_DELETE_ALL_ANSWERS", function (data, cb) {
if (!data || !data.channel) { return void cb({error: 'EINVAL'}); }
Cryptpad.clearOwnedChannel(data, cb);
});
sframeChan.on("Q_FORM_DELETE_ANSWER", function (data, cb) {
if (!deleteLines) {
return void cb({error: 'EFORBIDDEN'});
}
Cryptpad.deleteFormAnswers(data, cb);
});
sframeChan.on("Q_FORM_MUTE", function (data, cb) {
if (!Utils.secret) { return void cb({error: 'EINVAL'}); }
Cryptpad.muteChannel(Utils.secret.channel, data.muted, cb);
});
};
SFCommonO.start({
addData: addData,