feat!: remove old cryptpad server

BREAKING CHANGE: remove old cryptpad server from the codebase
This commit is contained in:
Fabrice Mouhartem 2026-08-14 15:44:07 +02:00
parent 2860bb68d8
commit f7bfba9146
No known key found for this signature in database
56 changed files with 0 additions and 16807 deletions

View File

@ -1,234 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const WebSocketServer = require('ws').Server;
const NetfluxSrv = require('chainpad-server');
const Decrees = require("./decrees");
const nThen = require("nthen");
const Fs = require("fs");
const Fse = require("fs-extra");
const Path = require("path");
const Nacl = require("tweetnacl/nacl-fast");
const Hash = require('./common-hash');
const Util = require('./common-util');
module.exports.create = function (Env) {
var log = Env.Log;
nThen(function (w) {
Decrees.load(Env, w(function (err) {
Env.flushCache();
if (err) {
log.error('DECREES_LOADING', {
error: err.code || err,
message: err.message,
});
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 || [];
// If we don't have any admin on this instance, print an onboarding link
if (Array.isArray(admins) && admins.length) { return; }
let token = Env.installToken;
let printLink = () => {
let url = `${Env.httpUnsafeOrigin}/install/#${token}`;
console.log('=============================');
console.log('Create your first admin account and customize your instance by visiting');
console.log(url);
console.log('=============================');
};
// If we already have a token, print it
if (token) { return void printLink(); }
// Otherwise create a new token
let decreeName = Path.join(Env.paths.decree, 'decree.ndjson');
token = Hash.createChannelId() + Hash.createChannelId();
let decree = ["ADD_INSTALL_TOKEN",[token],"",+new Date()];
Fs.appendFile(decreeName, JSON.stringify(decree) + '\n', w(function (err) {
if (err) { console.log(err); return; }
Env.installToken = token;
Env.envUpdated.fire();
printLink();
}));
}).nThen(function () {
if (!Env.admins.length) {
Env.Log.info('NO_ADMIN_CONFIGURED', {
message: `Your instance is not correctly configured for production usage. Review its checkup page for more information.`,
details: new URL('/checkup/', Env.httpUnsafeOrigin).href,
});
}
}).nThen(function (w) {
// we assume the server has generated a secret used to validate JWT tokens
if (typeof(Env.bearerSecret) === 'string') { return; }
// if one does not exist, then create one and remember it
// 256 bits
var bearerSecret = Util.encodeBase64(Nacl.randomBytes(32));
Env.bearerSecret = bearerSecret;
Env.Log.info("GENERATING_BEARER_SECRET", {});
Decrees.write(Env, [
'SET_BEARER_SECRET',
[bearerSecret],
'INTERNAL',
+new Date()
], w(function (err) {
if (err) { throw err; }
}));
}).nThen(function (w) {
Fse.mkdirp(Env.paths.block, w(function (err) {
if (err) {
log.error("BLOCK_FOLDER_CREATE_FAILED", err);
}
}));
}).nThen(function (w) {
var fullPath = Path.join(Env.paths.block, 'placeholder.txt');
Fs.writeFile(fullPath, 'PLACEHOLDER\n', w(function (err) {
if (err) {
log.error('BLOCK_PLACEHOLDER_CREATE_FAILED', err);
}
}));
}).nThen(function () {
// asynchronously create a historyKeeper and RPC together
require('./historyKeeper.js').create(Env, function (err, historyKeeper) {
if (err) { throw err; }
var noop = function () {};
var special_errors = {};
['EPIPE', 'ECONNRESET'].forEach(function (k) { special_errors[k] = noop; });
special_errors.NF_ENOENT = function (error, label, info) {
delete info.stack;
log.error(label, {
info: info,
});
};
// spawn ws server and attach netflux event handlers
let Server = Env.Server = NetfluxSrv.create(new WebSocketServer({ server: Env.httpServer}))
.on('channelClose', historyKeeper.channelClose)
.on('channelMessage', historyKeeper.channelMessage)
.on('channelOpen', historyKeeper.channelOpen)
.on('sessionClose', historyKeeper.sessionClose)
.on('sessionOpen', historyKeeper.sessionOpen)
.on('error', function (error, label, info) {
if (!error) { return; }
var code = error && (error.code || error.message);
if (code) {
/* EPIPE,ECONNERESET, NF_ENOENT */
if (typeof(special_errors[code]) === 'function') {
return void special_errors[code](error, label, info);
}
}
/* labels:
SEND_MESSAGE_FAIL, SEND_MESSAGE_FAIL_2, FAIL_TO_DISCONNECT,
FAIL_TO_TERMINATE, HANDLE_CHANNEL_LEAVE, NETFLUX_BAD_MESSAGE,
NETFLUX_WEBSOCKET_ERROR
*/
log.error(label, {
code: error.code,
message: error.message,
stack: error.stack,
info: info,
});
})
.register(historyKeeper.id, historyKeeper.directMessage);
// Store max active WS during the last day (reset when sending ping if enabled)
setInterval(() => {
try {
// Concurrent usage data
let oldWs = Env.maxConcurrentWs || 0;
let oldUniqueWs = Env.maxConcurrentUniqueWs || 0;
let oldChans = Env.maxActiveChannels || 0;
let oldUsers = Env.maxConcurrentRegUsers || 0;
let stats = Server.getSessionStats();
let chans = Server.getActiveChannelCount();
const map = Env.netfluxUsers;
// Extract public key from each ws connection
let regKeys = Object.keys(map).map(id => {
return Object.keys(map[id] || {})[0];
}).filter(Boolean);
// Convert to set to get only unique values
let regSet = new Set(regKeys);
const reg = regSet.size;
Env.maxConcurrentWs = Math.max(oldWs, stats.total);
Env.maxConcurrentUniqueWs = Math.max(oldUniqueWs, stats.unique);
Env.maxConcurrentRegUsers = Math.max(oldUsers, reg);
Env.maxActiveChannels = Math.max(oldChans, chans);
} catch (e) {}
}, 10000);
// Clean up active registered users and channels (possible memory leak)
setInterval(() => {
try {
let users = Env.netfluxUsers || {};
let online = Server.getOnlineUsers() || [];
let onlineSet = new Set(online);
let removed = 0;
Object.keys(users).forEach(id => {
if (!onlineSet.has(id)) {
delete users[id];
removed++;
}
});
if (removed) {
Env.Log.info("CLEANED_ACTIVE_USERS_MAP", {removed});
}
} catch (e) {}
try {
let HK = require('./hk-utils');
let chans = Env.channel_cache || {};
let active = Server.getActiveChannels() || [];
let activeSet = new Set(active);
let removed = 0;
Object.keys(chans).forEach(id => {
if (!activeSet.has(id)) {
HK.dropChannel(Env, id);
removed++;
}
});
if (Env.store) {
Env.store.closeInactiveChannels(activeSet);
}
if (removed) {
Env.Log.info("CLEANED_ACTIVE_CHANNELS_MAP", {removed});
}
} catch (e) {}
}, 30000);
});
});
};

View File

@ -1,356 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const nThen = require('nthen');
const Pins = require('./pins');
const Util = require("./common-util");
const Store = require('./storage/file.js');
const BlobStore = require("./storage/blob");
const BlockStore = require("./storage/block");
const Core = require("./commands/core");
const Metadata = require("./commands/metadata");
const Meta = require("./metadata");
const Logger = require("./log");
const plugins = require("./plugin-manager");
const HK = require('./hk-util');
const MFA = require("./storage/mfa");
let SSOUtils = plugins.SSO && plugins.SSO.utils;
const Path = require("path");
const Fse = require("fs-extra");
const { parentPort } = require('node:worker_threads');
const COMMANDS = {};
let Log;
const mkReportPath = function (Env, safeKey) {
return Path.join(Env.paths.archive, 'accounts', safeKey);
};
const storeReport = (Env, report, cb) => {
let path = mkReportPath(Env, report.key);
let s_data;
try {
s_data = JSON.stringify(report);
Fse.outputFile(path, s_data, cb);
} catch (err) {
return void cb(err);
}
};
const readReport = (Env, key, cb) => {
let path = mkReportPath(Env, key);
Fse.readJson(path, cb);
};
const deleteReport = (Env, key, cb) => {
let path = mkReportPath(Env, key);
Fse.remove(path, cb);
};
const init = (cb) => {
const Environment = require("./env");
const config = require('./load-config');
const Env = Environment.create(config);
Env.computeMetadata = function (channel, cb) {
const ref = {};
const lineHandler = Meta.createLineHandler(ref, (err) => { console.log(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);
}
cb(void 0, ref.meta);
});
};
nThen((waitFor) => {
Logger.create(config, waitFor(function (_) {
Log = Env.Log = _;
}));
Store.create(config, waitFor(function (err, _store) {
if (err) {
waitFor.abort();
return void cb(err);
}
Env.store = _store;
}));
Store.create({
filePath: config.pinPath,
archivePath: config.archivePath,
// archive pin logs to their own subpath
volumeId: 'pins',
}, waitFor(function (err, _) {
if (err) {
waitFor.abort();
throw err;
}
Env.pinStore = _;
}));
BlobStore.create({
blobPath: config.blobPath,
blobStagingPath: config.blobStagingPath,
archivePath: config.archivePath,
getSession: function () {},
}, waitFor(function (err, blob) {
if (err) {
waitFor.abort();
return void cb(err);
}
Env.blobStore = blob;
}));
}).nThen(() => {
cb(Env);
});
};
COMMANDS.start = (edPublic, blockId, reason) => {
const safeKey = Util.escapeKeyCharacters(edPublic);
const archiveReason = {
code: 'MODERATION_ACCOUNT',
txt: reason
};
let ref = {};
let blobsToArchive = [];
let channelsToArchive = [];
let deletedChannels = [];
let deletedBlobs = [];
let Env;
nThen((waitFor) => {
init(waitFor((_Env) => {
Env = _Env;
}));
}).nThen((waitFor) => {
let lineHandler = Pins.createLineHandler(ref, (err) => { console.log(err); });
Env.pinStore.readMessagesBin(safeKey, 0, (msgObj, readMore) => {
lineHandler(msgObj.buff.toString('utf8'));
readMore();
}, waitFor());
}).nThen((waitFor) => {
Log.info('MODERATION_ACCOUNT_ARCHIVAL_START', edPublic, waitFor());
var n = nThen;
Object.keys(ref.pins || {}).forEach((chanId) => {
n = n((w) => {
// Blobs
if (Env.blobStore.isFileId(chanId)) {
return Env.computeMetadata(chanId, w((e, md) => {
if (e || !md) { return; }
if (md && md.owners
&& md.owners.includes(edPublic)) {
blobsToArchive.push(chanId);
}
}));
}
// Pads
Metadata.getMetadata(Env, chanId, w((err, metadata) => {
if (err) { return; } // Can't read metadata? Don't archive
if (!Core.hasOwners(metadata)) { return; } // No owner, don't archive
if (Core.isOwner(metadata, edPublic) && metadata.owners.length === 1) {
channelsToArchive.push(chanId); // Only owner: archive
}
}));
}).nThen;
});
n(waitFor());
}).nThen((waitFor) => {
Log.info('MODERATION_ACCOUNT_ARCHIVAL_LISTED', JSON.stringify({
pads: channelsToArchive.length,
blobs: blobsToArchive.length
}), waitFor());
var n = nThen;
// Archive the pads
channelsToArchive.forEach((chanId) => {
n = n((w) => {
Env.store.archiveChannel(chanId, archiveReason, w(function (err) {
if (err) {
return Log.error('MODERATION_CHANNEL_ARCHIVAL_ERROR', {
error: err,
channel: chanId,
}, w());
}
deletedChannels.push(chanId);
Log.info('MODERATION_CHANNEL_ARCHIVAL', chanId, w());
}));
}).nThen;
});
// Archive the blobs
blobsToArchive.forEach((blobId) => {
n = n((w) => {
Env.blobStore.archive.blob(blobId, archiveReason, w(function (err) {
if (err) {
return Log.error('MODERATION_BLOB_ARCHIVAL_ERROR', {
error: err,
item: blobId,
}, w());
}
deletedBlobs.push(blobId);
Log.info('MODERATION_BLOB_ARCHIVAL', blobId, w());
}));
}).nThen;
});
n(waitFor(() => {
// Archive the pin log
Env.pinStore.archiveChannel(safeKey, undefined, waitFor(function (err) {
if (err) {
return Log.error('MODERATION_ACCOUNT_PIN_LOG', err, waitFor());
}
Log.info('MODERATION_ACCOUNT_LOG', safeKey, waitFor());
}));
blockId = blockId || ref.block;
if (!blockId) { return; }
BlockStore.archive(Env, blockId, archiveReason, waitFor(function (err) {
if (err) {
blockId = undefined;
return Log.error('MODERATION_ACCOUNT_BLOCK', err, waitFor());
}
Log.info('MODERATION_ACCOUNT_BLOCK', safeKey, waitFor());
}));
MFA.delete(Env, blockId, waitFor());
if (!SSOUtils) { return; }
SSOUtils.deleteAccount(Env, blockId, waitFor((err) => {
if (err) {
return Log.error('MODERATION_ACCOUNT_BLOCK_SSO', err, waitFor());
}
}));
}));
}).nThen((waitFor) => {
var report = {
key: safeKey,
channels: deletedChannels,
blobs: deletedBlobs,
blockId: blockId,
reason: reason
};
storeReport(Env, report, waitFor((err) => {
if (err) {
return Log.error('MODERATION_ACCOUNT_REPORT', report, waitFor());
}
}));
}).nThen(() => {
parentPort.postMessage(JSON.stringify(deletedChannels));
process.exit(0);
});
};
COMMANDS.restore = (edPublic) => {
const safeKey = Util.escapeKeyCharacters(edPublic);
let pads, blobs;
let blockId;
let errors = [];
let Env;
nThen((waitFor) => {
init(waitFor((_Env) => {
Env = _Env;
}));
}).nThen((waitFor) => {
Log.info('MODERATION_ACCOUNT_RESTORE_START', edPublic, waitFor());
readReport(Env, safeKey, waitFor((err, report) => {
if (err) { throw new Error(err); }
pads = report.channels;
blobs = report.blobs;
blockId = report.blockId;
}));
}).nThen((waitFor) => {
Log.info('MODERATION_ACCOUNT_RESTORE_LISTED', JSON.stringify({
pads: pads.length,
blobs: blobs.length
}), waitFor());
var n = nThen;
pads.forEach((chanId) => {
n = n((w) => {
Env.store.restoreArchivedChannel(chanId, w(function (err) {
if (err) {
errors.push(chanId);
return Log.error('MODERATION_CHANNEL_RESTORE_ERROR', {
error: err,
channel: chanId,
}, w());
}
Log.info('MODERATION_CHANNEL_RESTORE', chanId, w());
}));
}).nThen;
});
blobs.forEach((blobId) => {
n = n((w) => {
Env.blobStore.restore.blob(blobId, w(function (err) {
if (err) {
errors.push(blobId);
return Log.error('MODERATION_BLOB_RESTORE_ERROR', {
error: err,
item: blobId,
}, w());
}
Log.info('MODERATION_BLOB_RESTORE', blobId, w());
}));
}).nThen;
});
n(waitFor(() => {
// remove the pin logs of inactive accounts if inactive account removal is configured
Env.pinStore.restoreArchivedChannel(safeKey, waitFor(function (err) {
if (err) {
return Log.error('MODERATION_ACCOUNT_PIN_LOG_RESTORE', err, waitFor());
}
Log.info('MODERATION_ACCOUNT_LOG_RESTORE', safeKey, waitFor());
}));
if (!blockId) { return; }
BlockStore.restore(Env, blockId, waitFor(function (err) {
if (err) {
blockId = undefined;
return Log.error('MODERATION_ACCOUNT_BLOCK_RESTORE', err, waitFor());
}
Log.info('MODERATION_ACCOUNT_BLOCK_RESTORE', safeKey, waitFor());
}));
if (!SSOUtils) { return; }
SSOUtils.restoreAccount(Env, blockId, waitFor(function (err) {
if (err) {
return Log.error('MODERATION_ACCOUNT_BLOCK_RESTORE_SSO', err, waitFor());
}
}));
}));
}).nThen((waitFor) => {
deleteReport(Env, safeKey, waitFor((err) => {
if (err) {
return Log.error('MODERATION_ACCOUNT_REPORT_DELETE', safeKey, waitFor());
}
}));
}).nThen(() => {
parentPort.postMessage(JSON.stringify(errors));
process.exit(0);
});
};
const getStatus = (Env, edPublic, cb) => {
const safeKey = Util.escapeKeyCharacters(edPublic);
readReport(Env, safeKey, (err, report) => {
if (err) { return void cb(err); }
cb(void 0, report);
});
};
if (parentPort) {
parentPort.on('message', (message) => {
let parsed = message; //JSON.parse(message);
let command = parsed.command;
let content = parsed.content;
let block = parsed.block;
let reason = parsed.reason;
COMMANDS[command](content, block, reason);
});
parentPort.postMessage('READY');
} else {
module.exports = {
getStatus: getStatus
};
}

View File

@ -1,68 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
/*
## Purpose
To avoid running expensive IO or computation concurrently.
If the result of IO or computation is requested while an identical request
is already in progress, wait until the first one completes and provide its
result to every routine that requested it.
Asynchrony is guaranteed.
## Usage
Provide:
1. a named key for the computation or resource,
2. a callback to handle the result
3. an implementation which calls back with the result
```
var batch = Batch();
var read = function (path, cb) {
batch(path, cb, function (done) {
console.log("reading %s", path);
fs.readFile(path, 'utf8', done);
});
};
read('./pewpew.txt', function (err, data) {
if (err) { return void console.error(err); }
console.log(data);
});
read('./pewpew.txt', function (err, data) {
if (err) { return void console.error(err); }
console.log(data);
});
```
*/
module.exports = function (/* task */) {
var map = {};
return function (id, cb, impl) {
if (typeof(cb) !== 'function' || typeof(impl) !== 'function') {
throw new Error("expected callback and implementation");
}
if (map[id]) { return void map[id].push(cb); }
map[id] = [cb];
impl(function () {
var args = Array.prototype.slice.call(arguments);
//if (map[id] && map[id].length > 1) { console.log("BATCH-READ DID ITS JOB for [%s][%s]", task, id); }
setTimeout(function () {
map[id].forEach(function (h) {
h.apply(null, args);
});
delete map[id];
});
});
};
};

View File

@ -1,113 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Block = require("../commands/block");
const MFA = require("../storage/mfa");
const Util = require("../common-util");
const Sessions = require("../storage/sessions");
const Commands = module.exports;
var isValidBlockId = Block.isValidBlockId;
// Read the MFA settings for the given public key
const checkMFA = (Env, publicKey, cb) => {
// Success if we can't get the MFA settings
MFA.read(Env, publicKey, function (err, content) {
if (err) {
if (err.code !== "ENOENT") {
Env.Log.error('TOTP_VALIDATE_MFA_READ', {
error: err,
publicKey: publicKey,
});
}
return void cb();
}
var parsed = Util.tryParse(content);
if (!parsed) { return void cb(); }
cb("NOT_ALLOWED");
});
};
// Make sure the block is not protected by MFA but don't do anything else
const check = Commands.MFA_CHECK = function (Env, body, cb) {
var { publicKey } = body;
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
checkMFA(Env, publicKey, cb);
};
check.complete = function (Env, body, cb) { cb(); };
// Write a login block IFF
// 1. You can sign for the block's public key
// 2. the block is not protected by MFA
// Note: the internal WRITE_LOGIN_BLOCK will check is you're allowed to create this block
const writeBlock = Commands.WRITE_BLOCK = function (Env, body, cb) {
const { publicKey, content } = body;
// they must provide a valid block public key
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
if (publicKey !== content.publicKey) { return void cb("INVALID_KEY"); }
// check MFA
checkMFA(Env, publicKey, cb);
};
writeBlock.complete = function (Env, body, cb) {
const { publicKey, content, session } = body;
Block.writeLoginBlock(Env, content, (err) => {
if (err) { return void cb(err); }
if (!session) { return void cb(); }
const proof = Util.tryParse(content.registrationProof);
const oldKey = proof && proof[0];
Sessions.update(Env, publicKey, oldKey, session, "", cb);
});
};
// Remove a login block IFF
// 1. You can sign for the block's public key
// 2. the block is not protected by MFA
const removeBlock = Commands.REMOVE_BLOCK = function (Env, body, cb) {
const { publicKey } = body;
// they must provide a valid block public key
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
// check MFA
checkMFA(Env, publicKey, cb);
};
removeBlock.complete = function (Env, body, cb) {
const { publicKey, edPublic, reason } = body;
Block.removeLoginBlock(Env, publicKey, reason, edPublic, cb);
};
// Get an upload cookie
// Get a cookie allowing you to upload to the blobstage of your user
const uploadCookie = Commands.UPLOAD_COOKIE = function (Env, body, cb) {
const { publicKey } = body;
// they must provide a valid public key
if (publicKey && typeof(publicKey) === "string"
&& publicKey.length === 44) {
return cb();
}
cb("INVALID_KEY");
};
uploadCookie.complete = function (Env, body, cb) {
const { publicKey } = body;
const safeKey = Util.escapeKeyCharacters(publicKey);
Env.blobStore.uploadCookie(safeKey, (err, cookie) => {
if (err) { return void cb(err); }
cb(void 0, {cookie});
});
};

View File

@ -1,529 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const B32 = require("thirty-two");
const OTP = require("notp");
const nThen = require("nthen");
const Util = require("../common-util");
const MFA = require("../storage/mfa");
const Sessions = require("../storage/sessions");
const BlockStore = require("../storage/block");
const Block = require("../commands/block");
const config = require("../load-config");
const Commands = module.exports;
var isString = s => typeof(s) === 'string';
// basic definition of what we'll accept as an OTP code
// exactly six numerical digits
var isValidOTP = otp => {
return isString(otp) &&
// in the future this could be updated to support 8 digits
otp.length === 6 &&
// \D is non-digit characters, so this tests that it is exclusively numeric
!/\D/.test(otp);
};
// basic definition of what we'll accept as a recovery key
// 24 bytes encoded as b64 ==> 32 characters
var isValidRecoveryKey = otp => {
return isString(otp) &&
// in the future this could be updated to support 8 digits
otp.length === 32 &&
// \D is non-digit characters, so this tests that it is exclusively numeric
/[A-Za-z0-9+\/]{32}/.test(otp);
};
// we'll only allow users to set up multi-factor auth
// for keypairs they control which already have blocks
// this check doesn't confirm that their id is valid base64
// any attempt relying on this should fail when we can't decode
// the id they provided.
var isValidBlockId = Block.isValidBlockId;
// the base32 library can throw when decoding under various conditions.
// we have some basic requirements for the length of base32 as well,
// so we just do all the validation here. It either returns a buffer
// of length 20 or undefined, so the caller can just check whether it's
// falsey and otherwise assume it was well-formed
// Length === 20 comes from the recommendation of 160 bits of entropy
// in RFC4226 (https://www.rfc-editor.org/rfc/rfc4226#section-4)
var decode32 = S => {
let decoded;
try {
decoded = B32.decode(S);
} catch (err) { return; }
if (!(decoded instanceof Buffer) || decoded.length !== 20) { return; }
return decoded;
};
// Allow user settings?
var EXPIRATION = (config.otpSessionExpiration || 7 * 24) * 3600 * 1000;
// Create a session with a token for the given public key
const makeSession = (Env, publicKey, oldKey, ssoSession, cb) => {
const sessionId = ssoSession || Sessions.randomId();
let SSOUtils = Env.plugins && Env.plugins.SSO && Env.plugins.SSO.utils;
// For password change, we need to get the sso session associated to the old block key
// In other cases (login and totp_setup), the sso session is associated to the current block
oldKey = oldKey || publicKey; // use the current block if no old key
let isUpdate = false;
nThen(function (w) {
if (!ssoSession || !SSOUtils) { return; }
// If we have an session token, confirm this is an sso account
SSOUtils.readBlock(Env, oldKey, w((err) => {
if (err === 'ENOENT') { return; } // No sso block, no need to update the session
if (err) {
w.abort();
return void cb('TOTP_VALIDATE_READ_SSO');
}
// We have an existing session for an SSO account: update the existing session
isUpdate = true;
}));
}).nThen(function (w) {
// store the token
let sessionData = {
mfa: {
type: 'otp',
exp: (+new Date()) + EXPIRATION
}
};
var then = w(function (err) {
if (err) {
Env.Log.error("TOTP_VALIDATE_SESSION_WRITE", {
error: Util.serializeError(err),
publicKey: publicKey,
sessionId: sessionId,
});
w.abort();
return void cb("SESSION_WRITE_ERROR");
}
// else continue
});
if (isUpdate) {
Sessions.update(Env, publicKey, oldKey, sessionId, JSON.stringify(sessionData), then);
} else {
Sessions.write(Env, publicKey, sessionId, JSON.stringify(sessionData), then);
}
}).nThen(function () {
cb(void 0, {
bearer: sessionId,
});
});
};
// Read the MFA settings for the given public key
const readMFA = (Env, publicKey, cb) => {
// check that there is an MFA configuration for the given account
MFA.read(Env, publicKey, function (err, content) {
if (err) {
Env.Log.error('TOTP_VALIDATE_MFA_READ', {
error: err,
publicKey: publicKey,
});
return void cb('NO_MFA_CONFIGURED');
}
var parsed = Util.tryParse(content);
if (!parsed) { return void cb("INVALID_CONFIGURATION"); }
cb(undefined, parsed);
});
};
// Check if an OTP code is valid against the provided secret
const checkCode = (Env, secret, code, publicKey, _cb) => {
const cb = Util.mkAsync(_cb);
let decoded = decode32(secret);
if (!decoded) {
Env.Log.error("TOTP_VALIDATE_INVALID_SECRET", {
publicKey, // log the public key so the admin can investigate further
// don't log the problematic secret directly as
// logs are likely to be pasted in random places
});
return void cb("E_INVALID_SECRET");
}
// validate the code
var validated = OTP.totp.verify(code, decoded, {
window: 1,
});
if (!validated) {
// I won't worry about logging these OTPs as they shouldn't leak any useful information
Env.Log.error("TOTP_VALIDATE_BAD_OTP", {
code,
});
return void cb("INVALID_OTP");
}
// call back to indicate that their request was well-formed and valid
cb();
};
// This command allows clients to configure TOTP as a second factor protecting
// their login block IFF they:
// 1. provide a sufficiently strong TOTP secret
// 2. are able to produce a valid OTP code for that secret (indicating that their clock is sufficiently close to ours)
// 3. such a login block actually exists
// 4. are able to sign an arbitrary message for the login block's public key
// 5. have not already configured TOTP protection for this account
// (changing to a new secret can be done by disabling and re-enabling TOTP 2FA)
const TOTP_SETUP = Commands.TOTP_SETUP = function (Env, body, cb) {
const { publicKey, secret, code, contact } = body;
// the client MUST provide an OTP code of the expected format
// this doesn't check if it matches the secret and time, just that it's well-formed
if (!isValidOTP(code)) { return void cb("E_INVALID"); }
// if they provide an (optional) point of contact as a recovery mechanism then it should be a string.
// the intent is to allow to specify some side channel for those who inevitably lock themselves out
// we should be able to use that to validate their identity.
// I don't want to assume email, but limiting its length to 254 (the maximum email length) seems fair.
if (contact && (!isString(contact) || contact.length > 254)) { return void cb("INVALID_CONTACT"); }
// Check that the provided public key is the expected format for a block
if (!isValidBlockId(publicKey)) {
return void cb("INVALID_KEY");
}
// decode32 checks whether the secret decodes to a sufficiently long buffer
var decoded = decode32(secret);
if (!decoded) { return void cb('INVALID_SECRET'); }
// Reject attempts to setup TOTP if a record of their preferences already exists
MFA.read(Env, publicKey, function (err) {
// There **should be** an error here, because anything else
// means that a record already exists
// This may need to be adjusted as other methods of MFA are added
if (!err) { return void cb("EEXISTS"); }
// if no MFA settings exist then we expect ENOENT
// anything else indicates a problem and should result in rejection
if (err.code !== 'ENOENT') { return void cb(err); }
try {
// allow for 30s of clock drift in either direction
// returns an object ({ delta: 0 }) indicating the amount of clock drift
// if successful, otherwise `null`
var validated = OTP.totp.verify(code, decoded, {
window: 1,
});
if (!validated) { return void cb("INVALID_OTP"); }
cb();
} catch (err2) {
Env.Log.error('TOTP_SETUP_VERIFICATION_ERROR', {
error: err2,
});
return void cb("INTERNAL_ERROR");
}
});
};
// The 'complete' step for TOTP_SETUP will only be called if the client
// passed earlier validation and successfully signed the server's challenge.
// There's still a little bit more to do and it could still fail.
TOTP_SETUP.complete = function (Env, body, cb) {
// the OTP code should have already been validated
var { publicKey, secret, contact, session } = body;
// the device from which they configure MFA settings
// is assumed to be safe, so we'll respond with a JWT token
// the remainder of the setup is successfully completed.
// Otherwise they would have to reauthenticate.
// The session id is used as a reference to this particular session.
nThen(function (w) {
// confirm that the block exists
BlockStore.check(Env, publicKey, w(function (err) {
if (err) {
Env.Log.error("TOTP_SETUP_NO_BLOCK", {
publicKey,
});
w.abort();
return void cb("NO_BLOCK");
}
// otherwise the block exists, continue
}));
}).nThen(function (w) {
// store the data you'll need in the future
var data = {
method: 'TOTP', // specify this so it's easier to add other methods later?
secret: secret, // the 160 bit, base32-encoded secret that is used for OTP validation
creation: new Date(), // the moment at which the MFA was configured
};
if (isString(contact)) {
// 'contact' is an arbitary (and optional) string for manual recovery from 2FA auth fails
// it should already be validated
data.contact = contact;
}
// We attempt to store a record of the above preferences
// if it fails then we abort and inform the client of an error.
MFA.write(Env, publicKey, JSON.stringify(data), w(function (err) {
if (err) {
w.abort();
Env.Log.error("TOTP_SETUP_STORAGE_FAILURE", {
publicKey: publicKey,
error: err,
});
return void cb('STORAGE_FAILURE');
}
// otherwise continue
}));
}).nThen(function () {
// we have already stored the MFA data, which will cause access to the resource to be restricted to the provided TOTP secret.
// we attempt to create a session as a matter of convenience - so if it fails
// that just means they'll be forced to authenticate
makeSession(Env, publicKey, null, session, cb);
});
};
// This command is somewhat simpler than TOTP_SETUP
// Issue a client a JWT which will allow them to access a login block IFF:
// 1. That login block exists
// 2. That login block is protected by TOTP 2FA
// 3. They can produce a valid OTP for that block's TOTP secret
// 4. They can sign for the block's public key
const validate = Commands.TOTP_VALIDATE = function (Env, body, cb) {
var { publicKey, code } = body;
// they must provide a valid OTP code
if (!isValidOTP(code)) { return void cb('E_INVALID'); }
// they must provide a valid block public key
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
var secret;
nThen(function (w) {
// check that there is an MFA configuration for the given account
readMFA(Env, publicKey, w(function (err, content) {
if (err) {
w.abort();
return void cb(err);
}
secret = content.secret;
}));
}).nThen(function () {
checkCode(Env, secret, code, publicKey, cb);
});
};
validate.complete = function (Env, body, cb) {
/*
if they are here then they:
1. have a valid block configured with TOTP-based 2FA
2. were able to provide a valid TOTP for that block's secret
3. were able to sign their messages for the block's public key
So, we should:
1. instanciate a session for them by generating and storing a token for their public key
2. send them the token
*/
var { publicKey, session } = body;
makeSession(Env, publicKey, null, session, cb);
};
// Same as TOTP_VALIDATE but without making a session at the end
const check = Commands.TOTP_MFA_CHECK = function (Env, body, cb) {
var { publicKey, auth } = body;
const code = auth;
if (!isValidOTP(code)) { return void cb('E_INVALID'); }
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
var secret;
nThen(function (w) {
readMFA(Env, publicKey, w(function (err, content) {
if (err) {
w.abort();
return void cb(err);
}
secret = content.secret;
}));
}).nThen(function () {
checkCode(Env, secret, code, publicKey, cb);
});
};
check.complete = function (Env, body, cb) { cb(); };
// Revoke a client TOTP secret which will allow them to disable TOTP for a login block IFF:
// 1. That login block exists
// 2. That login block is protected by TOTP 2FA
// 3. They can produce a valid OTP for that block's TOTP secret
// 4. They can sign for the block's public key
const revoke = Commands.TOTP_REVOKE = function (Env, body, cb) {
var { publicKey, code, recoveryKey } = body;
// they must provide a valid OTP code
if (!isValidOTP(code) && !isValidRecoveryKey(recoveryKey)) { return void cb('E_INVALID'); }
// they must provide a valid block public key
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
var secret, recoveryStored;
nThen(function (w) {
// check that there is an MFA configuration for the given account
readMFA(Env, publicKey, w(function (err, content) {
if (err) {
w.abort();
return void cb(err);
}
secret = content.secret;
recoveryStored = content.contact;
}));
}).nThen(function (w) {
if (!recoveryKey) { return; }
w.abort();
if (!/^secret:/.test(recoveryStored)) {
return void cb("E_NO_RECOVERY_KEY");
}
recoveryStored = recoveryStored.slice(7);
if (recoveryKey !== recoveryStored) {
return void cb("E_WRONG_RECOVERY_KEY");
}
cb();
}).nThen(function () {
checkCode(Env, secret, code, publicKey, cb);
});
};
revoke.complete = function (Env, body, cb) {
/*
if they are here then they:
1. have a valid block configured with TOTP-based 2FA
2. were able to provide a valid TOTP for that block's secret
3. were able to sign their messages for the block's public key
So, we should:
1. Revoke the TOTP authentication for their block
2. Remove all existing sessions
*/
var { publicKey } = body;
MFA.revoke(Env, publicKey, cb);
};
// Write a login block using an existing OTP block IFF
// 1. You can sign for the block's public key
// 2. You have a proof for the old block
// 3. The old block is OTP protected
// 4. The OTP code is valid
// Note: this is used when users change their password
const writeBlock = Commands.TOTP_WRITE_BLOCK = function (Env, body, cb) {
const { publicKey, content } = body;
const code = content.auth;
const registrationProof = content.registrationProof;
// they must provide a valid block public key
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
if (publicKey !== content.publicKey) { return void cb("INVALID_KEY"); }
if (!isValidOTP(code)) { return void cb('E_INVALID'); }
if (!registrationProof) { return void cb('MISSING_ANCESTOR'); }
let secret;
let oldKey;
nThen(function (w) {
Block.validateAncestorProof(Env, registrationProof, w((err, provenKey) => {
if (err || !provenKey) {
w.abort();
return void cb('INVALID_ANCESTOR');
}
oldKey = provenKey;
}));
}).nThen(function (w) {
// check that there is an MFA configuration for the ancestor account
readMFA(Env, oldKey, w(function (err, content) {
if (err) {
w.abort();
return void cb(err);
}
secret = content.secret;
}));
}).nThen(function () {
// check that the OTP code is valid
checkCode(Env, secret, code, oldKey, cb);
});
};
writeBlock.complete = function (Env, body, cb) {
const { publicKey, content, session } = body;
let oldKey;
nThen(function (w) {
// Write new block
Block.writeLoginBlock(Env, content, w((err) => {
if (err) {
w.abort();
return void cb("BLOCK_WRITE_ERROR");
}
}));
}).nThen(function (w) {
// Copy MFA settings
const proof = Util.tryParse(content.registrationProof);
oldKey = proof && proof[0];
if (!oldKey) {
w.abort();
return void cb('INVALID_ANCESTOR');
}
MFA.copy(Env, oldKey, publicKey, w());
}).nThen(function () {
// Create a session for the current user
makeSession(Env, publicKey, oldKey, session, cb);
});
};
// Remove a login block IFF
// 1. You can sign for the block's public key
const removeBlock = Commands.TOTP_REMOVE_BLOCK = function (Env, body, cb) {
const { publicKey, auth } = body;
const code = auth;
// they must provide a valid block public key
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
if (!isValidOTP(code)) { return void cb('E_INVALID'); }
let secret;
nThen(function (w) {
// check that there is an MFA configuration for this block
readMFA(Env, publicKey, w(function (err, content) {
if (err) {
w.abort();
return void cb(err);
}
secret = content.secret;
}));
}).nThen(function () {
// check that the OTP code is valid
checkCode(Env, secret, code, publicKey, cb);
});
};
removeBlock.complete = function (Env, body, cb) {
const { publicKey, edPublic, reason } = body;
nThen(function (w) {
// Remove the block
Block.removeLoginBlock(Env, publicKey, reason, edPublic, w((err) => {
if (err) {
w.abort();
return void cb(err);
}
}));
}).nThen(() => {
// Delete the MFA settings and sessions
MFA.revoke(Env, publicKey, cb);
});
};

View File

@ -1,102 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
var Netflux = require("netflux-websocket");
var WebSocket = require("ws");
var nThen = require("nthen");
var Util = require("../../www/common/common-util");
var Nacl = require("tweetnacl/nacl-fast");
var Client = module.exports;
var createNetwork = Client.createNetwork = function (url, cb) {
var CB = Util.once(cb);
var info = {};
Netflux.connect(url, function (url) {
// this websocket seems to never close properly if the error is
// ECONNREFUSED
info.websocket = new WebSocket(url)
.on('error', function (err) {
CB(err);
})
.on('close', function (/* err */) {
delete info.websocket;
});
return info.websocket;
}).then(function (network) {
info.network = network;
CB(void 0, info);
}, function (err) {
CB(err);
});
};
var die = function (client) {
var disconnect = Util.find(client, ['config', 'network', 'disconnect']);
if (typeof(disconnect) === 'function') {
disconnect();
} else {
console.error("disconnect was not a function");
}
var close = Util.find(client, ['config', 'websocket', 'close']);
if (typeof(close) === 'function') {
client.config.websocket.close();
} else {
console.error("close was not a function");
}
};
Client.create = function (config, cb) {
if (typeof(config) === 'function') {
cb = config;
config = {};
}
var client = {
config: config,
};
var CB = Util.once(function (err, arg) {
if (err) { die(client); }
cb(err, arg);
});
client.shutdown = function () {
die(client);
};
nThen(function (w) {
if (config.network) { return; }
// connect to the network...
createNetwork('ws://localhost:3000/cryptpad_websocket', w(function (err, info) {
if (err) {
w.abort();
return void CB(err);
}
config.network = info.network;
config.websocket = info.websocket;
}));
}).nThen(function (w) {
// make sure the network has a historyKeeper id on it
// we're responsible for adding it
if (config.network.historyKeeper) { return; }
var channel = Util.uint8ArrayToHex(Nacl.randomBytes(16));
config.network.join(channel).then(w(function (wc) {
wc.members.some(function (member) {
if (member.length !== 16) { return; }
config.network.historyKeeper = member;
return true;
});
wc.leave();
}), function (err) {
w.abort();
CB(err);
});
}).nThen(function () {
CB(void 0, client);
});
};

File diff suppressed because it is too large Load Diff

View File

@ -1,258 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Block = module.exports;
const Nacl = require("tweetnacl/nacl-fast");
const nThen = require("nthen");
const Util = require("../common-util");
const BlockStore = require("../storage/block");
const Invitation = require("./invitation");
const Users = require("./users");
var isString = s => typeof(s) === 'string';
Block.isValidBlockId = id => {
return id && isString(id) && id.length === 44;
};
/*
We assume that the server is secured against MitM attacks
via HTTPS, and that malicious actors do not have code execution
capabilities. If they do, we have much more serious problems.
The capability to replay a block write or remove results in either
a denial of service for the user whose block was removed, or in the
case of a write, a rollback to an earlier password.
Since block modification is destructive, this can result in loss
of access to the user's drive.
So long as the detached signature is never observed by a malicious
party, and the server discards it after proof of knowledge, replays
are not possible. However, this precludes verification of the signature
at a later time.
Despite this, an integrity check is still possible by the original
author of the block, since we assume that the block will have been
encrypted with xsalsa20-poly1305 which is authenticated.
*/
Block.validateLoginBlock = function (Env, publicKey, signature, block, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
// convert the public key to a Uint8Array and validate it
if (typeof(publicKey) !== 'string') { return void cb('E_INVALID_KEY'); }
var u8_public_key;
try {
u8_public_key = Util.decodeBase64(publicKey);
} catch (e) {
return void cb('E_INVALID_KEY');
}
var u8_signature;
try {
u8_signature = Util.decodeBase64(signature);
} catch (e) {
Env.Log.error('INVALID_BLOCK_SIGNATURE', e);
return void cb('E_INVALID_SIGNATURE');
}
// convert the block to a Uint8Array
var u8_block;
try {
u8_block = Util.decodeBase64(block);
} catch (e) {
return void cb('E_INVALID_BLOCK');
}
// take its hash
var hash = Nacl.hash(u8_block);
// validate the signature against the hash of the content
var verified = Nacl.sign.detached.verify(hash, u8_signature, u8_public_key);
// existing authentication ensures that users cannot replay old blocks
// call back with (err) if unsuccessful
if (!verified) { return void cb("E_COULD_NOT_VERIFY"); }
return void cb(null, block);
};
Block.validateAncestorProof = function (Env, proof, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
/* prove that you own an existing block by signing for its publicKey */
try {
var parsed = JSON.parse(proof);
var pub = parsed[0];
var u8_pub = Util.decodeBase64(pub);
var sig = parsed[1];
var u8_sig = Util.decodeBase64(sig);
var valid = false;
nThen(function (w) {
valid = Nacl.sign.detached.verify(u8_pub, u8_sig, u8_pub);
if (!valid) {
w.abort();
return void cb('E_INVALID_ANCESTOR_PROOF');
}
// else fall through to next step
}).nThen(function () {
BlockStore.check(Env, pub, function (err) {
if (err) { return void cb('E_MISSING_ANCESTOR'); }
cb(void 0, pub);
});
});
} catch (err) {
return void cb(err);
}
};
Block.writeLoginBlock = function (Env, msg, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
const { publicKey, signature, ciphertext, registrationProof, userData, inviteToken, isSSO } = msg;
var previousKey;
var validatedBlock, path;
var validatedInvite;
nThen(function (w) {
if (!inviteToken) { return; }
Invitation.check(Env, inviteToken, w((err, state) => {
if (err || !state) { return; } // Invalid token, don't abort, check registration proof
validatedInvite = true;
}));
}).nThen(function (w) {
if (!Env.restrictRegistration) { return; }
var ssoAllowed = isSSO && !Env.restrictSsoRegistration;
if (!(registrationProof || validatedInvite || ssoAllowed)) {
// we allow users with existing blocks to create new ones
// call back with error if registration is restricted and no proof of an existing block was provided
w.abort();
Env.Log.info("BLOCK_REJECTED_REGISTRATION", {
publicKey: publicKey,
});
return cb("E_RESTRICTED");
}
if (!registrationProof) { return; }
Block.validateAncestorProof(Env, registrationProof, w(function (err, provenKey) {
if (err || !provenKey) { // double check that a key was validated
w.abort();
Env.Log.warn('BLOCK_REJECTED_INVALID_ANCESTOR', {
error: err,
});
return void cb("E_RESTRICTED");
}
previousKey = provenKey;
}));
}).nThen(function (w) {
Block.validateLoginBlock(Env, publicKey, signature, ciphertext, w(function (e, _validatedBlock) {
if (e) {
w.abort();
return void cb(e);
}
if (typeof(_validatedBlock) !== 'string') {
w.abort();
return void cb('E_INVALID_BLOCK_RETURNED');
}
validatedBlock = _validatedBlock;
}));
}).nThen(function () {
var buffer;
try {
buffer = Buffer.from(Util.decodeBase64(validatedBlock));
} catch (err) {
return void cb('E_BLOCK_DESERIALIZATION');
}
BlockStore.write(Env, publicKey, buffer, function (err) {
Env.Log.info('BLOCK_WRITE_BY_OWNER', {
blockId: publicKey,
isChange: Boolean(registrationProof),
previousKey: previousKey,
path: path,
});
cb(err);
if (!err && registrationProof) {
Users.checkUpdate(Env, userData, publicKey, (err) => {
if (!err) { return; }
Env.Log.error('UPDATE_KNOWN_USER', {
userData,
publicKey
});
});
}
});
if (validatedInvite) {
Invitation.use(Env, inviteToken, publicKey, userData, (err) => {
if (!err) { return; }
Env.Log.error('USE_INVITATION_LINK', {
inviteToken,
userData,
publicKey
});
});
} else if (isSSO && !Env.dontStoreSSOUsers && !registrationProof) {
let edPublic = Array.isArray(userData) && userData[1];
let name = Array.isArray(userData) && userData[0];
if (!edPublic) { return; }
let data = {
block: publicKey,
name,
edPublic,
type: 'sso',
alias: name
};
Users.add(Env, edPublic, data, null, (err) => {
if (err) {
Env.Log.error('INVITATION_ADD_USER', {
error: err,
data: data
});
}
});
}
});
};
/*
When users write a block, they upload the block, and provide
a signature proving that they deserve to be able to write to
the location determined by the public key.
When removing a block, there is nothing to upload, but we need
to sign something. Since the signature is considered sensitive
information, we can just sign some constant and use that as proof.
*/
Block.removeLoginBlock = function (Env, publicKey, reason, edPublic, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
BlockStore.archive(Env, publicKey, reason, function (err) {
Env.Log.info('ARCHIVAL_BLOCK_BY_OWNER_RPC', {
publicKey: publicKey,
status: err? String(err): 'SUCCESS',
});
cb(err);
});
if (edPublic && reason !== 'PASSWORD_CHANGE') {
Users.delete(Env, edPublic, (err) => {
if (err) { Env.Log.error('KNOWN_USER_DELETION_ERROR', { error: err, key: edPublic }); }
});
}
// We should also try to remove the SSO data. Errors will be logged
// but they don't have to be shown to the user. The account data
// is already deleted anyway.
// If this is NOT a password change, also delete sso user.
let SSOUtils = Env.plugins && Env.plugins.SSO && Env.plugins.SSO.utils;
if (!SSOUtils) { return; }
if (reason !== 'PASSWORD_CHANGE') {
SSOUtils.deleteAccount(Env, publicKey, () => {});
} else {
SSOUtils.deleteBlock(Env, publicKey, () => {});
}
};

View File

@ -1,389 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Channel = module.exports;
const Util = require("../common-util");
const nThen = require("nthen");
const Core = require("./core");
const Metadata = require("./metadata");
const Linked = require("./linked");
const HK = require("../hk-util");
const Nacl = require("tweetnacl/nacl-fast");
Channel.disconnectChannelMembers = function (Env, Server, channelId, code, reason, cb) {
var done = Util.once(Util.mkAsync(cb));
if (!Core.isValidId(channelId)) { return done('INVALID_ID'); }
const channel_cache = Env.channel_cache;
const metadata_cache = Env.metadata_cache;
const clear = function () {
delete channel_cache[channelId];
Server.clearChannel(channelId);
delete metadata_cache[channelId];
};
// an owner of a channel deleted it
nThen(function (w) {
// close the channel in the store
Env.msgStore.closeChannel(channelId, w());
}).nThen(function (w) {
// Server.channelBroadcast would be better
// but we can't trust it to track even one callback,
// let alone many in parallel.
// so we simulate it on this side to avoid race conditions
Server.getChannelUserList(channelId).forEach(function (userId) {
Server.send(userId, [
0,
Env.historyKeeper.id,
"MSG",
userId,
JSON.stringify({
error: code, //'EDELETED',
message: reason,
channel: channelId,
})
], w());
});
}).nThen(function () {
// clear the channel's data from memory
// once you've sent everyone a notice that the channel has been deleted
clear();
done();
}).orTimeout(function () {
Env.Log.warn('DISCONNECT_CHANNEL_MEMBERS_TIMEOUT', {
channelId,
code,
reason
});
clear();
done();
}, 30000);
};
Channel.clearOwnedChannel = function (Env, safeKey, channelId, cb, Server) {
if (typeof(channelId) !== 'string' || channelId.length !== 32) {
return cb('INVALID_ARGUMENTS');
}
var unsafeKey = Util.unescapeKeyCharacters(safeKey);
Metadata.getMetadata(Env, channelId, function (err, metadata) {
if (err) { return void cb(err); }
if (!Core.hasOwners(metadata)) { return void cb('E_NO_OWNERS'); }
// Confirm that the channel is owned by the user in question
if (!Core.isOwner(metadata, unsafeKey)) {
return void cb('INSUFFICIENT_PERMISSIONS');
}
return void Env.msgStore.clearChannel(channelId, function (e) {
if (e) { return void cb(e); }
cb();
const channel_cache = Env.channel_cache;
const clear = function () {
// delete the channel cache because it will have been invalidated
delete channel_cache[channelId];
};
nThen(function (w) {
Server.getChannelUserList(channelId).forEach(function (userId) {
Server.send(userId, [
0,
Env.historyKeeper.id,
'MSG',
userId,
JSON.stringify({
error: 'ECLEARED',
channel: channelId
})
], w());
});
}).nThen(function () {
clear();
}).orTimeout(function () {
Env.Log.warn("ON_CHANNEL_CLEARED_TIMEOUT", channelId);
clear();
}, 30000);
});
});
};
var archiveOwnedChannel = function (Env, safeKey, channelId, reason, __cb, Server) {
var _cb = Util.once(Util.mkAsync(__cb));
var unsafeKey = Util.unescapeKeyCharacters(safeKey);
reason = reason || 'ARCHIVE_OWNED';
nThen(function (w) {
// confirm that the channel exists before worrying about whether
// we have permission to delete it.
var cb = _cb;
Env.msgStore.getChannelSize(channelId, w(function (err, bytes) {
if (!bytes) {
w.abort();
return cb(err || "ENOENT");
}
}));
}).nThen(function (w) {
var cb = Util.both(w.abort, _cb);
Metadata.getMetadata(Env, channelId, w(function (err, metadata) {
if (err) { return void cb(err); }
if (!Core.hasOwners(metadata)) { return void cb('E_NO_OWNERS'); }
if (!Core.isOwner(metadata, unsafeKey)) {
return void cb('INSUFFICIENT_PERMISSIONS');
}
}));
}).nThen(function () {
var cb = _cb;
// temporarily archive the file
return void Env.msgStore.archiveChannel(channelId, reason, function (e) {
Env.Log.info('ARCHIVAL_CHANNEL_BY_OWNER_RPC', {
unsafeKey: unsafeKey,
channelId: channelId,
status: e? String(e): 'SUCCESS',
});
if (e) {
return void cb(e);
}
cb(void 0, 'OK');
Channel.disconnectChannelMembers(Env, Server, channelId, 'EDELETED', reason, err => {
if (err) { } // TODO
});
});
});
};
Channel.removeOwnedChannel = function (Env, safeKey, obj, __cb, Server) {
var _cb = Util.once(Util.mkAsync(__cb));
var channelId = obj.channel;
var reason = obj.reason;
if (typeof(channelId) !== 'string' || !Core.isValidId(channelId)) {
return _cb('INVALID_ARGUMENTS');
}
// archiving large channels or files can be expensive, so do it one at a time
// for any given user to ensure that nobody can use too much of the server's resources
Env.queueDeletes(safeKey, function (next) {
var cb = Util.both(_cb, next);
if (Env.blobStore.isFileId(channelId)) {
return void Env.removeOwnedBlob(channelId, safeKey, reason, cb);
}
Linked.listLinkedDocuments(Env, channelId, (err, channels) => {
archiveOwnedChannel(Env, safeKey, channelId, reason, (err, data) => {
if (!channels) { return void cb(err, data); }
if (err) { return void cb(err); }
Linked.archiveLinkedData(Env, channelId, reason, channels, () => {
cb(void 0, data);
});
}, Server);
});
});
};
Channel.trimHistory = function (Env, safeKey, data, cb) {
if (!(data && typeof(data.channel) === 'string' && typeof(data.hash) === 'string' && data.hash.length === 64)) {
return void cb('INVALID_ARGS');
}
var channelId = data.channel;
var unsafeKey = Util.unescapeKeyCharacters(safeKey);
var hash = data.hash;
nThen(function (w) {
Metadata.getMetadataRaw(Env, channelId, w(function (err, metadata) {
if (err) {
w.abort();
return void cb(err);
}
if (!Core.hasOwners(metadata)) {
w.abort();
return void cb('E_NO_OWNERS');
}
if (!Core.isOwner(metadata, unsafeKey)) {
w.abort();
return void cb("INSUFFICIENT_PERMISSIONS");
}
// else fall through to the next block
}));
}).nThen(function (w) {
// Archive old checkpoints
Linked.trimHistory(Env, { channel: channelId }, w());
}).nThen(function () {
// Trim chainpad doc:
Env.msgStore.trimChannel(channelId, hash, function (err) {
Env.Log.info('HK_TRIM_HISTORY', {
unsafeKey: unsafeKey,
channelId: channelId,
status: err? String(err): 'SUCCESS',
});
if (err) { return void cb(err); }
// clear historyKeeper's cache for this channel
Env.historyKeeper.channelClose(channelId);
cb(void 0, 'OK');
delete Env.channel_cache[channelId];
delete Env.metadata_cache[channelId];
});
});
};
// Delete a signed mailbox message. This is used when users want
// to delete their form reponses.
Channel.deleteMailboxMessage = function (Env, data, cb) {
const channelId = data.channel;
const hash = data.hash;
const proof = data.proof;
let nonce, proofBytes;
try {
nonce = Util.decodeBase64(proof.split('|')[0]);
proofBytes = Util.decodeBase64(proof.split('|')[1]);
} catch (e) {
return void cb('EINVAL');
}
Env.msgStore.deleteChannelLine(channelId, hash, function (msg) {
// Check if you're allowed to delete this hash
try {
const mySecret = Env.curvePrivate;
const msgBytes = Util.decodeBase64(msg).subarray(64); // Remove signature
const theirPublic = msgBytes.subarray(24,56); // 0-24 = nonce; 24-56=publickey (32 bytes)
const hashBytes = Nacl.box.open(proofBytes, nonce, theirPublic, mySecret);
return Util.encodeUTF8(hashBytes) === hash;
} catch (e) {
return false;
}
}, function (err) {
if (err) { return void cb(err); }
// clear historyKeeper's cache for this channel
Env.historyKeeper.channelClose(channelId);
cb();
delete Env.channel_cache[channelId];
delete Env.metadata_cache[channelId];
});
};
var ARRAY_LINE = /^\[/;
/* Files can contain metadata but not content
call back with true if the channel log has no content other than metadata
otherwise false
*/
Channel.isNewChannel = function (Env, channel, _cb) {
var cb = Util.once(_cb);
if (!Core.isValidId(channel)) { return void cb('INVALID_CHAN'); }
if (channel.length !== HK.STANDARD_CHANNEL_LENGTH &&
channel.length !== HK.ADMIN_CHANNEL_LENGTH) { return void cb('INVALID_CHAN'); }
Env.msgStore.readMessagesBin(channel, 0, function (msgObj, readMore, abort) {
try {
var msg = msgObj.buff.toString('utf8');
if (typeof(msg) === 'string' && ARRAY_LINE.test(msg)) {
abort();
return void cb(void 0, {isNew: false});
}
} catch (e) {
Env.WARN('invalid message read from store', e);
}
readMore();
}, function (err, reason) {
// no more messages...
cb(void 0, {
isNew: true,
reason: reason
});
});
};
/* writePrivateMessage
allows users to anonymously send a message to the channel
prevents their netflux-id from being stored in history
and from being broadcast to anyone that might currently be in the channel
Otherwise behaves the same as sending to a channel
*/
Channel.writePrivateMessage = function (Env, args, _cb, Server, netfluxId) {
var cb = Util.once(Util.mkAsync(_cb));
var channelId = args[0];
var msg = args[1];
// don't bother handling empty messages
if (!msg) { return void cb("INVALID_MESSAGE"); }
// don't support anything except regular channels
if (!Core.isValidId(channelId) || (channelId.length !== HK.STANDARD_CHANNEL_LENGTH
&& channelId.length !== HK.ADMIN_CHANNEL_LENGTH)) {
return void cb("INVALID_CHAN");
}
// We expect a modern netflux-websocket-server instance
// if this API isn't here everything will fall apart anyway
if (!(Server && typeof(Server.send) === 'function')) {
return void cb("NOT_IMPLEMENTED");
}
nThen(function (w) {
Metadata.getMetadataRaw(Env, channelId, w(function (err, metadata) {
if (err) {
w.abort();
Env.Log.error('HK_WRITE_PRIVATE_MESSAGE', err);
return void cb('METADATA_ERR');
}
// treat the broadcast channel as write-protected
if (channelId.length === HK.ADMIN_CHANNEL_LENGTH) {
metadata.restricted = true;
}
if (!metadata || !metadata.restricted) {
return;
}
var session = HK.getNetfluxSession(Env, netfluxId);
var allowed = HK.listAllowedUsers(metadata);
if (HK.isUserSessionAllowed(allowed, session)) { return; }
w.abort();
cb('INSUFFICIENT_PERMISSIONS');
}));
}).nThen(function () {
// historyKeeper expects something with an 'id' attribute
// it will fail unless you provide it, but it doesn't need anything else
var channelStruct = {
id: channelId,
};
// construct a message to store and broadcast
var fullMessage = [
0, // idk
null, // normally the netflux id, null isn't rejected, and it distinguishes messages written in this way
"MSG", // indicate that this is a MSG
channelId, // channel id
msg // the actual message content. Generally a string
];
// historyKeeper already knows how to handle metadata and message validation, so we just pass it off here
// if the message isn't valid it won't be stored.
Env.historyKeeper.channelMessage(Server, channelStruct, fullMessage, function (err, time) {
if (err) {
// Message not stored...
return void cb(err);
}
// Broadcast the message
Server.getChannelUserList(channelId).forEach(function (userId) {
Server.send(userId, fullMessage);
});
cb(void 0, time);
});
});
};

View File

@ -1,155 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Core = module.exports;
const Util = require("../common-util");
const escapeKeyCharacters = Util.escapeKeyCharacters;
//const { fork } = require('child_process');
Core.DEFAULT_LIMIT = 50 * 1024 * 1024;
Core.SESSION_EXPIRATION_TIME = 60 * 1000;
Core.isValidId = function (chan) {
return chan && chan.length && /^[a-zA-Z0-9=+-]*$/.test(chan) &&
[32, 33, 48].indexOf(chan.length) > -1;
};
Core.isValidPublicKey = function (owner) {
return typeof(owner) === 'string' && owner.length === 44;
};
var makeToken = Core.makeToken = function () {
return Number(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER))
.toString(16);
};
Core.makeCookie = function (token) {
var time = (+new Date());
time -= time % 5000;
return [
time,
process.pid,
token
];
};
var parseCookie = function (cookie) {
if (!(cookie && cookie.split)) { return null; }
var parts = cookie.split('|');
if (parts.length !== 3) { return null; }
var c = {};
c.time = new Date(parts[0]);
c.pid = Number(parts[1]);
c.seq = parts[2];
return c;
};
Core.getSession = function (Sessions, key) {
var safeKey = escapeKeyCharacters(key);
if (Sessions[safeKey]) {
Sessions[safeKey].atime = +new Date();
return Sessions[safeKey];
}
var user = Sessions[safeKey] = {};
user.atime = +new Date();
user.tokens = [
makeToken()
];
return user;
};
Core.expireSession = function (Sessions, safeKey) {
var session = Sessions[safeKey];
if (!session) { return; }
if (session.blobstage) {
session.blobstage.close();
}
delete Sessions[safeKey];
};
Core.expireSessionAsync = function (Env, safeKey, cb) {
setTimeout(function () {
Core.expireSession(Env.Sessions, safeKey);
cb(void 0, 'OK');
});
};
var isTooOld = function (time, now) {
return (now - time) > 300000;
};
Core.expireSessions = function (Sessions) {
var now = +new Date();
Object.keys(Sessions).forEach(function (safeKey) {
var session = Sessions[safeKey];
if (session && isTooOld(session.atime, now)) {
Core.expireSession(Sessions, safeKey);
}
});
};
var addTokenForKey = function (Sessions, publicKey, token) {
if (!Sessions[publicKey]) { throw new Error('undefined user'); }
var user = Core.getSession(Sessions, publicKey);
user.tokens.push(token);
user.atime = +new Date();
if (user.tokens.length > 2) { user.tokens.shift(); }
};
Core.isValidCookie = function (Sessions, publicKey, cookie) {
var parsed = parseCookie(cookie);
if (!parsed) { return false; }
var now = +new Date();
if (!parsed.time) { return false; }
if (isTooOld(parsed.time, now)) {
return false;
}
// different process. try harder
if (process.pid !== parsed.pid) {
return false;
}
var user = Core.getSession(Sessions, publicKey);
if (!user) { return false; }
var idx = user.tokens.indexOf(parsed.seq);
if (idx === -1) { return false; }
if (idx > 0) {
// make a new token
addTokenForKey(Sessions, publicKey, Core.makeToken());
}
return true;
};
// E_NO_OWNERS
Core.hasOwners = function (metadata) {
return Boolean(metadata && Array.isArray(metadata.owners));
};
Core.hasPendingOwners = function (metadata) {
return Boolean(metadata && Array.isArray(metadata.pending_owners));
};
// INSUFFICIENT_PERMISSIONS
Core.isOwner = function (metadata, unsafeKey) {
return metadata.owners.indexOf(unsafeKey) !== -1;
};
Core.isPendingOwner = function (metadata, unsafeKey) {
return metadata.pending_owners.indexOf(unsafeKey) !== -1;
};
Core.haveACookie = function (Env, safeKey, cb) {
cb();
};

View File

@ -1,89 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Invitation = module.exports;
const Invite = require('../storage/invite');
const Util = require("../common-util");
const Users = require("./users");
const Crypto = require('node:crypto');
const getUid = () => {
return Crypto.randomBytes(18).toString('hex');
};
Invitation.getAll = (Env, cb) => {
Invite.getAll(Env, (err, data) => {
if (err) { return void cb(err); }
cb(null, data);
});
};
Invitation.create = (Env, alias, email, _cb, unsafeKey) => {
const cb = Util.once(Util.mkAsync(_cb));
const id = getUid();
const invitation = {
alias,
email,
createdBy: unsafeKey,
time: +new Date()
};
Invite.write(Env, id, invitation, (err) => {
if (err) { return void cb(err); }
cb(null, id);
});
};
Invitation.delete = (Env, id, _cb) => {
const cb = Util.once(Util.mkAsync(_cb));
Invite.delete(Env, id, (err) => {
if (err && err !== 'ENOENT') { return void cb(err); }
cb(void 0, true);
});
};
Invitation.check = (Env, id, _cb) => {
const cb = Util.once(Util.mkAsync(_cb));
Invite.read(Env, id, (err) => {
if (err) { return void cb(err); }
cb(void 0, true);
});
};
Invitation.use = (Env, id, blockId, userData, _cb) => {
const cb = Util.once(Util.mkAsync(_cb));
Invite.read(Env, id, (err, _data) => {
if (err) { return void cb(err); }
let data = Util.clone(_data);
if (!Array.isArray(userData)) { userData = []; }
let name = userData[0];
let edPublic = userData[1];
data.block = blockId;
data.name = name;
data.edPublic = edPublic;
data.type = 'invite:' + id;
let adminKey = data.createdBy;
if (!Env.dontStoreInvitedUsers) {
Users.add(Env, edPublic, data, adminKey, (err) => {
if (err) {
Env.Log.error('INVITATION_ADD_USER', {
error: err,
data: data
});
}
});
}
Invite.delete(Env, id, (err) => {
if (err) {
Env.Log.error('INVITATION_DELETE_USE', {
error: err,
id: id
});
}
});
});
};

View File

@ -1,445 +0,0 @@
// SPDX-FileCopyrightText: 2026 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Linked = module.exports;
const nThen = require("nthen");
//const Core = require("./core");
//const CPCrypto = require('../crypto');
const Util = require("../common-util");
const MetaRPC = require("./metadata");
const HK = require("../hk-util");
const getMetadata = (Env, channel, _cb) => {
const cb = Util.once(Util.mkAsync(_cb));
const metadata = Env.metadata_cache[channel];
if (metadata && typeof(metadata) === 'object') {
return void cb(undefined, metadata);
}
MetaRPC.getMetadataRaw(Env, channel, (err, metadata) => {
if (err) { return void cb(err); }
if (metadata?.channel !== channel && channel.length !== HK.BLOB_ID_LENGTH) {
return cb();
}
// cache it
if (channel.length !== HK.BLOB_ID_LENGTH) {
Env.metadata_cache[channel] = metadata;
}
cb(undefined, metadata);
});
};
const allowedTypes = ['checkpoints', 'media', 'channels'];
// XXX add logs
Linked.getLinkedDocuments = (Env, data, cb) => {
Env.store.getLinkedDocuments(data.channel, (err, json) => {
if (err && err !== 'ENOENT') { return void cb(err?.message); }
cb(void 0, json || {});
});
};
Linked.listLinkedDocuments = (Env, channel, _cb) => {
const cb = Util.mkAsync(_cb);
if (channel.length !== HK.STANDARD_CHANNEL_LENGTH) {
return void cb(void 0, []);
}
const list = new Set();
Linked.getLinkedDocuments(Env, { channel }, (err, json) => {
if (err) { return void cb(err); }
// For each type, add the channels and/or blobs
allowedTypes.forEach(type => {
const data = json[type];
if (!Array.isArray(data)) { return; }
// Media or channel:
if (type !== 'checkpoints') {
data.forEach(id => { list.add(id); });
return;
}
// Checkpoint:
data.forEach(obj => {
if (obj?.rtChannel) { list.add(obj.rtChannel); }
if (obj?.blob) { list.add(obj.blob); }
});
});
cb(void 0, Array.from(list));
});
};
Linked.listOldCheckpoints = (Env, channel, cb) => {
const list = new Set();
Linked.getLinkedDocuments(Env, { channel }, (err, json) => {
if (err) { return void cb(err); }
const cps = json.checkpoints || [];
cps.pop(); // preserve last cp
cps.forEach(obj => {
if (obj?.rtChannel) { list.add(obj.rtChannel); }
if (obj?.blob) { list.add(obj.blob); }
});
cb(void 0, Array.from(list));
});
};
const checkContent = (content, user) => {
const { type, data } = content;
if (type === 'checkpoints' && data) {
const { rtChannel, blob } = data;
if (rtChannel?.length !== 32 || (blob && blob?.length !== 48)) {
return false;
}
return {
rtChannel, blob, user,
time: Date.now()
};
}
if (type === 'media') {
return data?.length === 48 ? data : false;
}
if (type === 'channels') {
return data?.length === 32 ? data : false;
}
return false;
};
Linked.addLinkedDocument = (Env, data, cb, _S, userId) => {
// data.user
// data.channel
// data.content
// type, data (channelId or blobId or checkpoint {blob, rtChannel}})
// data.proof
// (sign "{ user, channel, content }" with pad signing key
const { user, channel, content, netfluxId, proof } = data;
if (userId !== netfluxId) { return void cb('EFORBIDDEN'); }
const msg = Util.clone(data);
delete msg.proof;
const signedMsg = JSON.stringify(msg);
const type = content?.type;
if (!allowedTypes.includes(type)) {
return void cb('INVALID_TYPE');
}
const value = checkContent(content, user);
if (!value) { return void cb('INVALID_CONTENT'); }
let validateKey;
nThen(waitFor => {
getMetadata(Env, channel, waitFor((err, metadata) => {
if (!metadata?.validateKey) {
waitFor.abort();
return void cb(err || 'METADATA_ERROR');
}
validateKey = metadata.validateKey;
}));
}).nThen(waitFor => {
Env.checkSignature(signedMsg, proof, validateKey, waitFor((err)=> {
if (err) {
waitFor.abort();
return void cb('INVALID_PROOF');
}
}));
}).nThen(() => {
Env.store.addLinkedDocument(channel, type, value, cb);
});
};
Linked.resetLinkedDocuments = (Env, data, cb, _S, userId) => {
// data.user
// data.channel
// data.content
// data.proof
// (sign "{ user, channel, content }" with pad signing key
const { user, channel, content, netfluxId, proof } = data;
if (userId !== netfluxId) { return void cb('EFORBIDDEN'); }
const msg = Util.clone(data);
delete msg.proof;
const signedMsg = JSON.stringify(msg);
let validateKey;
const newContent = {};
allowedTypes.forEach(type => { newContent[type] = []; });
nThen(waitFor => {
getMetadata(Env, channel, waitFor((err, metadata) => {
if (!metadata?.validateKey) {
waitFor.abort();
return void cb(err || 'METADATA_ERROR');
}
validateKey = metadata.validateKey;
}));
}).nThen(waitFor => {
Env.checkSignature(signedMsg, proof, validateKey, waitFor((err)=> {
if (err) {
waitFor.abort();
return void cb('INVALID_PROOF');
}
}));
}).nThen(waitFor => {
Linked.getLinkedDocuments(Env, { channel }, waitFor((err, json = {}) => {
// checkpoints
if (Array.isArray(content?.checkpoints)) {
const old = json?.checkpoints || [];
// add last 10 valid checkpoints
let i = 0;
content.checkpoints.reverse().some(data => {
// If cp already exists, recover user and time
// Otherwise, check integrity of new value and add them now
const oldValue = old.find(obj => {
return obj.blob === data.blob &&
obj.rtChannel === data.rtChannel;
});
const toAdd = oldValue || checkContent({
type: 'checkpoints',
data
}, user);
if (!toAdd) { return false; }
newContent.checkpoints.unshift(toAdd);
// Abort after 10 cps
if (++i >= 10) { return true; }
});
}
// channels and media
['channels', 'media'].forEach(type => {
if (!Array.isArray(content?.[type])) { return; }
content[type].forEach(data => {
const toAdd = checkContent({type, data}, user);
if (!toAdd) { return false; }
newContent[type].push(toAdd);
});
});
}));
}).nThen(() => {
Env.store.resetLinkedDocuments(channel, newContent, (err, data) => {
const { oldContent } = data;
Env.Log.info('RESET_LINKED_DOCUMENTS', {user, channel, oldContent, content});
cb();
});
});
};
Linked.removeLinkedDocument = (Env, allData, cb, _S, userId) => {
// data.user
// data.channel
// data.content
// type, channelId or blobId
// data.proof
// (sign "{ user, channel, content }" with pad signing key
const { channel, content, netfluxId, proof } = allData;
if (userId !== netfluxId) { return void cb('EFORBIDDEN'); }
const { type, data } = content;
const msg = Util.clone(data);
delete msg.proof;
const signedMsg = JSON.stringify(msg);
if (!allowedTypes.includes(type)) {
return void cb('INVALID_TYPE');
}
if (typeof(data) !== "string" || ![32,48].includes(data.length)) {
return void cb('INVALID_CONTENT');
}
let validateKey;
nThen(waitFor => {
getMetadata(Env, channel, waitFor((err, metadata) => {
if (!metadata?.validateKey) {
waitFor.abort();
return void cb(err || 'METADATA_ERROR');
}
validateKey = metadata.validateKey;
}));
}).nThen(waitFor => {
Env.checkSignature(signedMsg, proof, validateKey, waitFor((err)=> {
if (err) {
waitFor.abort();
return void cb('INVALID_PROOF');
}
}));
}).nThen(() => {
Env.store.removeLinkedDocument(channel, type, data, cb);
});
};
Linked.getFileSize = (Env, data, _cb) => {
const cb = Util.once(_cb);
const channel = data.channel;
let linked;
nThen(waitFor => {
Linked.listLinkedDocuments(Env, channel, waitFor((err, channels) => {
if (err) {
waitFor.abort();
return void cb(err);
}
linked = channels || [];
}));
}).nThen(() => {
linked.push(channel);
Env.getTotalSize(linked, cb);
});
};
Linked.getHistorySize = (Env, data, _cb) => {
const cb = Util.once(_cb);
const channel = data.channel;
let linked;
let channelTotalSize = 0;
let size = 0;
let start = 0;
let hash;
nThen(waitFor => {
Linked.getLinkedDocuments(Env, data, waitFor((err, json) => {
if (err) {
waitFor.abort();
return void cb(err);
}
linked = Util.clone(json);
}));
}).nThen(waitFor => {
// Get main channel size (chainpad)
Env.getFileSize(channel, waitFor((err, _size) => {
if (err) {
waitFor.abort();
return void cb(err);
}
channelTotalSize = _size;
}), true);
}).nThen(waitFor => {
// Get history offset to compute non-history size
HK.getHistoryOffset(Env, channel, null, waitFor((err, offset) => {
if (err) {
waitFor.abort();
return void cb(err);
}
start = offset;
const chanSize = channelTotalSize - offset;
size += chanSize;
}));
}).nThen(waitFor => {
// Get oldest hash of non-history data
Env.store.readMessagesBin(channel, start, (msgObj, readMore, abort) => {
const parsed = Util.tryParse(msgObj.buff.toString('utf8'));
if (!parsed) { return void readMore(); }
hash = HK.getHash(parsed[4]);
abort();
}, waitFor());
}).nThen(waitFor => {
// Get last checkpoint size (blob + rtChannel)
// Note: blob may be falsy if no checkpoint
const lastCp = (linked?.checkpoints || []).pop();
if (!lastCp) { return; }
const { blob, rtChannel } = lastCp;
if (blob) {
Env.getFileSize(blob, waitFor((err, _size) => {
if (err) {
waitFor.abort();
return void cb(err);
}
size += _size;
}), true);
}
Env.getFileSize(rtChannel, waitFor((err, _size) => {
if (err) {
waitFor.abort();
return void cb(err);
}
size += _size;
}), true);
}).nThen(() => {
cb(void 0, {
size, hash
});
});
};
Linked.trimHistory = (Env, data, cb) => {
const channel = data.channel;
let linked;
// if we reach this step, it means this user is an owner of "channel"
// so we can also delete any document linked to "channel" (from metadata)
nThen(waitFor => {
// List all but the current checkpoints
Linked.listOldCheckpoints(Env, channel, waitFor((err, channels) => {
if (err) {
waitFor.abort();
return void cb(err);
}
linked = channels || [];
}));
}).nThen(() => {
let n = nThen;
linked.forEach(chan => {
n = n(w => {
// If channel is "linked", we can archive all but last cp
getMetadata(Env, chan, w((err, md) => {
if (md?.linked !== channel) { return; }
// This is an old checkpoint linked to our document,
// we can archive it
const reason = "TRIM_HISTORY";
if (chan.length === HK.BLOB_ID_LENGTH) {
return Env.blobStore.archive.blob(chan, reason, w());
}
Env.store.archiveChannel(chan, reason, w());
}));
}).nThen;
});
n(() => {
cb();
});
});
};
// Archive all linked documents that inherit metadata from their
// parent. We consider ownership has already been checked when
// this function is called.
Linked.archiveLinkedData = (Env, channel, reason, channels, _cb) => {
const cb = Util.once(_cb);
let n = nThen;
channels.forEach(chan => {
n = n(w => {
// For each linked document, check if they inherit properties
getMetadata(Env, chan, w((err, md) => {
if (md?.linked !== channel) { return; }
// If they do, archive the document
if (chan.length === HK.BLOB_ID_LENGTH) {
return Env.blobStore.archive.blob(chan, reason, w());
}
Env.store.archiveChannel(chan, reason, w());
}));
}).nThen;
});
n(() => {
cb();
});
};

View File

@ -1,228 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Data = module.exports;
const Meta = require("../metadata");
const Core = require("./core");
const Util = require("../common-util");
const HK = require("../hk-util");
Data.getMetadataRaw = function (Env, channel, _cb, resolveLinked) {
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 &&
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
if (channel.length === HK.ADMIN_CHANNEL_LENGTH) {
return void cb(void 0, {
channel: channel,
creation: +new Date(),
owners: Env.admins,
});
}
var cached = Env.metadata_cache[channel];
if (HK.isMetadataMessage(cached)) {
Env.checkCache(channel);
return void cb(void 0, cached);
}
Env.batchMetadata(channel, cb, function (done) {
Env.computeMetadata(channel, function (err, meta) {
if (!err && HK.isMetadataMessage(meta)) {
Env.metadata_cache[channel] = meta;
// clear metadata after a delay if nobody has joined the channel within 30s
Env.checkCache(channel);
}
if (resolveLinked && meta?.linked?.length === HK.STANDARD_CHANNEL_LENGTH
&& meta?.linked !== channel) {
Data.getMetadataRaw(Env, meta.linked, (err, _meta) => {
meta.owners = _meta.owners;
meta.restricted = _meta.restricted;
meta.allowed = _meta.allowed;
done(err, meta);
});
return;
}
done(err, meta);
});
});
};
Data.getMetadata = function (Env, channel, cb, Server, netfluxId) {
Data.getMetadataRaw(Env, channel, function (err, metadata) {
if (err) { return void cb(err); }
if (!(metadata && metadata.restricted)) {
// if it's not restricted then just call back
return void cb(void 0, metadata);
}
const session = HK.getNetfluxSession(Env, netfluxId);
const allowed = HK.listAllowedUsers(metadata);
if (!HK.isUserSessionAllowed(allowed, session)) {
return void cb(void 0, {
restricted: metadata.restricted,
allowed: allowed,
rejected: true,
});
}
cb(void 0, metadata);
});
};
/* setMetadata
- write a new line to the metadata log if a valid command is provided
- data is an object: {
channel: channelId,
command: metadataCommand (string),
value: value
}
*/
Data.setMetadata = function (Env, safeKey, data, cb, Server) {
var unsafeKey = Util.unescapeKeyCharacters(safeKey);
var channel = data.channel;
var command = data.command;
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'); }
Env.queueMetadata(channel, function (next) {
Data.getMetadataRaw(Env, channel, function (err, metadata) {
if (err) {
cb(err);
return void next();
}
if (!Core.hasOwners(metadata)) {
cb('E_NO_OWNERS');
return void next();
}
// if you are a pending owner and not an owner
// you can either ADD_OWNERS, or RM_PENDING_OWNERS
// and you should only be able to add yourself as an owner
// everything else should be rejected
// else if you are not an owner
// you should be rejected
// else write the command
// Confirm that the channel is owned by the user in question
// or the user is accepting a pending ownership offer
if (Core.hasPendingOwners(metadata) &&
Core.isPendingOwner(metadata, unsafeKey) &&
!Core.isOwner(metadata, unsafeKey)) {
// If you are a pending owner, make sure you can only add yourelf as an owner
if ((command !== 'ADD_OWNERS' && command !== 'RM_PENDING_OWNERS')
|| !Array.isArray(data.value)
|| data.value.length !== 1
|| data.value[0] !== unsafeKey) {
cb('INSUFFICIENT_PERMISSIONS');
return void next();
}
// FIXME wacky fallthrough is hard to read
// we could pass this off to a writeMetadataCommand function
// and make the flow easier to follow
} else if (!Core.isOwner(metadata, unsafeKey)) {
cb('INSUFFICIENT_PERMISSIONS');
return void next();
}
// Add the new metadata line
var line = [command, data.value, +new Date()];
var changed = false;
try {
changed = Meta.handleCommand(metadata, line);
} catch (e) {
cb(e);
return void next();
}
// if your command is valid but it didn't result in any change to the metadata,
// call back now and don't write any "useless" line to the log
if (!changed) {
cb(void 0, metadata);
return void next();
}
let store = Env.msgStore;
if (channel.length === HK.BLOB_ID_LENGTH) {
store = Env.blobStore;
}
store.writeMetadata(channel, JSON.stringify(line), function (e) {
if (e) {
cb(e);
return void next();
}
// send the message back to the person who changed it
// since we know they're allowed to see it
cb(void 0, metadata);
next();
const metadata_cache = Env.metadata_cache;
// update the cached metadata
metadata_cache[channel] = metadata;
Env.checkCache(channel);
// it's easy to check if the channel is restricted
const isRestricted = metadata.restricted;
// and these values will be used in any case
const s_metadata = JSON.stringify(metadata);
const hk_id = Env.historyKeeper.id;
if (!isRestricted) {
// pre-allow-list behaviour
// if it's not restricted, broadcast the new metadata to everyone
return void Server.channelBroadcast(channel, s_metadata, hk_id);
}
// otherwise derive the list of users (unsafeKeys) that are allowed to stay
const allowed = HK.listAllowedUsers(metadata);
// anyone who is not allowed will get the same error message
const s_error = JSON.stringify({
error: 'ERESTRICTED',
channel: channel,
});
// iterate over the channel's userlist
const toRemove = [];
Server.getChannelUserList(channel).forEach(function (userId) {
const session = HK.getNetfluxSession(Env, userId);
// if the user is allowed to remain, send them the metadata
if (HK.isUserSessionAllowed(allowed, session)) {
return void Server.send(userId, [
0,
hk_id,
'MSG',
userId,
s_metadata
], function () {});
}
// otherwise they are not in the list.
// send them an error and kick them out!
toRemove.push(userId);
Server.send(userId, [
0,
hk_id,
'MSG',
userId,
s_error
], function () {});
});
Server.removeFromChannel(channel, toRemove);
});
}, true);
});
};

View File

@ -1,54 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
/*jshint esversion: 6 */
const Moderators = module.exports;
const Moderator = require('../storage/moderator');
const Util = require("../common-util");
Moderators.getAll = (Env, cb) => {
Moderator.getAll(Env, (err, data) => {
if (err) { return void cb(err); }
let res = {};
Object.keys(data).forEach(safeKey => {
const unsafeKey = Util.unescapeKeyCharacters(safeKey);
res[unsafeKey] = data[safeKey];
});
cb(null, res);
});
};
Moderators.getKeysSync = (Env) => {
return Moderator.getAllKeys(Env);
};
Moderators.add = (Env, edPublic, data, adminKey, _cb) => {
const cb = Util.once(Util.mkAsync(_cb));
data.createdBy = adminKey;
data.time = +new Date();
const safeKey = Util.escapeKeyCharacters(edPublic);
Moderator.write(Env, safeKey, data, (err) => {
if (err) { return void cb(err); }
if (!Env.moderators.includes(edPublic)) {
Env.moderators.push(edPublic);
}
Env.envUpdated.fire();
cb();
});
};
Moderators.delete = (Env, id, _cb) => {
const cb = Util.once(Util.mkAsync(_cb));
const safeKey = Util.escapeKeyCharacters(id);
Moderator.delete(Env, safeKey, (err) => {
if (err && err !== 'ENOENT') { return void cb(err); }
let idx = Env.moderators.indexOf(id);
if (idx !== -1) {
Env.moderators.splice(idx, 1);
Env.envUpdated.fire();
}
cb(void 0, true);
});
};

View File

@ -1,339 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Core = require("./core");
const Pinning = module.exports;
const Util = require("../common-util");
const nThen = require("nthen");
const Linked = require('./linked');
const escapeKeyCharacters = Util.escapeKeyCharacters;
const unescapeKeyCharacters = Util.unescapeKeyCharacters;
var sumChannelSizes = function (sizes) { // FIXME this synchronous code could be done by a worker
return Object.keys(sizes).map(function (id) { return sizes[id]; })
.filter(function (x) {
// only allow positive numbers
return !(typeof(x) !== 'number' || x <= 0);
})
.reduce(function (a, b) { return a + b; }, 0);
};
// FIXME it's possible for this to respond before the server has had a chance
// to fetch the limits. Maybe we should respond with an error...
// or wait until we actually know the limits before responding
var getLimit = Pinning.getLimit = function (Env, safeKey, cb) {
var unsafeKey = unescapeKeyCharacters(safeKey);
var limit = Env.limits[unsafeKey];
var defaultLimit = typeof(Env.defaultStorageLimit) === 'number'?
Env.defaultStorageLimit: Core.DEFAULT_LIMIT;
var toSend = limit && typeof(limit.limit) === "number"?
[limit.limit, limit.plan, limit.note] : [defaultLimit, '', ''];
cb(void 0, toSend);
};
var getMultipleFileSize = function (Env, channels, cb) {
Env.getMultipleFileSize(channels, cb);
};
var loadUserPins = function (Env, safeKey, cb) {
var session = Core.getSession(Env.Sessions, safeKey);
if (session.channels) {
return cb(session.channels);
}
Env.batchUserPins(safeKey, cb, function (done) {
Env.getPinState(safeKey, function (err, value) {
if (!err) {
// only put this into the cache if it completes
session.channels = value;
}
done(value);
});
});
};
var truthyKeys = function (O) {
try {
return Object.keys(O).filter(function (k) {
return O[k];
});
} catch (err) {
return [];
}
};
var getChannelList = Pinning.getChannelList = function (Env, safeKey, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
loadUserPins(Env, safeKey, function (pins) {
cb(truthyKeys(pins));
});
};
var addLinkedDocuments = (Env, channels, cb) => {
var arr = Array.from(channels);
var n = nThen;
arr.forEach(chan => {
// For each channel, add their linked documents
n = n(w => {
Linked.listLinkedDocuments(Env, chan, w((err, linked) => {
if (err || !linked) { return; }
linked.forEach(id => {
channels.add(id);
});
}));
}).nThen;
});
n(() => {
cb();
});
};
Pinning.getTotalSize = function (Env, safeKey, cb) {
var unsafeKey = unescapeKeyCharacters(safeKey);
var limit = Env.limits[unsafeKey];
// Get a common key if multiple users share the same quota, otherwise take the public key
var batchKey = (limit && Array.isArray(limit.users)) ? limit.users.join('') : safeKey;
Env.batchTotalSize(batchKey, cb, function (done) {
let channels = new Set();
nThen(function (waitFor) {
// Get the channels list for our user account
getChannelList(Env, safeKey, waitFor(function (_channels) {
if (!_channels) {
waitFor.abort();
return done('INVALID_PIN_LIST');
}
for (let channel of _channels) {
channels.add(channel);
}
}));
// Get the channels list for users sharing our quota
if (limit && Array.isArray(limit.users) && limit.users.length > 1) {
limit.users.forEach(function (key) {
if (key === unsafeKey) { return; } // Don't count ourselves twice
getChannelList(Env, key, waitFor(function (_channels) {
if (!_channels) { return; } // Broken user, don't count their quota
for (let channel of _channels) {
channels.add(channel);
}
}));
});
}
}).nThen(function (waitFor) {
addLinkedDocuments(Env, channels, waitFor());
}).nThen(function () {
Env.getTotalSize(Array.from(channels), done);
});
});
};
/* Users should be able to clear their own pin log with an authenticated RPC
*/
Pinning.removePins = function (Env, safeKey, cb) {
// FIXME respect the queue
Env.pinStore.archiveChannel(safeKey, undefined, function (err) {
Core.expireSession(Env.Sessions, safeKey);
Env.Log.info('ARCHIVAL_PIN_BY_OWNER_RPC', {
safeKey: safeKey,
status: err? String(err): 'SUCCESS',
});
if (err) { return void cb(err); }
cb(void 0, 'OK');
});
};
Pinning.trimPins = function (Env, safeKey, cb) {
cb("NOT_IMPLEMENTED");
};
var getFreeSpace = Pinning.getFreeSpace = function (Env, safeKey, cb) {
getLimit(Env, safeKey, function (e, limit) {
if (e) { return void cb(e); }
Pinning.getTotalSize(Env, safeKey, function (e, size) {
if (typeof(size) === 'undefined') { return void cb(e); }
var rem = limit[0] - size;
if (typeof(rem) !== 'number') {
return void cb('invalid_response');
}
cb(void 0, rem);
});
});
};
Pinning.getHash = function (Env, safeKey, cb) {
getChannelList(Env, safeKey, function (channels) {
Env.hashChannelList(channels, cb);
});
};
Pinning.pinChannel = function (Env, safeKey, channels, cb) {
if (!channels && channels.filter) {
return void cb('INVALID_PIN_LIST');
}
// get channel list ensures your session has a cached channel list
getChannelList(Env, safeKey, function (pinned) {
var session = Core.getSession(Env.Sessions, safeKey);
// only pin channels which are not already pinned
var toStore = channels.filter(function (channel) {
return channel && pinned.indexOf(channel) === -1;
});
if (toStore.length === 0) {
return void cb();
}
let pin = function () {
Env.pinStore.message(safeKey, JSON.stringify(['PIN', toStore, +new Date()]),
function (e) {
if (e) { return void cb(e); }
if (!session || !session.channels) { return void cb(); }
toStore.forEach(function (channel) {
session.channels[channel] = true;
});
cb();
});
};
// Support tickets are always pinned, no need to check the limit
if (safeKey === escapeKeyCharacters(Env.supportPinKey)) {
return void pin();
}
getMultipleFileSize(Env, toStore, function (e, sizes) {
if (typeof(sizes) === 'undefined') { return void cb(e); }
var pinSize = sumChannelSizes(sizes); // FIXME don't do this in the main thread...
getFreeSpace(Env, safeKey, function (e, free) {
if (typeof(free) === 'undefined') {
Env.WARN('getFreeSpace', e);
return void cb(e);
}
if (pinSize > free) { return void cb('E_OVER_LIMIT'); }
pin();
});
});
});
};
Pinning.unpinChannel = function (Env, safeKey, channels, cb) {
if (!channels && channels.filter) {
// expected array
return void cb('INVALID_PIN_LIST');
}
getChannelList(Env, safeKey, function (pinned) {
var session = Core.getSession(Env.Sessions, safeKey);
// only unpin channels which are pinned
var toStore = channels.filter(function (channel) {
return channel && pinned.indexOf(channel) !== -1;
});
if (toStore.length === 0) {
return void cb();
}
Env.pinStore.message(safeKey, JSON.stringify(['UNPIN', toStore, +new Date()]),
function (e) {
if (e) { return void cb(e); }
toStore.forEach(function (channel) {
delete session.channels[channel];
});
cb();
});
});
};
Pinning.resetUserPins = function (Env, safeKey, channelList, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!Array.isArray(channelList)) { return void cb('INVALID_PIN_LIST'); }
var session = Core.getSession(Env.Sessions, safeKey);
if (!channelList.length) {
return void cb();
}
let reset = function () {
var pins = {};
Env.pinStore.message(safeKey, JSON.stringify(['RESET', channelList, +new Date()]),
function (e) {
if (e) { return void cb(e); }
channelList.forEach(function (channel) {
pins[channel] = true;
});
// update in-memory cache IFF the reset was allowed.
if (session) { session.channels = pins; }
cb();
});
};
// Support tickets are always pinned, no need to check the limit
if (safeKey === escapeKeyCharacters(Env.supportPinKey)) {
return void reset();
}
getMultipleFileSize(Env, channelList, function (e, sizes) {
if (typeof(sizes) === 'undefined') { return void cb(e); }
var pinSize = sumChannelSizes(sizes);
getLimit(Env, safeKey, function (e, limit) {
if (e) {
Env.WARN('[RESET_ERR]', e);
return void cb(e);
}
/* we want to let people pin, even if they are over their limit,
but they should only be able to do this once.
This prevents data loss in the case that someone registers, but
does not have enough free space to pin their migrated data.
They will not be able to pin additional pads until they upgrade
or delete enough files to go back under their limit. */
if (pinSize > limit[0] && session.hasPinned) { return void(cb('E_OVER_LIMIT')); }
reset();
});
});
};
Pinning.getFileSize = function (Env, channel, cb) {
Env.getFileSize(channel, cb);
};
/* accepts a list, and returns a sublist of channel or file ids which seem
to have been deleted from the server (file size 0)
we might consider that we should only say a file is gone if fs.stat returns
ENOENT, but for now it's simplest to just rely on getFileSize...
*/
Pinning.getDeletedPads = function (Env, channels, cb) {
Env.getDeletedPads(channels, cb);
};
// FIXME this will be removed from the client
Pinning.isChannelPinned = function (Env, channel, cb) {
return void cb(void 0, true);
};
Pinning.isPremium = function (Env, userKey, cb) {
const limit = Env.limits[userKey];
return void cb(void 0, !!limit?.plan);
//return void cb(void 0, (limit?.plan && limit.plan !== "custom"));
};

View File

@ -1,322 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Quota = module.exports;
//const Util = require("../common-util");
const Keys = require("../keys");
const Https = require("https");
const Http = require("http");
const Util = require("../common-util");
const Stats = require("../stats");
const Admin = require("./admin-rpc.js");
const nThen = require('nthen');
var validLimitFields = ['limit', 'plan', 'note', 'users', 'origin'];
Quota.isValidLimit = function (o) {
var valid = o && typeof(o) === 'object' &&
typeof(o.limit) === 'number' &&
typeof(o.plan) === 'string' &&
typeof(o.note) === 'string' &&
// optionally contains a 'users' array
(Array.isArray(o.users) || typeof(o.users) === 'undefined') &&
// check that the object contains only the expected fields
!Object.keys(o).some(function (k) {
return validLimitFields.indexOf(k) === -1;
});
return valid;
};
Quota.applyCustomLimits = function (Env) {
// DecreedLimits > customLimits > serverLimits;
// FIXME perform an integrity check on shared limits
// especially relevant because we use Env.limits
// when considering whether to archive inactive accounts
// read custom limits from the Environment (taken from config)
var customLimits = (function (custom) {
var limits = {};
Object.keys(custom).forEach(function (k) {
var unsafeKey = Keys.canonicalize(k);
if (!unsafeKey) { return; }
limits[unsafeKey] = custom[k];
});
return limits;
}(Env.customLimits || {}));
Env.limits = Env.limits || {};
Object.keys(customLimits).forEach(function (k) {
if (!Quota.isValidLimit(customLimits[k])) { return; }
Env.limits[k] = customLimits[k];
});
// console.log(Env.limits);
};
var isRemoteVersionNewer = function (local, remote) {
try {
local = local.split('.').map(Number);
remote = remote.split('.').map(Number);
for (var i = 0; i < 3; i++) {
if (remote[i] < local[i]) { return false; }
if (remote[i] > local[i]) { return true; }
}
} catch (err) {
// if anything goes wrong just fall through and return false
// false negatives are better than false positives
}
return false;
};
/*
var Assert = require("assert");
[
// remote versions
['4.5.0', '4.5.0', false], // equal semver should not prompt
['4.5.0', '4.5.1', true], // patch versions should prompt
['4.5.0', '4.6.0', true], // minor versions should prompt
['4.5.0', '5.0.0', true], // major versions should prompt
// local
['5.3.1', '4.9.0', false], // newer major should not prompt
['4.7.0', '4.6.0', false], // newer minor should not prompt
['4.7.0', '4.6.1', false], // newer patch should not prompt if other values are greater
].forEach(function (x) {
var result = isRemoteVersionNewer(x[0], x[1]);
Assert.equal(result, x[2]);
});
*/
// check if the remote endpoint reported an available server version
// which is newer than your current version (Env.version)
// if so, set Env.updateAvailable to the URL of its release notes
var checkUpdateAvailability = function (Env, json) {
if (!(json && typeof(json.updateAvailable) === 'string' && typeof(json.version) === 'string')) { return; }
// expects {updateAvailable: 'https://github.com/cryptpad/cryptpad/releases/4.7.0', version: '4.7.0'}
// the version string is provided explicitly even though it could be parsed from GitHub's URL
// this will allow old instances to understand responses of arbitrary URLs
// as long as we keep using semver for 'version'
if (!isRemoteVersionNewer(Env.version, json.version)) {
Env.updateAvailable = undefined;
return;
}
Env.updateAvailable = json.updateAvailable;
Env.Log.info('AN_UPDATE_IS_AVAILABLE', {
version: json.version,
updateAvailable: json.updateAvaiable,
});
};
var queryAccountServer = function (Env, cb) {
var done = Util.once(Util.mkAsync(cb));
var rawBody = Stats.instanceData(Env);
let send = () => {
Env.Log.info("SERVER_TELEMETRY", rawBody);
var body = JSON.stringify(rawBody);
var options = {
host: 'accounts.cryptpad.fr',
path: '/api/getauthorized',
method: 'POST',
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body)
}
};
var req = Https.request(options, function (response) {
if (!('' + response.statusCode).match(/^2\d\d$/)) {
return void cb('SERVER ERROR ' + response.statusCode);
}
var str = '';
response.on('data', function (chunk) {
str += chunk;
});
response.on('end', function () {
try {
var json = JSON.parse(str);
checkUpdateAvailability(Env, json);
done(void 0, json);
} catch (e) {
done(e);
}
});
});
req.on('error', function () {
done();
});
req.end(body);
};
if (Env.provideAggregateStatistics) {
let stats = {};
nThen(waitFor => {
Admin.getRegisteredUsers(Env, null, waitFor((err, data) => {
if (err) { return; }
stats.registered = data.users;
if (Env.lastPingRegisteredUsers) {
stats.usersDiff = stats.registered - Env.lastPingRegisteredUsers;
}
Env.lastPingRegisteredUsers = stats.registered;
}));
}).nThen(() => {
if (Env.maxConcurrentWs) {
stats.maxConcurrentWs = Env.maxConcurrentWs;
Env.maxConcurrentWs = 0;
}
if (Env.maxConcurrentUniqueWs) {
stats.maxConcurrentUniqueIPs = Env.maxConcurrentUniqueWs;
Env.maxConcurrentUniqueWs = 0;
}
if (Env.maxConcurrentRegUsers) {
stats.maxConcurrentRegUsers = Env.maxConcurrentRegUsers;
Env.maxConcurrentRegUsers = 0;
}
if (Env.maxActiveChannels) {
stats.maxConcurrentChannels = Env.maxActiveChannels;
Env.maxActiveChannels = 0;
}
rawBody.statistics = stats;
send();
});
return;
}
send();
};
Quota.shouldContactServer = function (Env) {
return !(Env.blockDailyCheck === true ||
(
typeof(Env.blockDailyCheck) === 'undefined' &&
Env.adminEmail === false
)
);
};
Quota.pingAccountsDaily = function (Env, _cb) {
var cb = Util.mkAsync(_cb);
if (!Quota.shouldContactServer(Env)) { return void cb(); }
queryAccountServer(Env, function (err) {
cb(err);
});
};
var queryQuotaServer = function (Env, cb) {
var done = Util.once(Util.mkAsync(cb));
var rawBody = Stats.instanceData(Env);
Env.Log.info("QUOTA_UPDATE", rawBody);
var body = JSON.stringify(rawBody);
var options = {
host: undefined,
path: '/api/getquota',
method: 'POST',
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body)
}
};
const accountsOrigin = Env.accounts_api;
var H = Https;
if (typeof(accountsOrigin) === 'string') {
try {
let url = new URL(accountsOrigin);
if (!['https:', 'http:'].includes(url.protocol)) { throw new Error("INVALID_PROTOCOL"); }
if (url.protocol === 'http:') { H = Http; }
let port = Number(url.port);
if (port && typeof(port) === 'number') { options.port = port; }
options.host = url.hostname;
Env.Log.info("USING_CUSTOM_ACCOUNTS_API", {
value: accountsOrigin,
});
} catch (err) {
Env.Log.error("INVALID_CUSTOM_QUOTA_API", {
error: err.message,
value: accountsOrigin,
});
}
}
var req = H.request(options, function (response) {
if (!('' + response.statusCode).match(/^2\d\d$/)) {
return void cb('SERVER ERROR ' + response.statusCode);
}
var str = '';
response.on('data', function (chunk) {
str += chunk;
});
response.on('end', function () {
try {
var json = JSON.parse(str);
// don't overwrite the limits with junk data
if (json && json.message === 'EINVAL') { return void done(); }
done(void 0, json);
} catch (e) {
done(e);
}
});
});
req.on('error', function (e) {
Quota.applyCustomLimits(Env);
done(e);
});
req.end(body);
};
Quota.queryQuotaServer = function (Env, cb) {
Env.batchAccountQuery('', cb, function (done) {
queryQuotaServer(Env, done);
});
};
Quota.updateCachedLimits = function (Env, _cb) {
var cb = Util.mkAsync(_cb);
if (!Env.accounts_api) {
Quota.applyCustomLimits(Env);
return void cb();
}
Quota.queryQuotaServer(Env, function (err, json) {
if (err) { return void cb(err); }
if (!json) { return void cb(); }
for (var k in json) {
if (k.length === 44 && json[k]) {
json[k].origin = 'remote';
}
}
Env.limits = json;
Quota.applyCustomLimits(Env);
cb();
});
};
// The limits object contains storage limits for all the publicKey that have paid
// To each key is associated an object containing the 'limit' value and a 'note' explaining that limit
Quota.getUpdatedLimit = function (Env, safeKey, cb) {
Quota.updateCachedLimits(Env, function (err) {
if (err) { return void cb(err); }
var limit = Env.limits[safeKey];
if (limit && typeof(limit.limit) === 'number') {
return void cb(void 0, [limit.limit, limit.plan, limit.note]);
}
return void cb(void 0, [Env.defaultStorageLimit, '', '']);
});
};

View File

@ -1,98 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Upload = module.exports;
const Util = require("../common-util");
const Pinning = require("./pin-rpc");
const nThen = require("nthen");
const Core = require("./core");
Upload.status = function (Env, safeKey, data, _cb) { // FIXME FILES
const filesize = data.size;
var cb = Util.once(Util.mkAsync(_cb));
// validate that the provided size is actually a positive number
if (typeof(filesize) !== 'number' &&
filesize >= 0) { return void cb('E_INVALID_SIZE'); }
nThen(function (w) {
// if the proposed upload size is within the regular limit
// jump ahead to the next block
if (filesize <= Env.maxUploadSize) { return; }
// if larger uploads aren't explicitly enabled then reject them
if (typeof(Env.premiumUploadSize) !== 'number') {
w.abort();
return void cb('TOO_LARGE');
}
// otherwise go and retrieve info about the user's quota
Pinning.getLimit(Env, safeKey, w(function (err, limit) {
if (err) {
w.abort();
return void cb("E_BAD_LIMIT");
}
var plan = limit[1];
// see if they have a special plan, reject them if not
if (plan === '') {
w.abort();
return void cb('TOO_LARGE');
}
// and that they're not over the greater limit
if (filesize >= Env.premiumUploadSize) {
w.abort();
return void cb("TOO_LARGE");
}
// fallthrough will proceed to the next block
}));
}).nThen(function (w) {
var abortAndCB = Util.both(w.abort, cb);
Env.blobStore.status(safeKey, w(function (err, inProgress) {
// if there's an error something is weird
if (err) { return void abortAndCB(err); }
// we cannot upload two things at once
if (inProgress) { return void abortAndCB(void 0, true); }
}));
}).nThen(function () {
// if yuo're here then there are no pending uploads
// check if you have space in your quota to upload something of this size
Pinning.getFreeSpace(Env, safeKey, function (e, free) {
if (e) { return void cb(e); }
if (filesize >= free) { return cb('NOT_ENOUGH_SPACE'); }
var user = Core.getSession(Env.Sessions, safeKey);
user.pendingUploadSize = filesize;
user.currentUploadSize = 0;
user.linked = data.linked;
cb(void 0, false);
});
});
};
Upload.upload = function (Env, safeKey, data, cb) {
Env.blobStore.uploadWs(safeKey, data?.chunk, cb);
};
Upload.cancel = function (Env, safeKey, arg, cb) {
Env.blobStore.cancel(safeKey, arg?.size, cb);
};
var completeUpload = function (owned) {
return function (Env, safeKey, arg, cb) {
Env.blobStore.closeBlobstage(safeKey);
var user = Core.getSession(Env.Sessions, safeKey);
var size = user.pendingUploadSize;
var linked = user.linked;
Env.completeUpload(safeKey, arg, Boolean(owned), size, linked, cb);
};
};
Upload.complete = completeUpload(false);
Upload.complete_owned = completeUpload(true);

View File

@ -1,79 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Users = module.exports;
const User = require('../storage/user');
const Util = require("../common-util");
Users.getAll = (Env, cb) => {
User.getAll(Env, (err, data) => {
if (err) { return void cb(err); }
cb(null, data);
});
};
Users.add = (Env, edPublic, data, adminKey, _cb) => {
const cb = Util.once(Util.mkAsync(_cb));
data.createdBy = adminKey;
data.time = +new Date();
const safeKey = Util.escapeKeyCharacters(edPublic);
User.write(Env, safeKey, data, (err) => {
if (err) { return void cb(err); }
cb();
});
};
Users.delete = (Env, id, _cb) => {
const cb = Util.once(Util.mkAsync(_cb));
User.delete(Env, id, (err) => {
if (err && err !== 'ENOENT') { return void cb(err); }
cb(void 0, true);
});
};
Users.read = (Env, edPublic, _cb) => {
const cb = Util.once(Util.mkAsync(_cb));
User.read(Env, edPublic, (err, data) => {
if (err) { return void cb(err); }
cb(void 0, data);
});
};
Users.update = (Env, edPublic, changes, _cb) => {
const cb = Util.once(Util.mkAsync(_cb));
Users.read(Env, edPublic, (err, data) => {
if (err === 'ENOENT') { return void cb(); }
if (err) { return void cb(err); }
if (typeof(changes) !== "object") { return void cb('EINVAL'); }
// User exists, update their data
var aborted = Object.keys(changes || {}).some((key) => {
if (changes[key] === false) {
delete data[key];
return;
}
if (String(changes[key]).length > 300) {
cb('E_TOO_LONG');
return true;
}
data[key] = changes[key];
});
if (aborted) { return; }
User.update(Env, edPublic, data, cb);
});
};
// On password change, update the block
Users.checkUpdate = (Env, userData, newBlock, cb) => {
if (!Array.isArray(userData)) { userData = []; }
let edPublic = userData[1];
if (!edPublic) { return void cb('INVALID_PUBLIC_KEY'); }
Users.read(Env, edPublic, (err, data) => {
if (err === 'ENOENT') { return void cb(); }
if (err) { return void cb(err); }
// User exists, update their block
data.block = newBlock;
User.update(Env, edPublic, data, cb);
});
};

View File

@ -1,6 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
module.exports = require("../src/common/common-hash");

View File

@ -1,5 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
module.exports = require("../src/common/common-util");

View File

@ -1,28 +0,0 @@
// SPDX-FileCopyrightText: 2024 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Nacl = require('tweetnacl/nacl-fast');
const CPCrypto = module.exports;
const plugins = require('./plugin-manager');
CPCrypto.init = (cb) => {
const crypto = {};
crypto.open = (signedMsg, validateKey) => {
return Nacl.sign.open(signedMsg, validateKey);
};
crypto.detachedVerify = (signedBuffer, signatureBuffer, validateKey) => {
return Nacl.sign.detached.verify(signedBuffer, signatureBuffer, validateKey);
};
if (plugins.SODIUM && plugins.SODIUM.crypto) {
let c = plugins.SODIUM.crypto;
if (c.open) { crypto.open = c.open; }
if (c.detachedVerify) { crypto.detachedVerify = c.detachedVerify; }
}
// Make async because we might need it later with libsodium's promise
// libsodium.ready.then(() => {});
setTimeout(() => {
cb(void 0, crypto);
});
};

View File

@ -1,140 +0,0 @@
// 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,433 +0,0 @@
// SPDX-FileCopyrightText: 2025 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
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
IMPLEMENTED:
RESTRICT_REGISTRATION(<boolean>)
RESTRICT_SSO_REGISTRATION(<boolean>)
UPDATE_DEFAULT_STORAGE(<number>)
// QUOTA MANAGEMENT
SET_QUOTA(<string:signkey>, limit)
RM_QUOTA(<string:signkey>)
// INACTIVITY
SET_INACTIVE_TIME
SET_ACCOUNT_RETENTION_TIME
SET_ARCHIVE_RETENTION_TIME
// UPLOADS
SET_MAX_UPLOAD_SIZE
SET_PREMIUM_UPLOAD_SIZE
// BACKGROUND PROCESSES
DISABLE_INTEGRATED_TASKS
DISABLE_INTEGRATED_EVICTION
ENABLE_PROFILING
SET_PROFILING_WINDOW
// BROADCAST
SET_LAST_BROADCAST_HASH
SET_SURVEY_URL
SET_MAINTENANCE
// EASIER CONFIG
SET_ADMIN_EMAIL
SET_SUPPORT_MAILBOX
SET_SUPPORT_KEYS
// COMMUNITY PARTICIPATION AND GOVERNANCE
CONSENT_TO_CONTACT
LIST_MY_INSTANCE
PROVIDE_AGGREGATE_STATISTICS
REMOVE_DONATE_BUTTON
BLOCK_DAILY_CHECK
// Customized instance info
SET_INSTANCE_JURISDICTION
SET_INSTANCE_DESCRIPTION
SET_INSTANCE_NAME
SET_INSTANCE_NOTICE
// bearer secret
SET_BEARER_SECRET
NOT IMPLEMENTED:
// RESTRICTED REGISTRATION
ADD_INVITE
REVOKE_INVITE
REDEEM_INVITE
DISABLE_EMBEDDING
// 2.0
Env.DEV_MODE || Env.FRESH_MODE,
ADD_ADMIN_KEY
RM_ADMIN_KEY
*/
// 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)));
};
// 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 ((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', ['DISABLE_EMBEDDING', [true]]], console.log)
commands.ENABLE_EMBEDDING = makeBooleanSetter('enableEmbedding');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['ENFORCE_MFA', [true]]], console.log)
commands.ENFORCE_MFA = makeBooleanSetter('enforceMFA');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['RESTRICT_REGISTRATION', [true]]], console.log)
commands.RESTRICT_REGISTRATION = makeBooleanSetter('restrictRegistration');
commands.RESTRICT_SSO_REGISTRATION = makeBooleanSetter('restrictSsoRegistration');
commands.DISABLE_STORE_INVITED_USERS = makeBooleanSetter('dontStoreInvitedUsers');
commands.DISABLE_STORE_SSO_USERS = makeBooleanSetter('dontStoreSSOUsers');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['DISABLE_INTEGRATED_EVICTION', [true]]], console.log)
commands.DISABLE_INTEGRATED_EVICTION = makeBooleanSetter('disableIntegratedEviction');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['DISABLE_INTEGRATED_TASKS', [true]]], console.log)
commands.DISABLE_INTEGRATED_TASKS = makeBooleanSetter('disableIntegratedTasks');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['CONSENT_TO_CONTACT', [true]]], console.log)
commands.CONSENT_TO_CONTACT = makeBooleanSetter('consentToContact');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['LIST_MY_INSTANCE', [true]]], console.log)
commands.LIST_MY_INSTANCE = makeBooleanSetter('listMyInstance');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['PROVIDE_AGGREGATE_STATISTICS', [true]]], console.log)
commands.PROVIDE_AGGREGATE_STATISTICS = makeBooleanSetter('provideAggregateStatistics');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['REMOVE_DONATE_BUTTON', [true]]], console.log)
commands.REMOVE_DONATE_BUTTON = makeBooleanSetter('removeDonateButton');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['BLOCK_DAILY_CHECK', [true]]], console.log)
commands.BLOCK_DAILY_CHECK = makeBooleanSetter('blockDailyCheck');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_LOGO_MIME', ['image/png']]], console.log)
commands.SET_LOGO_MIME = makeGenericSetter('logoMimeType', args_isString);
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_ACCENT_COLOR', ['#ff0073']]], console.log)
commands.SET_ACCENT_COLOR = makeGenericSetter('accentColor', args_isString);
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['ENABLE_PROFILING', [true]]], console.log)
commands.ENABLE_PROFILING = makeBooleanSetter('enableProfiling');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_PROFILING_WINDOW', [10000]]], console.log)
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');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_PREMIUM_UPLOAD_SIZE', [150 * 1024 * 1024]]], console.log)
commands.SET_PREMIUM_UPLOAD_SIZE = makeIntegerSetter('premiumUploadSize');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['UPDATE_DEFAULT_STORAGE', [100 * 1024 * 1024]]], console.log)
commands.UPDATE_DEFAULT_STORAGE = makeIntegerSetter('defaultStorageLimit');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INACTIVE_TIME', [90]]], console.log)
commands.SET_INACTIVE_TIME = makeIntegerSetter('inactiveTime');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_ARCHIVE_RETENTION_TIME', [30]]], console.log)
commands.SET_ARCHIVE_RETENTION_TIME = makeIntegerSetter('archiveRetentionTime');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_ACCOUNT_RETENTION_TIME', [365]]], console.log)
commands.SET_ACCOUNT_RETENTION_TIME = makeIntegerSetter('accountRetentionTime');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_ADMIN_EMAIL', ['admin@website.tld']]], console.log)
commands.SET_ADMIN_EMAIL = makeGenericSetter('adminEmail', args_isString);
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_SUPPORT_MAILBOX', ["Tdz6+fE9N9XXBY93rW5qeNa/k27yd40c0vq7EJyt7jA="]]], console.log)
commands.SET_SUPPORT_MAILBOX = makeGenericSetter('supportMailbox', function (args) {
return args_isString(args) && Core.isValidPublicKey(args[0]);
});
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_SUPPORT_KEYS', ["Tdz6+fE9N9XXBY93rW5qeNa/k27yd40c0vq7EJyt7jA=", "Tdz6+fE9N9XXBY93rW5qeNa/k27yd40c0vq7EJyt7jA="]]], console.log)
commands.DISABLE_APPS = function (Env, args) {
if (!Array.isArray(args)) { throw new Error("INVALID_ARGS"); }
if (JSON.stringify(args) === JSON.stringify(Env.appsToDisable)) { return false; }
Env.appsToDisable = args;
return true;
};
commands.SET_SUPPORT_KEYS = function (Env, args) {
const curvePublic = args[0]; // Support mailbox key
const edPublic = args[1]; // Support pin log
let validated = typeof(curvePublic) === "string" &&
(Core.isValidPublicKey(curvePublic) || !curvePublic) &&
typeof(edPublic) === "string" &&
(Core.isValidPublicKey(edPublic) || !edPublic);
if (!validated) { throw new Error('INVALID_ARGS'); }
if (Env.supportMailboxKey === curvePublic && Env.supportPinKey === edPublic) { return false; }
Env.supportMailboxKey = curvePublic;
Env.supportPinKey = edPublic;
return true;
};
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_PURPOSE', ["development"]]], console.log)
commands.SET_INSTANCE_PURPOSE = makeGenericSetter('instancePurpose', args_isString);
// 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');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_NAME', ['My Personal CryptPad']]], console.log)
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_NAME', [{default:'My Personal CryptPad', fr: "Mon CryptPad personnel"}]]], console.log)
commands.SET_INSTANCE_NAME = makeTranslation('instanceName');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_DESCRIPTION', ['A personal instance, hosted for me and nobody else']]], console.log)
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_DESCRIPTION', [{default:'A personal server, not intended for public usage', fr: 'Un serveur personnel, non destiné à un usage public'}]]], console.log)
commands.SET_INSTANCE_DESCRIPTION = makeTranslation('instanceDescription');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_NOTICE', ['Our hosting costs have increased during the pandemic. Please consider donating!']]], console.log)
// 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');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_LAST_BROADCAST_HASH', [hash]]], console.log)
commands.SET_LAST_BROADCAST_HASH = makeBroadcastSetter('lastBroadcastHash');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_SURVEY_URL', [url]]], console.log)
commands.SET_SURVEY_URL = makeBroadcastSetter('surveyURL');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_MAINTENANCE', [{start: +Date, end: +Date}]]], console.log)
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_MAINTENANCE', [""]]], console.log)
commands.SET_MAINTENANCE = makeBroadcastSetter('maintenance', args_isMaintenance);
// 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) {
throw new Error("INVALID_ARGS");
}
var unsafeKey = Keys.canonicalize(args[0]);
if (!unsafeKey) {
throw new Error("INVALID_ARGS");
}
// make sure you're not overwriting an existing limit
//if (Env.customLimits[unsafeKey]) { throw new Error("EEXISTS"); }
var limit = args[1];
if (!Quota.isValidLimit(limit)) { // do we really want this?
throw new Error("INVALID_ARGS");
}
limit.origin = 'decree';
// map the new limit to the user's unsafeKey
Env.customLimits[unsafeKey] = limit;
Env.limits[unsafeKey] = limit;
return true;
};
commands.RM_QUOTA = function (Env, args) {
if (!Array.isArray(args) || args.length !== 1) {
throw new Error("INVALID_ARGS");
}
var unsafeKey = Keys.canonicalize(args[0]);
if (!unsafeKey) {
throw new Error("INVALID_ARGS");
}
if (!Env.customLimits[unsafeKey]) {
throw new Error("ENOENT");
}
delete Env.customLimits[unsafeKey];
delete Env.limits[unsafeKey];
return true;
};
commands.ADD_INSTALL_TOKEN = function (Env, args) {
if (!Array.isArray(args) || args.length !== 1 || !args[0]) {
throw new Error("INVALID_ARGS");
}
var token = args[0];
Env.installToken = token;
return true;
};
commands.ADD_ADMIN_KEY = function (Env, args) {
if (!Array.isArray(args) || args.length !== 1 || !args[0]) {
throw new Error("INVALID_ARGS");
}
Env.admins = Env.admins || [];
var key = Keys.canonicalize(args[0]);
if (!key) { throw new Error("INVALID_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;
};
commands.SET_BEARER_SECRET = function (Env, args) {
if (!args_isString(args) || args.length !== 1 || !args[0]) {
throw new Error("INVALID_ARGS");
}
var secret = args[0];
if (secret === Env.bearerSecret) { return false; }
Env.bearerSecret = secret;
return true;
};
module.exports = DecreesCore.create(DECREE_NAME, commands);

View File

@ -1,98 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
var Default = module.exports;
Default.commonCSP = function (Env) {
var domain = Env.httpUnsafeOrigin;
var sandbox = Env.httpSafeOrigin;
sandbox = (sandbox && sandbox !== domain ? sandbox : '');
// Content-Security-Policy
var accounts_api = Env.accounts_api || '';
var wsURL = domain.replace('https://', 'wss://').replace('http://', 'ws://');
return [
`default-src 'none'`,
`style-src 'unsafe-inline' 'self' ${domain}`,
`font-src 'self' data: ${domain}`,
/* child-src is used to restrict iframes to a set of allowed domains.
* connect-src is used to restrict what domains can connect to the websocket.
*
* it is recommended that you configure these fields to match the
* domain which will serve your CryptPad instance.
*/
`child-src ${domain}`,
// IE/Edge
`frame-src 'self' blob: ${sandbox}`,
/* this allows connections over secure or insecure websockets
if you are deploying to production, you'll probably want to remove
the ws://* directive
*/
`connect-src 'self' blob: ${domain} ${sandbox} ${accounts_api} ${wsURL}`,
// data: is used by codemirror
`img-src 'self' data: blob: ${domain}`,
`media-src blob:`,
// for accounts.cryptpad.fr authentication and cross-domain iframe sandbox
Env.enableEmbedding? `frame-ancestors 'self' ${Env.protocol} vector:`: `frame-ancestors 'self' ${domain}`,
`worker-src 'self'`,
""
];
};
Default.contentSecurity = function (Env) {
return (Default.commonCSP(Env).join('; ') + "script-src 'self' resource: " + Env.httpUnsafeOrigin).replace(/\s+/g, ' ');
};
Default.padContentSecurity = function (Env) {
return (Default.commonCSP(Env).join('; ') + "script-src 'self' 'unsafe-eval' 'unsafe-inline' resource: " + Env.httpUnsafeOrigin).replace(/\s+/g, ' ');
};
Default.httpHeaders = function (Env) {
return {
"X-XSS-Protection": "1; mode=block",
"X-Content-Type-Options": "nosniff",
"Access-Control-Allow-Origin": Env.enableEmbedding? '*': Env.permittedEmbedders,
"Referrer-Policy": "same-origin",
"Permissions-policy":"interest-cohort=()"
};
};
Default.mainPages = function () {
return [
'index',
'contact',
'features',
'maintenance'
];
};
/* The recommmended minimum Node.js version
* ideally managed using NVM and not your system's
* package manager, which usually provides a very outdated version
*/
Default.recommendedVersion = [16,14,2];
/* By default the CryptPad server will run scheduled tasks every five minutes
* If you want to run scheduled tasks in a separate process (like a crontab)
* you can disable this behaviour by setting the following value to true
*/
//disableIntegratedTasks: false,
/* CryptPad's file storage adaptor closes unused files after a configurable
* number of milliseconds (default 30000 (30 seconds))
*/
// channelExpirationMs: 30000,
/* CryptPad's file storage adaptor is limited by the number of open files.
* When the adaptor reaches openFileLimit, it will clean up older files
*/
//openFileLimit: 2048,

View File

@ -1,449 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const { existsSync, readdirSync } = require('node:fs');
const Crypto = require('crypto');
const WriteQueue = require("./write-queue");
const BatchRead = require("./batch-read");
const Keys = require("./keys");
const Core = require("./commands/core");
const Quota = require("./commands/quota");
const Util = require("./common-util");
const Package = require("../package.json");
const Default = require("./defaults");
const Path = require("path");
const plugins = require('./plugin-manager');
const Nacl = require("tweetnacl/nacl-fast");
var canonicalizeOrigin = function (s) {
if (typeof(s) === 'undefined') { return; }
return (s || '').trim().replace(/\/+$/, '');
};
var isValidPort = function (p) {
return typeof(p) === 'number' && p < 65535;
};
var deriveSandboxOrigin = function (unsafe, port) {
var url = new URL(unsafe);
url.port = port;
return url.origin;
};
var isRecentVersion = function () {
var R = Default.recommendedVersion;
var V = process.version;
if (typeof(V) !== 'string') { return false; }
var parts = V.replace(/^v/, '').split('.').map(Number);
if (parts.length < 3) { return false; }
if (!parts.every(n => typeof(n) === 'number' && !isNaN(n))) {
return false;
}
if (parts[0] < R[0]) { return false; }
if (parts[0] > R[0]) { return true; }
// v16
if (parts[1] < R[1]) { return false; }
if (parts[1] > R[1]) { return true; }
if (parts[2] >= R[2]) { return true; }
return false;
};
const getInstalledOOVersions = function() {
if (!existsSync('www/common/onlyoffice/dist')) {
return [];
}
return readdirSync('www/common/onlyoffice/dist');
};
module.exports.create = function (config) {
var httpUnsafeOrigin = canonicalizeOrigin(config.httpUnsafeOrigin);
var httpSafeOrigin;
var NO_SANDBOX = false;
var httpSafePort;
var httpPort = isValidPort(config.httpPort)? config.httpPort: 3000;
if (typeof(config.httpSafeOrigin) !== 'string') {
NO_SANDBOX = true;
if (typeof(config.httpSafePort) !== 'number') { httpSafePort = httpPort + 1; }
httpSafeOrigin = deriveSandboxOrigin(httpUnsafeOrigin, httpSafePort);
} else {
httpSafeOrigin = canonicalizeOrigin(config.httpSafeOrigin);
}
if (typeof(config.websocketPort) !== 'number') {
config.websocketPort = 3003;
}
var permittedEmbedders = config.permittedEmbedders;
if (typeof(permittedEmbedders) === 'string') {
permittedEmbedders = permittedEmbedders.trim();
}
const curve = Nacl.box.keyPair();
const Env = {
plugins: plugins,
logFeedback: Boolean(config.logFeedback),
mainPages: config.mainPages || Default.mainPages(),
protocol: new URL(httpUnsafeOrigin).protocol,
fileHost: config.fileHost? new URL(config.fileHost).origin : undefined,
NO_SANDBOX: NO_SANDBOX,
httpSafePort: httpSafePort,
websocketPort: config.websocketPort,
accounts_api: config.accounts_api,
shouldUpdateNode: !isRecentVersion(),
version: Package.version,
installMethod: config.installMethod || undefined,
httpUnsafeOrigin: httpUnsafeOrigin,
httpSafeOrigin: httpSafeOrigin,
permittedEmbedders: typeof(permittedEmbedders) === 'string' && permittedEmbedders? permittedEmbedders: httpSafeOrigin,
removeDonateButton: config.removeDonateButton,
httpPort: isValidPort(config.httpPort)? config.httpPort: 3000,
httpAddress: typeof(config.httpAddress) === 'string'? config.httpAddress: 'localhost',
websocketPath: config.externalWebsocketURL,
logIP: config.logIP,
OFFLINE_MODE: false,
FRESH_KEY: '',
FRESH_MODE: true,
DEV_MODE: false,
configCache: {},
broadcastCache: {},
officeHeadersCache: undefined,
standardHeadersCache: undefined,
apiHeadersCache: undefined,
flushCache: function () {
Env.FRESH_KEY = +new Date();
if (!(Env.DEV_MODE || Env.FRESH_MODE)) { Env.FRESH_MODE = true; }
Env.cacheFlushed.fire();
if (!Env.Log) { return; }
Env.Log.info("UPDATING_FRESH_KEY", Env.FRESH_KEY);
},
Log: undefined,
// store
id: Crypto.randomBytes(8).toString('hex'),
launchTime: +new Date(),
enableProfiling: false,
profilingWindow: 10000,
bytesWritten: 0,
inactiveTime: config.inactiveTime,
archiveRetentionTime: config.archiveRetentionTime,
accountRetentionTime: config.accountRetentionTime,
adminEmail: config.adminEmail,
supportMailbox: config.supportMailboxPublicKey,
supportMailboxKey: undefined,
metadata_cache: {},
channel_cache: {},
cache_checks: {},
queueStorage: WriteQueue(),
queueDeletes: WriteQueue(),
queueValidation: WriteQueue(),
queueMetadata: WriteQueue(),
batchIndexReads: BatchRead("HK_GET_INDEX"),
batchMetadata: BatchRead('GET_METADATA'),
batchRegisteredUsers: BatchRead("GET_REGISTERED_USERS"),
batchDiskUsage: BatchRead('GET_DISK_USAGE'),
batchUserPins: BatchRead('LOAD_USER_PINS'),
batchTotalSize: BatchRead('GET_TOTAL_SIZE'),
batchAccountQuery: BatchRead("QUERY_ACCOUNT_SERVER"),
intervals: {},
maxUploadSize: config.maxUploadSize || (20 * 1024 * 1024),
premiumUploadSize: false, // overridden below...
Sessions: {},
paths: {},
//msgStore: config.store,
// /api/broadcast
lastBroadcastHash: '',
surveyURL: undefined,
maintenance: undefined,
netfluxUsers: {},
pinStore: undefined,
limits: {},
admins: [],
installToken: undefined,
WARN: function (e, output) { // TODO deprecate this
if (!Env.Log) { return; }
if (e && output) {
Env.Log.warn(e, {
output: output,
message: String(e),
stack: new Error(e).stack,
});
}
},
// as of 4.14.0 you need to opt-in to remote embedding.
enableEmbedding: false,
/* FIXME restrictRegistration is initialized as false and then overridden by admin decree
There is a narrow window in which someone could register before the server updates this value.
See also the cached 'restrictRegistration' value in server.js#serveConfig
*/
restrictRegistration: false,
blockDailyCheck: config.blockDailyCheck === true,
consentToContact: false,
listMyInstance: false,
provideAggregateStatistics: false,
updateAvailable: undefined,
instanceName: {},
instanceDescription: {},
instanceJurisdiction: {},
instanceNotice: {},
customLimits: {},
// FIXME this attribute isn't in the default conf
// but it is referenced in Quota
domain: config.domain,
maxWorkers: undefined,
disableIntegratedTasks: config.disableIntegratedTasks || false,
disableIntegratedEviction: typeof(config.disableIntegratedEviction) === 'undefined'? true: config.disableIntegratedEviction,
lastEviction: +new Date(),
evictionReport: {},
commandTimers: {},
sso: plugins?.SSO?.config || {},
enforceMFA: config.enforceMFA,
...(getInstalledOOVersions().length > 0
? {
onlyOffice: {
availableVersions: getInstalledOOVersions(),
},
}
: {}),
// initialized as undefined
bearerSecret: void 0,
curvePrivate: curve.secretKey,
curvePublic: Util.encodeBase64(curve.publicKey),
selfDestructTo: {},
};
Object.keys(plugins || {}).forEach(name => {
let plugin = plugins[name];
if (!plugin.customizeEnv) { return; }
try { plugin.customizeEnv(Env); }
catch (e) {}
});
(function () {
var max = config.maxWorkers;
// if the supplied value is not a positive number, leave maxWorkers undefined
// one worker will be created for each CPU core
if (typeof(max) !== 'number' || isNaN(max) || max < 1) { return; }
Env.maxWorkers = max;
}());
(function () {
// mode can be FRESH (default), DEV, or PACKAGE
if (process.env.PACKAGE) {
// `PACKAGE=1 node server` uses the version string from package.json as the cache string
//console.log("PACKAGE MODE ENABLED");
Env.FRESH_MODE = false;
Env.DEV_MODE = false;
} else if (process.env.DEV) {
// `DEV=1 node server` will use a random cache string on every page reload
//console.log("DEV MODE ENABLED");
Env.FRESH_MODE = false;
Env.DEV_MODE = true;
} else {
// `FRESH=1 node server` will set a random cache string when the server is launched
// and use it for the process lifetime or until it is reset from the admin panel
//console.log("FRESH MODE ENABLED");
Env.FRESH_KEY = +new Date();
}
// Offline mode is mostly for development. It lets us test clientside cache and offline support
if (process.env.OFFLINE) { Env.OFFLINE_MODE = true; }
}());
Env.checkCache = function (channel) {
var f = Env.cache_checks[channel] || Util.throttle(function () {
delete Env.cache_checks[channel];
if (Env.channel_cache[channel]) { return; }
delete Env.metadata_cache[channel];
}, 30000);
f();
};
(function () {
var custom = config.customLimits;
if (!custom) { return; }
var stored = Env.customLimits;
Object.keys(custom).forEach(function (k) {
var unsafeKey = Keys.canonicalize(k);
if (!unsafeKey) {
console.log("INVALID_CUSTOM_LIMIT_ID", {
message: "A custom quota upgrade was provided via your config with an invalid identifier. It will be ignored.",
key: k,
value: custom[k],
});
return;
}
if (stored[unsafeKey]) {
console.log("INVALID_CUSTOM_LIMIT_DUPLICATED", {
message: "A duplicated custom quota upgrade was provided via your config which would have overridden an existing value. It will be ignored.",
key: k,
value: custom[k],
});
return;
}
if (!Quota.isValidLimit(custom[k])) {
console.log("INVALID_CUSTOM_LIMIT_VALUE", {
message: "A custom quota upgrade was provided via your config with an invalid value. It will be ignored.",
key: k,
value: custom[k],
});
return;
}
var limit = stored[unsafeKey] = Util.clone(custom[k]);
limit.origin = 'config';
});
}());
(function () {
var pes = config.premiumUploadSize;
if (!isNaN(pes) && pes >= Env.maxUploadSize) {
Env.premiumUploadSize = pes;
}
}());
var paths = Env.paths;
var keyOrDefaultString = function (key, def) {
return Path.resolve(typeof(config[key]) === 'string'? config[key]: def);
};
Env.incrementBytesWritten = function (n) {
if (!Env.enableProfiling) { return; }
if (!n || typeof(n) !== 'number' || n < 0) { return; }
Env.bytesWritten += n;
setTimeout(function () {
Env.bytesWritten -= n;
}, Env.profilingWindow);
};
paths.pin = keyOrDefaultString('pinPath', './pins');
paths.block = keyOrDefaultString('blockPath', './block');
paths.data = keyOrDefaultString('filePath', './datastore');
paths.staging = keyOrDefaultString('blobStagingPath', './blobstage');
paths.blob = keyOrDefaultString('blobPath', './blob');
paths.decree = keyOrDefaultString('decreePath', './data/');
paths.base = keyOrDefaultString('base', './data');
paths.archive = keyOrDefaultString('archivePath', './data/archive');
paths.task = keyOrDefaultString('taskPath', './tasks');
Env.defaultStorageLimit = typeof(config.defaultStorageLimit) === 'number' && config.defaultStorageLimit >= 0?
config.defaultStorageLimit:
Core.DEFAULT_LIMIT;
try {
Env.adminsData = (config.adminKeys || []).slice();
Env.admins = (config.adminKeys || []).map(function (k) {
try {
return Keys.canonicalize(k);
} catch (err) {
return;
}
}).filter(Boolean);
} catch (e) {
console.error("Can't parse admin keys. Please update or fix your config.js file!");
}
try {
let moderators = require('./commands/moderators').getKeysSync(Env);
Env.moderators = moderators.map(safeKey => {
return Util.unescapeKeyCharacters(safeKey);
}) || [];
} catch (e) {
console.log(e);
console.error("Can't parse support keys.");
}
Env.envUpdated = Util.mkEvent();
Env.cacheFlushed = Util.mkEvent();
return Env;
};
// don't serialize these things
const BAD = [
'Log',
'envUpdated',
'cacheFlushed',
'evictionReports',
'commandTimers',
'metadata_cache',
'channel_cache',
'cache_checks',
'intervals',
'Sessions',
'netfluxUsers',
'limits',
'customLimits',
'scheduleDecree',
'plugins',
'httpServer',
'pinStore',
'msgStore',
'store',
'blobStore',
];
module.exports.serialize = function (Env) {
return JSON.stringify(Env, function (key, value) {
if (value === Env) { return value; }
if (BAD.includes(key)) { return; }
if (typeof(value) === 'function') { return; }
//console.log('serializing', { key, value, });
if (Util.isCircular(value)) { return; }
return value;
});
};

View File

@ -1,671 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
var nThen = require("nthen");
var Bloom = require("@mcrowe/minibloom");
var Util = require("../lib/common-util");
var Linked = require("../lib/commands/linked");
var Pins = require("../lib/pins");
var Keys = require("./keys");
var Path = require('node:path');
var Fs = require("node:fs");
var getNewestTime = function (stats) {
return stats[['atime', 'ctime', 'mtime'].reduce(function (a, b) {
return stats[b] > stats[a]? b: a;
})];
};
/*
Env = {
limits: {
<unsafeKey>: <limit>,
},
archiveRetentionTime: <number of days>,
accountRetentionTime: <number of days>,
inactiveTime: <number of days>,
paths: {
pin: <path to pin storage>
},
store,
pinStore,
Log,
blobStore,
};
*/
// the number of ms artificially introduced between CPU-intensive operations
var THROTTLE_FACTOR = 10;
var PROGRESS_FACTOR = 1000;
var evictArchived = function (Env, cb) {
var Log;
var store;
var blobs;
var retentionTime = +new Date() - (Env.archiveRetentionTime * 24 * 3600 * 1000);
var report = {
// archivedChannelsRemoved,
// archivedAccountsRemoved,
// archivedBlobsRemoved,
// totalChannels,
// activeChannels,
// totalBlobs,
// activeBlobs,
// totalAccounts,
// activeAccounts,
// channelsArchived,
launchTime: +new Date(),
// runningTime,
};
var loadStorage = function () {
store = Env.store;
Log = Env.Log;
blobs = Env.blobStore;
};
var removeArchivedChannels = function (w) {
// this block will iterate over archived channels and removes them
// if they've been in cold storage for longer than your configured archive time
// if the admin has not set an 'archiveRetentionTime', this block makes no sense
// so just skip it
if (typeof(Env.archiveRetentionTime) !== "number") { return; }
// count the number of files which have been removed in this run
var removed = 0;
var accounts = 0;
var handler = function (err, item, cb) {
if (err) {
return Log.error('EVICT_ARCHIVED_CHANNEL_ITERATION', err, cb);
}
// don't mess with files that are freshly stored in cold storage
// based on ctime because that's changed when the file is moved...
if (+new Date(item.ctime) > retentionTime) {
return void cb();
}
// but if it's been stored for the configured time...
// expire it
if (Env.DRY_RUN) {
if (item.channel.length === 32) { removed++; }
else if (item.channel.length === 44) { accounts++; }
return void Log.info("EVICT_ARCHIVED_CHANNEL_DRY_RUN", item.channel, cb);
}
store.removeArchivedChannel(item.channel, w(function (err) {
if (err) {
return Log.error('EVICT_ARCHIVED_CHANNEL_REMOVAL_ERROR', {
error: err,
channel: item.channel,
}, cb);
}
if (item.channel.length === 32) {
removed++;
} else if (item.channel.length === 44) {
accounts++;
}
Log.info('EVICT_ARCHIVED_CHANNEL_REMOVAL', item.channel, cb);
}));
};
// if you hit an error, log it
// otherwise, when there are no more channels to process
// log some stats about how many were removed
var done = function (err) {
if (err) {
return Log.error('EVICT_ARCHIVED_FINAL_ERROR', err);
}
report.archivedChannelsRemoved = removed;
report.archivedAccountsRemoved = accounts;
Log.info('EVICT_ARCHIVED_CHANNELS_REMOVED', removed);
Log.info('EVICT_ARCHIVED_ACCOUNTS_REMOVED', accounts);
};
store.listArchivedChannels(handler, w(done));
};
// Blob proofs are no longer supported and can't be restored
// so we can delete them all
var removeArchivedBlobProofs = function (w) {
var archivePath = Path.join(Env.paths.archive, 'blob');
const cb = Util.once(w());
let i = 0;
nThen(w => {
Fs.readdir(archivePath, w((err, list) => {
if (err) { return; }
list.forEach(dir => {
// Look for 3 characters long folders
if (dir.length !== 3) { return; }
let path = Path.join(archivePath, dir);
Fs.rm(path, { recursive: true, force: true }, w(err => {
if (err) { return; }
i++;
}));
});
}));
}).nThen(() => {
Log.info('EVICT_ARCHIVED_BLOB_PROOFS', i);
cb();
});
};
var removeArchivedBlobs = function (w) {
if (typeof(Env.archiveRetentionTime) !== "number") { return; }
// Iterate over archived blobs and remove them
// if they are older than the specified retention time
var removed = 0;
blobs.list.archived.blobs(function (err, item, next) {
next = Util.mkAsync(next, THROTTLE_FACTOR);
if (err) {
Log.error("EVICT_BLOB_LIST_ARCHIVED_BLOBS_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_DRY_RUN", item, next);
}
blobs.remove.archived.blob(item.blobId, function (err) {
if (err) {
Log.error("EVICT_ARCHIVED_BLOB_ERROR", item);
return void next();
}
Log.info("EVICT_ARCHIVED_BLOB", item);
removed++;
next();
});
}, w(function () {
report.archivedBlobsRemoved = removed;
Log.info('EVICT_ARCHIVED_BLOBS_REMOVED', removed);
}));
};
if (Env.DRY_RUN) { Env.Log.info('DRY RUN'); }
nThen(loadStorage)
.nThen(removeArchivedChannels)
.nThen(removeArchivedBlobProofs)
.nThen(removeArchivedBlobs)
.nThen(function () {
cb(void 0, report);
});
};
module.exports = function (Env, cb) {
var complete = Util.once(Util.mkAsync(cb));
var report = {
// archivedChannelsRemoved,
// archivedAccountsRemoved,
// archivedBlobsRemoved,
// totalChannels,
// activeChannels,
// totalBlobs,
// activeBlobs,
// totalAccounts,
// activeAccounts,
// channelsArchived,
launchTime: +new Date(),
// runningTime,
};
// the administrator should have set an 'inactiveTime' in their config
// if they didn't, just exit.
if (!Env.inactiveTime || typeof(Env.inactiveTime) !== "number") {
return void complete("NO_INACTIVE_TIME");
}
// get a list of premium accounts on this instance
// pre-converted to the 'safeKey' format so we can easily compare
// them against ids we see on the filesystem
var premiumSafeKeys = Object.keys(Env.limits || {})
.map(function (id) {
return Keys.canonicalize(id);
})
.filter(Boolean)
.map(Util.escapeKeyCharacters);
// files which have not been changed since before this date can be considered inactive
var inactiveTime = +new Date() - (Env.inactiveTime * 24 * 3600 * 1000);
// files which were archived before this date can be considered safe to remove
var retentionTime = +new Date() - (Env.archiveRetentionTime * 24 * 3600 * 1000);
var store;
var pinStore;
var Log;
var blobs;
/* It's fairly easy to know if a channel or blob is active
but knowing whether it is pinned requires that we
keep the set of pinned documents in memory.
Some users will share the same set of documents in their pin lists,
so the representation of pinned documents should scale sub-linearly
with the number of users and pinned documents.
That said, sub-linear isn't great...
A Bloom filter is "a space-efficient probabilistic data structure"
which lets us check whether an item is _probably_ or _definitely not_
in a set. This is good enough for our purposes since we just want to
know whether something can safely be removed and false negatives
(not safe to remove when it actually is) are acceptable.
We set our capacity to some large number, and the error rate to whatever
we think is acceptable.
TODO make this configurable ?
*/
var BLOOM_CAPACITY = (1 << 24) - 1; // over two million items
var BLOOM_ERROR = 1 / 10000; // an error rate of one in ten thousand
// we'll use one filter for the set of active documents
var activeDocs = Bloom.optimalFilter(BLOOM_CAPACITY, BLOOM_ERROR);
// and another one for the set of pinned documents
var pinnedDocs = Bloom. optimalFilter(BLOOM_CAPACITY, BLOOM_ERROR);
var startTime = +new Date();
var msSinceStart = function () {
return (+new Date()) - startTime;
};
var loadStorage = function () {
store = Env.store;
pinStore = Env.pinStore;
Log = Env.Log;
blobs = Env.blobStore;
};
var categorizeChannelsByActivity = function (w) {
var channels = 0;
var active = 0;
var handler = function (err, item, cb) {
channels++;
if (channels % PROGRESS_FACTOR === 0) {
Log.info('EVICT_CHANNEL_CATEGORIZATION_PROGRESS', {
channels: channels,
});
}
if (err) {
return Log.error('EVICT_CHANNEL_CATEGORIZATION', err, cb);
}
// if the channel has been modified recently
// we don't use mtime because we don't want to count access to the file, just modifications
if (+new Date(item.mtime) > inactiveTime) {
// add it to the set of activeDocs
activeDocs.add(item.channel);
active++;
return void cb();
}
return void cb();
};
var done = function () {
report.activeChannels = active;
report.totalChannels = channels;
Log.info('EVICT_CHANNELS_CATEGORIZED', {
active: active,
channels: channels,
}, w());
};
Log.info('EVICT_CHANNEL_ACTIVITY_START', 'Assessing channel activity');
store.listChannels(handler, w(done));
};
var categorizeBlobsByActivity = function (w) {
var n_blobs = 0;
var active = 0;
Log.info('EVICT_BLOBS_ACTIVITY_START', 'Assessing blob activity');
blobs.list.blobs(function (err, item, next) {
next = Util.mkAsync(next, THROTTLE_FACTOR);
n_blobs++;
if (n_blobs % PROGRESS_FACTOR === 0) {
Log.info('EVICT_BLOB_CATEGORIZATION_PROGRESS', {
blobs: n_blobs,
});
}
if (err) {
return Log.error("EVICT_BLOB_CATEGORIZATION", err, next);
}
if (!item) {
return void Log.error("EVICT_BLOB_CATEGORIZATION_INVALID", item, next);
}
if (item.mtime > inactiveTime) {
activeDocs.add(item.blobId);
active++;
return void next();
}
next();
}, w(function () {
report.totalBlobs = n_blobs;
report.activeBlobs = active;
Log.info('EVICT_BLOBS_CATEGORIZED', {
active: active,
blobs: n_blobs,
}, w());
}));
};
var categorizeAccountsByActivity = function (w) {
// iterate over all accounts
var accounts = 0;
var inactive = 0;
var accountRetentionTime;
if (typeof(Env.accountRetentionTime) === 'number' && Env.accountRetentionTime > 0) {
accountRetentionTime = +new Date() - (24 * 3600 * 1000 * Env.accountRetentionTime);
} else {
accountRetentionTime = -1;
}
var pinAll = function (pinList, cb) {
let n = nThen;
pinList.forEach(function (docId) {
pinnedDocs.add(docId);
// Add linked documents
if (docId.length !== 32) { return; }
n = n(w => {
Linked.listLinkedDocuments(Env, docId, w((err, channels) => {
if (!Array.isArray(channels)) { return; }
channels.forEach(chan => { pinnedDocs.add(chan); });
}));
}).nThen;
});
n(() => { cb(); });
};
var docIsActive = function (docId) {
return activeDocs.test(docId);
};
var accountIsActive = function (mtime, pinList) {
// console.log("id [%s] in premiumSafeKeys", id, premiumSafeKeys.indexOf(id) !== -1);
// if their pin log has changed recently then consider them active
if (mtime && mtime > accountRetentionTime) {
return true;
}
// iterate over their pinned documents until you find one that has been active
return pinList.some(docIsActive);
};
var isPremiumAccount = function (id) {
return premiumSafeKeys.indexOf(id) !== -1;
};
var PRESERVE_INACTIVE_ACCOUNTS = accountRetentionTime <= 0;
// otherwise, we'll only retain data from active accounts
// so we need more heuristics
var handler = function (content, id, next) {
next = Util.mkAsync(next, THROTTLE_FACTOR);
accounts++;
if (accounts % PROGRESS_FACTOR === 0) {
Log.info('EVICT_ACCOUNT_CATEGORIZATION_PROGRESS', {
accounts: accounts,
});
}
var mtime = content.latest;
var pinList = Object.keys(content.pins);
if (accountIsActive(mtime, pinList)) {
// add active accounts' pinned documents to a second bloom filter
return pinAll(pinList, next);
}
// Otherwise they are inactive.
// We keep track of how many accounts are inactive whether or not
// we plan to delete them, because it may be interesting information
inactive++;
if (PRESERVE_INACTIVE_ACCOUNTS) {
return pinAll(pinList, () => {
Log.info('EVICT_INACTIVE_ACCOUNT_PRESERVED', {
id: id,
mtime: mtime,
}, next);
});
}
if (isPremiumAccount(id)) {
return pinAll(pinList, () => {
Log.info("EVICT_INACTIVE_PREMIUM_ACCOUNT", {
id: id,
mtime: mtime,
}, next);
});
}
// remove the pin logs of inactive accounts if inactive account removal is configured
if (Env.DRY_RUN) {
return void Log.info("EVICT_INACTIVE_ACCOUNT_DRY_RUN", id, next);
}
pinStore.archiveChannel(id, undefined, function (err) {
if (err) {
return Log.error('EVICT_INACTIVE_ACCOUNT_PIN_LOG', err, next);
}
Log.info('EVICT_INACTIVE_ACCOUNT_LOG', id, next);
});
};
var done = function () {
var label = PRESERVE_INACTIVE_ACCOUNTS?
"EVICT_COUNT_ACCOUNTS":
"EVICT_INACTIVE_ACCOUNTS";
report.totalAccounts = accounts;
report.activeAccounts = accounts - inactive;
Log.info(label, {
accounts: accounts,
inactive: inactive,
});
};
Log.info('EVICT_ACCOUNTS_ACTIVITY_START', 'Assessing account activity');
Pins.load(w(done), {
pinPath: Env.paths.pin,
handler: handler,
});
};
var archiveInactiveBlobs = function (w) {
// iterate over blobs and remove them
// if they have not been accessed within the specified retention time
var removed = 0;
var total = 0;
Log.info('EVICT_BLOB_START', {});
blobs.list.blobs(function (err, item, next) {
next = Util.mkAsync(next, THROTTLE_FACTOR);
if (err) {
return Log.error("EVICT_BLOB_LIST_BLOBS_ERROR", err, next);
}
if (!item) {
return void Log.error('EVICT_BLOB_LIST_BLOBS_NO_ITEM', item, next);
}
total++;
if (total % PROGRESS_FACTOR === 0) {
Log.info("EVICT_BLOB_PROGRESS", {
blobs: total,
});
}
if (pinnedDocs.test(item.blobId)) { return void next(); }
if (activeDocs.test(item.blobId)) { return void next(); }
// 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();
next();
}
// 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);
}
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());
}), true);
};
var archiveInactiveChannels = function (w) {
var channels = 0;
var archived = 0;
var handler = function (err, item, cb) {
cb = Util.mkAsync(cb, THROTTLE_FACTOR);
channels++;
if (channels % PROGRESS_FACTOR === 0) {
Log.info('EVICT_INACTIVE_CHANNELS_PROGRESS', {
channels,
archived,
});
}
if (err) {
return Log.error('EVICT_CHANNEL_ITERATION', err, cb);
}
// ignore the special admin broadcast channel
if (item.channel.length === 33) { return void cb(); }
// check if the database has any ephemeral channels
// if it does it's because of a bug, and they should be removed
if (item.channel.length === 34) {
if (Env.DRY_RUN) {
return void Log.info("EVICT_EPHEMERAL_DRY_RUN", item.channel, cb);
}
return void store.removeChannel(item.channel, w(function (err) {
if (err) {
return Log.error('EVICT_EPHEMERAL_CHANNEL_REMOVAL_ERROR', {
error: err,
channel: item.channel,
}, cb);
}
Log.info('EVICT_EPHEMERAL_CHANNEL_REMOVAL', item.channel, cb);
}));
}
// bail out if the channel is in the set of activeDocs
if (activeDocs.test(item.channel)) { return void cb(); }
// ignore the channel if it's pinned
if (pinnedDocs.test(item.channel)) { return void cb(); }
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
store.getChannelStats(item.channel, 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 (w) {
if (Env.DRY_RUN) {
archived++;
w.abort();
return void Log.info("EVICT_CHANNEL_ARCHIVAL_DRY_RUN", item.channel, cb);
}
return void store.archiveChannel(item.channel, 'INACTIVE', w(function (err) {
if (err) {
Log.error('EVICT_CHANNEL_ARCHIVAL_ERROR', {
error: err,
channel: item.channel,
}, w());
return;
}
archived++;
Log.info('EVICT_CHANNEL_ARCHIVAL', item.channel, w());
}));
}).nThen(cb);
};
var done = function () {
report.channelsArchived = archived;
return void Log.info('EVICT_CHANNELS_ARCHIVED', {
channels,
archived,
});
};
Log.info('EVICT_INACTIVE_CHANNELS_START', {});
store.listChannels(handler, w(done), true); // using a hacky "fast mode" since we only need the channel id
};
if (Env.DRY_RUN) { Env.Log.info('DRY RUN'); }
nThen(loadStorage)
// iterate over all documents and add them to a bloom filter if they have been active
.nThen(categorizeChannelsByActivity)
.nThen(categorizeBlobsByActivity)
// iterate over all accounts and add them to a bloom filter if they are active
.nThen(categorizeAccountsByActivity)
// iterate again and archive inactive unpinned documents
// (documents which are not in either bloom filter)
.nThen(archiveInactiveBlobs)
.nThen(archiveInactiveChannels)
.nThen(function () {
var runningTime = report.runningTime = msSinceStart();
Log.info("EVICT_TIME_TO_RUN_SCRIPT", runningTime);
}).nThen(function () {
complete(void 0, report);
});
};
module.exports.archived = evictArchived;

View File

@ -1,249 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const nThen = require('nthen');
const RPC = require("./rpc");
const HK = require("./hk-util.js");
const Store = require("./storage/file");
const BlobStore = require("./storage/blob");
const Workers = require("./workers/index");
const Core = require("./commands/core");
module.exports.create = function (Env, cb) {
const Log = Env.Log;
Log.silly('HK_LOADING', 'LOADING HISTORY_KEEPER MODULE');
Env.historyKeeper = {
metadata_cache: Env.metadata_cache,
channel_cache: Env.channel_cache,
id: Env.id,
channelMessage: function (Server, channel, msgStruct, cb) {
// netflux-server emits 'channelMessage' events whenever someone broadcasts to a channel
// historyKeeper stores these messages if the channel id indicates that they are
// a channel type with permanent history
HK.onChannelMessage(Env, Server, channel, msgStruct, cb);
},
channelClose: function (channelName) {
// netflux-server emits 'channelClose' events whenever everyone leaves a channel
// we drop cached metadata and indexes at the same time
HK.dropChannel(Env, channelName);
},
channelOpen: function (Server, channelName, userId, wait) {
Env.channel_cache[channelName] = Env.channel_cache[channelName] || {};
var sendHKJoinMessage = function () {
Server.send(userId, [
0,
Env.id,
'JOIN',
channelName
]);
};
// a little backwards compatibility in case you don't have the latest server
// allow lists won't work unless you update, though
if (typeof(wait) !== 'function') { return void sendHKJoinMessage(); }
var next = wait();
var cb = function (err, info) {
next(err, info, sendHKJoinMessage);
};
// only conventional channels can be restricted
if ((channelName || "").length !== HK.STANDARD_CHANNEL_LENGTH) {
return void cb();
}
// gets and caches the metadata...
HK.getMetadata(Env, channelName, function (err, metadata) {
if (err) {
Log.error('HK_METADATA_ERR', {
channel: channelName,
error: err,
});
}
if (metadata && metadata.selfdestruct && metadata.selfdestruct !== Env.id) {
HK.removeChannel(Env, channelName);
return void cb('ESELFDESTRUCT');
}
if (Env.selfDestructTo && Env.selfDestructTo[channelName]) {
clearTimeout(Env.selfDestructTo[channelName]);
}
if (!metadata || (metadata && !metadata.restricted)) {
// the channel doesn't have metadata, or it does and it's not restricted
// either way, let them join.
return void cb();
}
// this channel is restricted. verify that the user in question is in the allow list
// construct a definitive list (owners + allowed)
var allowed = HK.listAllowedUsers(metadata);
// and get the list of keys for which this user has already authenticated
var session = HK.getNetfluxSession(Env, userId);
if (HK.isUserSessionAllowed(allowed, session)) {
return void cb();
}
// If the channel is restricted, send the history keeper ID so that they
// can try to authenticate
allowed.unshift(Env.id);
// otherwise they're not allowed.
// respond with a special error that includes the list of keys
// which would be allowed...
// FIXME RESTRICT bonus points if you hash the keys to limit data exposure
cb("ERESTRICTED", allowed);
});
},
sessionClose: function (userId, reason) {
HK.closeNetfluxSession(Env, userId);
if (Env.logIP && !['SOCKET_CLOSED', 'INACTIVITY'].includes(reason)) {
return void Log.info('USER_DISCONNECTED_ERROR', {
userId: userId,
reason: reason
});
}
if (['BAD_MESSAGE', 'SEND_MESSAGE_FAIL_2'].indexOf(reason) !== -1) {
if (reason && reason.code === 'ECONNRESET') { return; }
return void Log.error('SESSION_CLOSE_WITH_ERROR', {
userId: userId,
reason: reason,
});
}
if (['SOCKET_CLOSED', 'SOCKET_ERROR'].includes(reason)) { return; }
Log.verbose('SESSION_CLOSE_ROUTINE', {
userId: userId,
reason: reason,
});
},
sessionOpen: function (userId, ip) {
if (!Env.logIP) { return; }
Log.info('USER_CONNECTION', {
userId: userId,
ip: ip,
});
},
directMessage: function (Server, seq, userId, json) {
// netflux-server allows you to register an id with a handler
// this handler is invoked every time someone sends a message to that id
HK.onDirectMessage(Env, Server, seq, userId, json);
},
};
Log.verbose('HK_ID', 'History keeper ID: ' + Env.id);
var pinPath = Env.paths.pin;
nThen(function (w) {
// create a pin store
Store.create({
filePath: pinPath,
archivePath: Env.paths.archive,
// indicate that archives should be put in a 'pins' archvie folder
volumeId: 'pins',
}, w(function (err, s) {
if (err) { throw err; }
Env.pinStore = s;
}));
// create a channel store
Store.create({
filePath: Env.paths.data,
archivePath: Env.paths.archive,
}, w(function (err, _store) {
if (err) { throw err; }
Env.msgStore = _store; // API used by rpc
Env.store = _store; // API used by historyKeeper
}));
// create a blob store
BlobStore.create({
blobPath: Env.paths.blob,
blobStagingPath: Env.paths.staging,
archivePath: Env.paths.archive,
getSession: function (safeKey) {
return Core.getSession(Env.Sessions, safeKey);
},
}, w(function (err, blob) {
if (err) { throw new Error(err); }
Env.blobStore = blob;
}));
}).nThen(function (w) {
Workers.initialize(Env, {
blobPath: Env.paths.blob,
blobStagingPath: Env.paths.staging,
taskPath: Env.paths.task,
pinPath: Env.paths.pin,
filePath: Env.paths.data,
archivePath: Env.paths.archive,
blockPath: Env.paths.block,
inactiveTime: Env.inactiveTime,
archiveRetentionTime: Env.archiveRetentionTime,
accountRetentionTime: Env.accountRetentionTime,
maxWorkers: Env.maxWorkers,
}, w(function (err) {
if (err) {
throw new Error(err);
}
}));
}).nThen(function () {
var tasks_running;
Env.intervals.taskExpiration = setInterval(function () {
if (Env.disableIntegratedTasks) { return; }
if (tasks_running) { return; }
tasks_running = true;
Env.runTasks(function (err) {
if (err) {
Log.error('TASK_RUNNER_ERR', err);
}
tasks_running = false;
});
}, 1000 * 60 * 5); // run every five minutes
}).nThen(function () {
const ONE_DAY = 24 * 1000 * 60 * 60;
// setting the time of the last eviction to "now"
// effectively makes it so that we'll start evicting after the server
// has been up for at least one day
var active = false;
Env.intervals.eviction = setInterval(function () {
if (Env.disableIntegratedEviction) { return; }
if (active) { return; }
var now = +new Date();
// evict inactive data once per day
if ((now - ONE_DAY) < Env.lastEviction) { return; }
active = true;
Env.evictInactive(function (err, report) {
if (err) {
// NO_INACTIVE_TIME
Log.error('EVICT_INACTIVE_MAIN_ERROR', err);
}
active = false;
Env.lastEviction = now;
if (report) {
Log.info('EVICT_INACTIVE_REPORT', report);
}
Env.evictionReport = report || {};
});
}, 60 * 1000);
}).nThen(function () {
RPC.create(Env, function (err, _rpc) {
if (err) { throw err; }
Env.rpc = _rpc;
cb(void 0, Env.historyKeeper);
});
});
};

File diff suppressed because it is too large Load Diff

View File

@ -1,349 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
var Nacl = require("tweetnacl/nacl-fast");
var Util = require('./common-util.js');
const plugins = require("./plugin-manager");
var Challenge = require("./storage/challenge.js");
// C.read(Env, id, cb)
// C.write(Env,id, data, cb)
// C.delete(Env, id, cb)
/*
The API for command definition consists of two stages:
Clients first send a command and its associated parameters.
The server validates that the command is supported, and that
the provided parameters are valid. If it fails validation for any reason,
the server responds with an error and the protocol is aborted.
COMMANDS[COMMAND_NAME] = function (Env, body, cb) {
// inspect parameters in the request body
if (!body.essential_parameter) {
return void cb('NO');
}
cb();
};
Commands whose parameters are successfully validated
have those parameters stored on the disk (or a relational DB in the future).
The server then requests that the client sign their well-formulated
command along with a server-generated transaction id ('txid': randomized to prevent replays)
and a date (so that it can ensure that the client responds within a reasonable window.
Clients then respond with a txid and a cryptographic signature
which matches the parameters of the command. The server loads the command
with the corresponding txid, checks that it was signed within a reasonable time window,
validates the signature, and attempts to complete the command's execution:
COMMAND[COMMAND_NAME].complete = function (Env, body, cb) {
doAThing(function (err, values) {
if (err) {
// Log the error and respond that the command was not successful
return void cb("SORRY_BUT_IM_NOT_OK");
}
cb(void 0, {
arbitrary: values,
});
});
};
In this second stage the protocol can be aborted if the client has done something wrong:
(ie. if it did not produce a valid signature for the command)
or it can can fail because the server was not able to complete the requested task
(ie. because of an I/O error or because an error was thrown and caught).
It is intended that the server will respond with an appropriate error if
the request cannot be completed, and it will respond OK if everything completed successfully.
*/
var COMMANDS = {};
// Methods allowing clients to configure Time-based One-Time Passwords for their login-block,
// and to authenticate new sessions once a TOTP secret has been associated with their account,
const NOAUTH = require("./challenge-commands/base.js");
COMMANDS.MFA_CHECK = NOAUTH.MFA_CHECK;
COMMANDS.WRITE_BLOCK = NOAUTH.WRITE_BLOCK; // Account creation + password change
COMMANDS.REMOVE_BLOCK = NOAUTH.REMOVE_BLOCK;
COMMANDS.UPLOAD_COOKIE = NOAUTH.UPLOAD_COOKIE;
const TOTP = require("./challenge-commands/totp.js");
COMMANDS.TOTP_SETUP = TOTP.TOTP_SETUP;
COMMANDS.TOTP_VALIDATE = TOTP.TOTP_VALIDATE;
COMMANDS.TOTP_MFA_CHECK = TOTP.TOTP_MFA_CHECK;
COMMANDS.TOTP_REVOKE = TOTP.TOTP_REVOKE;
COMMANDS.TOTP_WRITE_BLOCK = TOTP.TOTP_WRITE_BLOCK; // Password change only for now (v5.5.0)
COMMANDS.TOTP_REMOVE_BLOCK = TOTP.TOTP_REMOVE_BLOCK;
// Load challenges added by plugins
Object.keys(plugins || {}).forEach(id => {
try {
let plugin = plugins[id];
if (!plugin.challenge) { return; }
let commands = plugin.challenge;
Object.keys(commands).forEach(cmd => {
if (COMMANDS[cmd]) { return; } // Don't overwrite
COMMANDS[cmd] = commands[cmd];
});
} catch (e) {}
});
/*
const SSO = plugins.SSO && plugins.SSO.challenge;
COMMANDS.SSO_AUTH = SSO.SSO_AUTH;
COMMANDS.SSO_AUTH_CB = SSO.SSO_AUTH_CB;
COMMANDS.SSO_WRITE_BLOCK = SSO.SSO_WRITE_BLOCK; // Account creation only
COMMANDS.SSO_UPDATE_BLOCK = SSO.SSO_UPDATE_BLOCK; // Password change
COMMANDS.SSO_VALIDATE = SSO.SSO_VALIDATE;
*/
var randomToken = () => Util.encodeBase64(Nacl.randomBytes(24)).replace(/\//g, '-');
// this function handles the first stage of the protocol
// (the server's validation of the client's request and the generation of its challenge)
var handleCommand = function (Env, req, res) {
var body = req.body;
var command = body.command;
// reject if the command does not have a corresponding function
if (typeof(COMMANDS[command]) !== 'function') {
Env.Log.error('CHALLENGE_UNSUPPORTED_COMMAND', command);
return void res.status(500).json({
error: 'invalid command',
});
}
var publicKey = body.publicKey;
// reject if they did not provide a valid public key
if (!publicKey || typeof(publicKey) !== 'string' || publicKey.length !== 44) {
Env.Log.error('CHALLENGE_INVALID_KEY', publicKey);
return void res.status(500).json({
error: 'Invalid key',
});
}
try {
COMMANDS[command](Env, body, function (err) {
if (err) {
Env.Log.error('CHALLENGE_COMMAND_EXECUTION_ERROR', {
command,
error: Util.serializeError(err),
});
// errors returned from commands are passed back to the client
// as a weak precaution, we try to only send an error's message
// if one exists. This makes it less likely that we'll respond with any
// sensitive information in a stack trace. Ideally functions should
// only return error messages or codes in the form of a string or number,
// but mistakes happen.
return void res.status(500).json({
error: (err && err.message) || err,
});
}
var txid = randomToken();
var date = new Date().toISOString();
var copy = Util.clone(body);
copy.txid = txid;
copy.date = date;
// Write the command and challenge to disk, because the challenge protocol
// is interactive and the subsequent response might be handled by a different http worker
// this makes it so we can avoid holding state in memory
Challenge.write(Env, txid, JSON.stringify(copy), function (err) {
if (err) {
Env.Log.error('CHALLENGE_WRITE_ERROR', Util.serializeError(err));
return void res.status(500).json({
// arbitrary error message, only intended for debugging
error: 'Internal server error 6250',
});
}
// respond with challenge parameters
return void res.status(200).json({
txid: txid,
date: date,
});
});
}, req);
} catch (err) {
Env.Log.error("CHALLENGE_COMMAND_THROWN_ERROR", {
error: Util.serializeError(err),
});
return void res.status(500).json({
// arbitrary error message, only intended for debugging
error: 'Internal server error 7692',
});
}
};
// this function handles the second stage of the protocol
// (the client's response to the server's challenge)
var handleResponse = function (Env, req, res) {
var body = req.body;
if (Object.keys(body).some(k => !/(sig|txid)/.test(k))) {
Env.Log.error("CHALLENGE_RESPONSE_DEBUGGING", body);
// we expect the response to only have two keys
// if any more are present then the response is malformed
return void res.status(500).json({
error: 'extraneous parameters',
});
}
// transaction ids are issued to the client by the server
// they allow it to recall the full details of the challenge
// to which the client is responding
var txid = body.txid;
// if no txid is present, then the server can't look up the corresponding challenge
// the response is definitely malformed, so reject it.
// Additionally, we expect txids to be 32 characters long (24 Uint8s as base64)
// reject txids of any other length
if (!txid || typeof(txid) !== 'string' || txid.length !== 32) {
Env.Log.error('CHALLENGE_RESPONSE_BAD_TXID', body);
return void res.status(500).json({
error: "Invalid txid",
});
}
var sig = body.sig;
if (!sig || typeof(sig) !== 'string' || sig.length !== 88) {
Env.Log.error("CHALLENGE_RESPONSE_BAD_SIG", body);
return void res.status(500).json({
error: "Missing signature",
});
}
Challenge.read(Env, txid, function (err, text) {
if (err) {
Env.Log.error("CHALLENGE_READ_ERROR", {
txid: txid,
error: Util.serializeError(err),
});
return void res.status(500).json({
error: "Unexpected response",
});
}
var json = Util.tryParse(text);
if (!json) {
Env.Log.error("CHALLENGE_PARSE_ERROR", {
txid: txid,
});
return void res.status(500).json({
error: "Internal server error 129",
});
}
var publicKey = json.publicKey;
if (!publicKey || typeof(publicKey) !== 'string') {
// This shouldn't happen, as we expect that the server
// will have validated the key to an extent before storing the challenge
Env.Log.error('CHALLENGE_INVALID_PUBLICKEY', {
publicKey: publicKey,
});
return res.status(500).json({
error: "Invalid public key",
});
}
var action;
try {
action = COMMANDS[json.command].complete;
} catch (err2) {}
if (typeof(action) !== 'function') {
Env.Log.error("CHALLENGE_RESPONSE_ACTION_NOT_IMPLEMENTED", json.command);
return res.status(501).json({
error: 'Not implemented',
});
}
var u8_toVerify,
u8_sig,
u8_publicKey;
try {
u8_toVerify = Util.decodeUTF8(text);
u8_sig = Util.decodeBase64(sig);
u8_publicKey = Util.decodeBase64(publicKey);
} catch (err3) {
Env.Log.error('CHALLENGE_RESPONSE_DECODING_ERROR', {
command: json.command,
publicKey: publicKey,
error: Util.serializeError(err3),
});
return res.status(500).json({
error: "decoding error"
});
}
// validate the response
var success = Nacl.sign.detached.verify(u8_toVerify, u8_sig, u8_publicKey);
if (success !== true) {
Env.Log.error("CHALLENGE_RESPONSE_SIGNATURE_FAILURE", {
publicKey,
});
return void res.status(500).json({
error: 'Failed signature validation',
});
}
// garbage collection can clean this up later
Challenge.delete(Env, txid, function (err) {
if (err) {
Env.Log.error("CHALLENGE_DELETION_ERROR", {
txid: txid,
error: Util.serializeError(err),
});
}
});
// execute the command
action(Env, json, function (err, content) {
if (err) {
Env.Log.error("CHALLENGE_RESPONSE_ACTION_ERROR", {
error: Util.serializeError(err),
});
return res.status(500).json({
error: 'Execution error',
errorCode: Util.serializeError(err)
});
}
res.status(200).json(content);
}, req, res);
});
};
module.exports.handle = function (Env, req, res /*, next */) {
var body = req.body;
// we expect that the client has posted some JSON data
if (!body) {
return void res.status(500).json({
error: 'invalid request',
});
}
// we only expect responses to challenges to have a 'txid' attribute
// further validation is performed in handleResponse
if (body.txid) {
return void handleResponse(Env, req, res);
}
// we only expect initial requests to have a 'command' attribute
// further validation is performed in handleCommand
if (body.command) {
return void handleCommand(Env, req, res);
}
// if a request is neither a command nor a response, then reject it with an error
res.status(500).json({
error: 'invalid request',
});
};

View File

@ -1,906 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const process = require("node:process");
const Http = require("node:http");
const Default = require("./defaults");
const Path = require("node:path");
const Fs = require("node:fs");
const nThen = require("nthen");
const Util = require("./common-util");
const Logger = require("./log");
const AuthCommands = require("./http-commands");
const MFA = require("./storage/mfa");
const Sessions = require("./storage/sessions");
const cookieParser = require("cookie-parser");
const bodyParser = require('body-parser');
const BlobStore = require("./storage/blob");
const BlockStore = require("./storage/block");
const plugins = require("./plugin-manager");
const gzipStatic = require('connect-gzip-static');
const CPCrypto = require('./crypto');
const DEFAULT_QUERY_TIMEOUT = 5000;
const PID = process.pid;
let SSOUtils = plugins.SSO && plugins.SSO.utils;
var Env = JSON.parse(process.env.Env);
let blobStore;
let cpcrypto;
Env.plugins = plugins;
const response = Util.response(function (errLabel, info) {
if (!Env.Log) { return; }
Env.Log.error(errLabel, info);
});
const guid = () => {
return Util.guid(response._pending);
};
const sendMessage = Env.sendMessage = (msg, cb, opt) => {
var txid = guid();
var timeout = (opt && opt.timeout) || DEFAULT_QUERY_TIMEOUT;
var obj = {
pid: PID,
txid: txid,
content: msg,
};
response.expect(txid, cb, timeout);
process.send(obj);
};
const Log = {};
Logger.levels.forEach(level => {
Log[level] = function (tag, info) {
sendMessage({
command: 'LOG',
level: level,
tag: tag,
info: info,
}, (err) => {
if (err) {
return void console.error(new Error(err));
}
});
};
});
Env.Log = Log;
Env.incrementBytesWritten = function () {};
const EVENTS = {};
EVENTS.ENV_UPDATE = function (data /*, cb */) {
try {
Env = JSON.parse(data);
Env.blobStore = blobStore;
Env.Log = Log;
Env.plugins = plugins;
Env.sendMessage = sendMessage;
Env.incrementBytesWritten = function () {};
} catch (err) {
Log.error('HTTP_WORKER_ENV_UPDATE', Util.serializeError(err));
}
};
EVENTS.FLUSH_CACHE = function (data) {
if (typeof(data) !== 'number') {
return Log.error('INVALID_FRESH_KEY', data);
}
Env.FRESH_KEY = data;
[ 'configCache', 'broadcastCache', ].forEach(key => {
Env[key] = {};
});
[ 'officeHeadersCache', 'standardHeadersCache', 'apiHeadersCache', ].forEach(key => {
Env[key] = undefined;
});
};
Object.keys(plugins || {}).forEach(name => {
let plugin = plugins[name];
if (!plugin.addHttpEvents) { return; }
try {
let events = plugin.addHttpEvents(Env);
Object.keys(events || {}).forEach(cmd => {
// Uppercase event name?
if (cmd !== cmd.toUpperCase()) { return; }
// Event is a function?
if (typeof(events[cmd]) !== "function") { return; }
// Event doesn't already exists?
if (EVENTS[cmd]) { return; }
EVENTS[cmd] = events[cmd];
});
} catch (e) {}
});
process.on('message', msg => {
if (!(msg && msg.txid)) { return; }
if (msg.type === 'REPLY') {
var txid = msg.txid;
return void response.handle(txid, [msg.error, msg.value]);
} else if (msg.type === 'EVENT') {
// response to event...
// ie. Update Env, flush cache, etc.
var ev = EVENTS[msg.command];
if (typeof(ev) === 'function') {
return void ev(msg.data, () => {});
}
}
//console.error("UNHANDLED_MESSAGE", msg);
});
var applyHeaderMap = function (res, map) {
for (let header in map) {
if (typeof(map[header]) === 'string') { res.setHeader(header, map[header]); }
}
};
var EXEMPT = [
/^\/common\/onlyoffice\/.*\.html.*/,
/^\/common\/onlyoffice\/dist\/.*\/sdkjs\/common\/spell\/spell\/spell.js.*/, // OnlyOffice loads spell.wasm in a way that needs unsave-eval
/^\/(sheet|presentation|doc)\/inner\.html.*/,
/^\/unsafeiframe\/inner\.html.*$/,
];
var cacheHeaders = function (Env, key, headers) {
if (Env.DEV_MODE) { return; }
Env[key] = headers;
};
var getHeaders = function (Env, type) {
var key = type + 'HeadersCache';
if (Env[key]) { return Util.clone(Env[key]); }
var headers = Default.httpHeaders(Env);
var csp;
if (type === 'office') {
csp = Default.padContentSecurity(Env);
} else {
csp = Default.contentSecurity(Env);
}
headers['Content-Security-Policy'] = csp;
headers["Cross-Origin-Resource-Policy"] = 'cross-origin';
headers["Cross-Origin-Embedder-Policy"] = 'require-corp';
cacheHeaders(Env, key, headers);
// Don't set CSP headers on /api/ endpoints
// because they aren't necessary and they cause problems
// when duplicated by NGINX in production environments
if (type === 'api') { delete headers['Content-Security-Policy']; }
return Util.clone(headers);
};
var setHeaders = function (req, res) {
var type;
if (EXEMPT.some(regex => regex.test(req.url))) {
type = 'office';
} else if (/^\/api\/(broadcast|config)/.test(req.url)) {
type = 'api';
} else {
type = 'standard';
}
var h = getHeaders(Env, type);
// Allow main domain to load resources from the sandbox URL
if (!Env.enableEmbedding && req.get('origin') === Env.httpUnsafeOrigin &&
/^\/common\/onlyoffice\/dist\/.*\/fonts\/.*/.test(req.url)) {
h['Access-Control-Allow-Origin'] = Env.httpUnsafeOrigin;
}
applyHeaderMap(res, h);
};
const Express = require("express");
Express.static.mime.define({'application/wasm': ['wasm']});
var app = Express();
app.use(bodyParser.urlencoded({
extended: true
}));
app.use(cookieParser());
(function () {
if (!Env.logFeedback) { return; }
const logFeedback = function (url) {
url.replace(/\?(.*?)=/, function (all, fb) {
Log.feedback(fb, '');
});
};
app.head(/^\/common\/feedback\.html/, function (req, res, next) {
logFeedback(req.url);
next();
});
}());
const { createProxyMiddleware } = require("http-proxy-middleware");
var httpAddress = Env.httpAddress === '::' ? 'localhost' : Env.httpAddress;
var proxyTarget = new URL('', `ws:${httpAddress}`);
proxyTarget.port = Env.websocketPort;
const wsProxy = createProxyMiddleware({
target: proxyTarget.href,
ws: true,
logLevel: 'error',
onProxyReqWs: function (proxyReq, req) {
proxyReq.setHeader('X-Real-Ip', req.socket.remoteAddress);
},
logProvider: (p) => {
p.error = (data) => {
if (/ECONNRESET/.test(data)) { return; }
Env.Log.error('HTTP_PROXY_MIDDLEWARE', data);
};
return p;
}
});
app.use('/cryptpad_websocket', wsProxy);
app.use('/ssoauth', (req, res, next) => {
if (SSOUtils && req && req.body && req.body.SAMLResponse) {
req.method = 'GET';
let token = Util.uid();
let smres = req.body.SAMLResponse;
return SSOUtils.writeRequest(Env, {
id: token,
type: 'saml',
content: smres
}, (err) => {
if (err) {
Log.error('E_SSO_WRITE_REQ', err);
return res.sendStatus(500);
}
let value = `samltoken="${token}"; SameSite=Strict; HttpOnly; Path=/; Secure`;
res.setHeader('Set-Cookie', value);
next();
});
}
next();
});
app.use('/blob', function (req, res, next) {
/* Head requests are used to check the size of a blob.
Clients can configure a maximum size to download automatically,
and can manually click to download blobs which exceed that limit. */
const url = req.url;
if (typeof(url) === "string" && Env.blobStore) {
const s = url.split('/');
if (s[1] && s[1].length === 2 && s[2] && s[2].length === Env.blobStore.BLOB_LENGTH) {
Env.blobStore.updateActivity(s[2], () => {});
}
}
if (req.method === 'HEAD') {
Express.static(Path.resolve(Env.paths.blob), {
setHeaders: function (res /*, path, stat */) {
res.set('Access-Control-Allow-Origin', Env.enableEmbedding? '*': Env.permittedEmbedders);
res.set('Access-Control-Allow-Headers', 'Content-Length');
res.set('Access-Control-Expose-Headers', 'Content-Length');
}
})(req, res, next);
return;
}
/* Some GET requests concern the whole file,
others only target ranges, either:
1. a two octet prefix which encodes the length of the metadata in octets
2. the metadata itself, excluding the two preceding octets
*/
/*
// Example code to demonstrate the types of requests which are handled
if (req.method === 'GET') {
if (!req.headers.range) {
// metadata
} else {
// full request
}
}
*/
next();
});
app.use(function (req, res, next) {
/* These are pre-flight requests, through which the client
confirms with the server that it is permitted to make the
actual requests which will follow */
if (req.method === 'OPTIONS' && /\/blob\//.test(req.url)) {
res.setHeader('Access-Control-Allow-Origin', Env.enableEmbedding? '*': Env.permittedEmbedders);
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Access-Control-Allow-Origin');
res.setHeader('Access-Control-Max-Age', 1728000);
res.setHeader('Content-Type', 'application/octet-stream; charset=utf-8');
res.setHeader('Content-Length', 0);
res.statusCode = 204;
return void res.end();
}
setHeaders(req, res);
if (/[\?\&]ver=[^\/]+$/.test(req.url)) { res.setHeader("Cache-Control", "max-age=31536000"); }
else { res.setHeader("Cache-Control", "no-cache"); }
next();
});
Object.keys(plugins || {}).forEach(name => {
let plugin = plugins[name];
if (!plugin.addHttpEndpoints) { return; }
plugin.addHttpEndpoints(Env, app);
});
// serve custom app content from the customize directory
// useful for testing pages customized with opengraph data
app.use(Express.static(Path.resolve('./customize/www')));
app.use(gzipStatic(Path.resolve('./www')));
app.use("/common", Express.static('./src/common'));
var mainPages = Env.mainPages || Default.mainPages();
var mainPagePattern = new RegExp('^\/(' + mainPages.join('|') + ').html$');
app.get(mainPagePattern, Express.static('./customize'));
app.get(mainPagePattern, Express.static('./customize.dist'));
app.use("/blob", Express.static(Path.resolve(Env.paths.blob), {
maxAge: Env.DEV_MODE? "0d": "365d"
}));
app.use("/datastore",
(req, res, next) => {
if (req.method === 'HEAD') {
next();
} else {
res.status(403).end();
}
},
Express.static(Env.paths.data, {
maxAge: "0d"
}
));
app.use('/block/', function (req, res, next) {
var parsed = Path.parse(req.url);
var name = parsed.name;
// block access control only applies to files
// identified by base64-encoded public keys
// skip everything else, ie. /block/placeholder.txt
if (/placeholder\.txt(\?.+)?/.test(parsed.base)) {
return void next();
}
if (typeof(name) !== 'string' || name.length !== 44) {
return void res.status(404).json({
error: "INVALID_ID",
});
}
var authorization = req.headers.authorization;
var mfa_params, sso_params;
nThen(function (w) {
// First, check whether the block id in question has any MFA settings stored
MFA.read(Env, name, w(function (err, content) {
// ENOENT means there are no settings configured
// it could be a 404 or an existing block without MFA protection
// in either case you can abort and fall through
// allowing the static webserver to handle either case
if (err && err.code === 'ENOENT') {
return;
}
// we're not expecting other errors. the sensible thing is to fail
// closed - meaning assume some protection is in place but that
// the settings couldn't be loaded for some reason. block access
// to the resource, logging for the admin and responding to the client
// with a vague error code
if (err) {
Log.error('GET_BLOCK_METADATA', err);
return void res.status(500).json({
code: 500,
error: "UNEXPECTED_ERROR",
});
}
// Otherwise, some settings were loaded correctly.
// We're expecting stringified JSON, so try to parse it.
// Log and respond with an error again if this fails.
// If it parses successfully then fall through to the next block.
try {
mfa_params = JSON.parse(content);
} catch (err2) {
w.abort();
Log.error("INVALID_BLOCK_METADATA", err2);
return res.status(500).json({
code: 500,
error: "UNEXPECTED_ERROR",
});
}
}));
// Same for SSO settings
if (!SSOUtils) { return; }
SSOUtils.readBlock(Env, name, w(function (err, content) {
if (err && (err.code === 'ENOENT' || err === 'ENOENT')) {
return;
}
if (err) {
Log.error('GET_BLOCK_METADATA', err);
return void res.status(500).json({
code: 500,
error: "UNEXPECTED_ERROR",
});
}
sso_params = content;
}));
}).nThen(function (w) {
if (!mfa_params && !sso_params) {
w.abort();
next();
}
}).nThen(function (w) {
// Block is protected with 2FA or SSO, make sure it still exists
const url = req.url;
if (typeof(url) !== "string") { return; }
const s = url.split('/');
const id = s[2];
if (!(s[1]?.length === 2 && BlockStore.isValidKey(id))) { return; }
BlockStore.isAvailable(Env, id, w((err, val) => {
if (err) { return; }
if (val !== false) { return; }
// Block doesn't exist, send the placeholder
w.abort();
return BlockStore.readPlaceholder(Env, id, reason => {
res.status(404).json({
reason,
code: 404
});
});
}));
}).nThen(function (w) {
// We should only be able to reach this logic
// if we successfully loaded and parsed some JSON
// representing the user's MFA and/or SSO settings.
// Failures at this point relate to insufficient or incorrect authorization.
// This function standardizes how we reject such requests.
// So far the only additional factor which is supported is TOTP.
// We specify what the method is to allow for future alternatives
// and inform the client so they can determine how to respond
// "401" means "Unauthorized"
var no = function () {
w.abort();
res.status(401).json({
sso: Boolean(sso_params),
method: mfa_params && mfa_params.method,
code: 401
});
};
// if you are here it is because this block is protected by MFA or SSO.
// they will need to provide a JSON Web Token, so we can reject them outright
// if one is not present in their authorization header
if (!authorization) { return void no(); }
// The authorization header should be of the form
// "Authorization: Bearer <SessionId>"
// We can reject the request if it is malformed.
let token = authorization.replace(/^Bearer\s+/, '').trim();
if (!token) { return void no(); }
Sessions.read(Env, name, token, function (err, contentStr) {
if (err) {
Log.error('SESSION_READ_ERROR', err);
return res.status(401).json({
sso: Boolean(sso_params),
method: mfa_params && mfa_params.method,
code: 401,
});
}
let content = Util.tryParse(contentStr);
if (mfa_params && !content.mfa) { return void no(); }
if (sso_params && !content.sso) { return void no(); }
if (content.mfa && content.mfa.exp && (+new Date()) > content.mfa.exp) {
Log.error("OTP_SESSION_EXPIRED", content.mfa);
Sessions.delete(Env, name, token, function (err) {
if (err) {
Log.error('SESSION_DELETE_EXPIRED_ERROR', err);
return;
}
Log.info('SESSION_DELETE_EXPIRED', err);
});
return void no();
}
if (content.sso && content.sso.exp && (+new Date()) > content.sso.exp) {
Log.error("SSO_SESSION_EXPIRED", content.sso);
Sessions.delete(Env, name, token, function (err) {
if (err) {
Log.error('SSO_SESSION_DELETE_EXPIRED_ERROR', err);
return;
}
Log.info('SSO_SESSION_DELETE_EXPIRED', err);
});
return void no();
}
// Interpret the existence of a file in that location as the continued
// validity of the session. Fall through and let the built-in webserver
// handle the 404 or serving the file.
next();
});
});
});
// TODO this would be a good place to update a block's atime
// in a manner independent of the filesystem. ie. for detecting and archiving
// inactive accounts in a way that will not be invalidated by other forms of access
// like filesystem backups.
app.use("/block", Express.static(Path.resolve(Env.paths.block), {
maxAge: "0d",
}));
// In case of a 404 for the block, check if a placeholder exists
// and provide the result if that's the case
app.use("/block", (req, res, next) => {
const url = req.url;
if (typeof(url) === "string") {
const s = url.split('/');
if (s[1] && s[1].length === 2 && BlockStore.isValidKey(s[2])) {
return BlockStore.readPlaceholder(Env, s[2], (content) => {
res.status(404).json({
reason: content,
code: 404
});
});
}
}
next();
});
app.use("/customize", Express.static('customize'));
app.use("/customize", Express.static('customize.dist'));
app.use("/customize.dist", Express.static('customize.dist'));
app.use(/^\/[^\/]*$/, Express.static('customize'));
app.use(/^\/[^\/]*$/, Express.static('customize.dist'));
// if dev mode: never cache
var cacheString = function () {
return (Env.FRESH_KEY? '-' + Env.FRESH_KEY: '') + (Env.DEV_MODE? '-' + (+new Date()): '');
};
var makeRouteCache = function (template, cacheName) {
var cleanUp = {};
return function (req, res) {
var cache = Env[cacheName] = Env[cacheName] || {};
var host = req.headers.host.replace(/\:[0-9]+/, '');
res.setHeader('Content-Type', 'text/javascript');
// don't cache anything if you're in dev mode
if (Env.DEV_MODE) {
return void res.send(template(host));
}
// generate a lookup key for the cache
var cacheKey = host + ':' + cacheString();
// FIXME mutable
// we must be able to clear the cache when updating any mutable key
// if there's nothing cached for that key...
if (!cache[cacheKey]) {
// generate the response and cache it in memory
cache[cacheKey] = template(host);
// and create a function to conditionally evict cache entries
// which have not been accessed in the last 20 seconds
cleanUp[cacheKey] = Util.throttle(function () {
delete cleanUp[cacheKey];
delete cache[cacheKey];
}, 20000);
}
// successive calls to this function
if (typeof (cleanUp[cacheKey]) === "function") {
cleanUp[cacheKey]();
}
return void res.send(cache[cacheKey]);
};
};
var serveConfig = makeRouteCache(function () {
// NOTE: we may extract JSON from this config using slice(27, -5)
const ssoList = Env.sso && Array.isArray(Env.sso.list) &&
Env.sso.list.map(function (obj) { return obj.name; }) || [];
let forceSso = Env?.sso?.enforced ?? true;
let allowPw = Env?.sso?.cpPassword ?? true;
let forcePw = Env?.sso?.forceCpPassword ?? true;
const ssoCfg = (Env?.sso?.enabled) ? {
enabled: Env?.sso?.enabled,
force: forceSso ? 1 : 0,
password: allowPw && (forcePw ? 2 : 1) || 0,
list: ssoList
} : false;
return [
'define(function(){',
'return ' + JSON.stringify({
requireConf: {
waitSeconds: 600,
urlArgs: 'ver=' + Env.version + cacheString(),
},
removeDonateButton: (Env.removeDonateButton === true),
accounts_api: Env.accounts_api,
websocketPath: Env.websocketPath,
httpUnsafeOrigin: Env.httpUnsafeOrigin,
adminEmail: Env.adminEmail,
adminKeys: Env.admins,
moderatorKeys: Env.moderators,
inactiveTime: Env.inactiveTime,
supportMailbox: Env.supportMailbox,
supportMailboxKey: Env.supportMailboxKey,
defaultStorageLimit: Env.defaultStorageLimit,
maxUploadSize: Env.maxUploadSize,
premiumUploadSize: Env.premiumUploadSize,
restrictRegistration: Env.restrictRegistration,
appsToDisable: Env.appsToDisable,
restrictSsoRegistration: Env.restrictSsoRegistration,
httpSafeOrigin: Env.httpSafeOrigin,
enableEmbedding: Env.enableEmbedding,
fileHost: Env.fileHost,
shouldUpdateNode: Env.shouldUpdateNode || undefined,
listMyInstance: Env.listMyInstance,
sso: ssoCfg,
enforceMFA: Env.enforceMFA,
onlyOffice: Env.onlyOffice
}, null, '\t'),
'});'
].join(';\n');
}, 'configCache');
var serveBroadcast = makeRouteCache(function () {
var maintenance = Env.maintenance;
if (maintenance && maintenance.end && maintenance.end < (+new Date())) {
maintenance = undefined;
}
return [
'define(function(){',
'return ' + JSON.stringify({
curvePublic: Env.curvePublic,
lastBroadcastHash: Env.lastBroadcastHash,
surveyURL: Env.surveyURL,
maintenance: maintenance
}, null, '\t'),
'});'
].join(';\n');
}, 'broadcastCache');
app.get('/api/config', serveConfig);
app.get('/api/broadcast', serveBroadcast);
(function () {
let extensions = plugins._extensions;
let styles = plugins._styles;
let str = JSON.stringify(extensions);
let str2 = JSON.stringify(styles);
let js = `let extensions = ${str};
let styles = ${str2};
let lang = window.cryptpadLanguage;
let paths = [];
extensions.forEach(name => {
paths.push(\`optional!/\${name}/extensions.js\`);
paths.push(\`optional!json!/\${name}/translations/messages.json\`);
const l = lang === "en" ? '' : \`\${lang}.\`;
paths.push(\`optional!json!/\${name}/translations/messages.\${l}json\`);
});
styles.forEach(name => {
paths.push(\`optional!less!/\${name}/style.less\`);
});
define(paths, function () {
let args = Array.prototype.slice.apply(arguments);
return args;
}, function () {
// ignore missing files
});`;
app.get('/extensions.js', (req, res) => {
res.setHeader('Content-Type', 'text/javascript');
res.send(js);
});
})();
var Define = function (obj) {
return `define(function (){
return ${JSON.stringify(obj, null, '\t')};
});`;
};
app.get('/api/instance', function (req, res) {
res.setHeader('Content-Type', 'text/javascript');
res.send(Define({
color: Env.accentColor,
name: Env.instanceName,
description: Env.instanceDescription,
location: Env.instanceJurisdiction,
notice: Env.instanceNotice,
}));
});
var four04_path = Path.resolve('./customize.dist/404.html');
var fivehundred_path = Path.resolve('./customize.dist/500.html');
var custom_four04_path = Path.resolve('./customize/404.html');
var custom_fivehundred_path = Path.resolve('./customize/500.html');
var send404 = function (res, path) {
if (!path && path !== four04_path) { path = four04_path; }
Fs.exists(path, function (exists) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
if (exists) { return Fs.createReadStream(path).pipe(res); }
send404(res);
});
};
var send500 = function (res, path) {
if (!path && path !== fivehundred_path) { path = fivehundred_path; }
Fs.exists(path, function (exists) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
if (exists) { return Fs.createReadStream(path).pipe(res); }
send500(res);
});
};
app.get('/api/profiling', function (req, res) {
if (!Env.enableProfiling) { return void send404(res); }
sendMessage({
command: 'GET_PROFILING_DATA',
}, (err, value) => {
if (err) {
res.status(500);
return void send500(res);
}
res.setHeader('Content-Type', 'text/javascript');
res.send(JSON.stringify({
bytesWritten: value,
}));
});
});
app.get('/api/logo', function (req, res) {
let path = Path.resolve('./customize/CryptPad_logo_hero.svg');
let base = Path.resolve('./customize.dist/CryptPad_logo_hero.svg');
Fs.exists(path, function (exists) {
res.setHeader('Content-Disposition', 'inline');
if (exists) {
let mime = Env.logoMimeType || 'image/svg+xml';
res.setHeader('Content-Type', mime);
return res.sendFile(path);
}
res.sendFile(base);
});
});
app.use('/upload-blob', Express.json({limit:"500kb"}), (req, res) => {
if (req.method !== "POST") {
return res.status(403).send();
}
const { chunk, sig, edPublic } = req.body;
if (!cpcrypto) {
return void res.status(500).send({error: 'NOCRYPTO'});
}
const forbidden = reason => {
return void res.status(403).send({error: reason});
};
try {
// Check signature
const sigu8 = Util.decodeBase64(sig);
const vkey = Util.decodeBase64(edPublic);
const ok = cpcrypto.open(sigu8, vkey);
if (!ok) { return forbidden('INVALID_KEY'); }
const cookie = Util.encodeUTF8(sigu8.subarray(64));
// Check cookie
const safeKey = Util.escapeKeyCharacters(edPublic);
Env.blobStore.checkUploadCookie(safeKey, value => {
if (value !== cookie) {
return forbidden('INVALID_COOKIE');
}
// Upload chunk
Env.blobStore.upload(safeKey, chunk, (err) => {
if (err) {
return res.status(500).send({error: err});
}
// Get new cookie
Env.blobStore.uploadCookie(safeKey, (err, _c) => {
if (err) {
return res.status(500).send({error: err});
}
res.status(200).send({
cookie: _c
});
});
});
});
} catch (e) {
return void res.status(500).send({error: e.message});
}
});
// This endpoint handles authenticated RPCs over HTTP
// via an interactive challenge-response protocol
app.use(Express.json());
app.post('/api/auth', function (req, res, next) {
AuthCommands.handle(Env, req, res, next);
});
app.use(function (req, res /*, next */) {
if (/^(\/favicon\.ico\/|.*\.js\.map|.*\/translations\/.*\.json)/.test(req.url)) {
// ignore common 404s
} else {
Log.info('HTTP_404', req.url);
}
res.status(404);
send404(res, custom_four04_path);
});
// default message for thrown errors in ExpressJS routes
app.use(function (err, req, res /*, next*/) {
Log.error('EXPRESSJS_ROUTING', {
error: err.stack || err,
});
res.status(500);
send500(res, custom_fivehundred_path);
});
var server = Http.createServer(app);
nThen(function (w) {
server.listen(Env.httpPort, Env.httpAddress, w());
if (Env.httpSafePort) {
let safeServer = Http.createServer(app);
safeServer.listen(Env.httpSafePort, Env.httpAddress, w());
}
server.on('upgrade', function (req, socket, head) {
// TODO warn admins that websockets should only be proxied in this way in a dev environment
// in production it's more efficient to have your reverse proxy (NGINX) directly forward
// websocket traffic to the correct port (Env.websocketPort)
wsProxy.upgrade(req, socket, head);
});
var config = require("./load-config");
BlobStore.create({
blobPath: config.blobPath,
blobStagingPath: config.blobStagingPath,
archivePath: config.archivePath,
getSession: function () {},
}, w(function (err, blob) {
if (err) { return; }
Env.blobStore = blobStore = blob;
}));
CPCrypto.init(w(function (err, crypto) {
cpcrypto = crypto;
}));
}).nThen(function () {
// TODO inform the parent process that this worker is ready
Object.keys(Env.plugins || {}).forEach(name => {
let plugin = plugins[name];
if (!plugin.initialize) { return; }
try { plugin.initialize(Env, "http-worker"); }
catch (e) {}
});
});
process.on('uncaughtException', function (err) {
console.error('[%s] UNCAUGHT EXCEPTION IN HTTP WORKER', new Date());
console.error(err);
console.error("TERMINATING");
process.exit(1);
});

View File

@ -1,5 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
module.exports = require("../src/common/common-signing-keys");

View File

@ -1,112 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
var Store = require("./storage/file");
var Util = require("./common-util");
var Logger = module.exports;
/* Every line in the log should contain:
* timestamp
* public key of initiator
* the action
* the event's tag
*/
var messageTemplate = function (type, time, tag, info) {
return JSON.stringify([type.toUpperCase(), time, tag, info]);
};
var noop = function () {};
var write = function (ctx, content, cb) {
if (typeof(cb) !== "function") { cb = noop; }
if (!ctx.store) {
cb = Util.mkAsync(cb);
return void cb();
}
ctx.store.log(ctx.channelName, content, cb);
};
// various degrees of logging
const logLevels = Logger.levels = ['silly', 'verbose', 'debug', 'feedback', 'info', 'warn', 'error'];
var handlers = {};
['silly', 'debug', 'verbose', 'feedback', 'info'].forEach(function (level) {
handlers[level] = function (ctx, content) { console.log(content); };
});
['warn', 'error'].forEach(function (level) {
handlers[level] = function (ctx, content) { console.error(content); };
});
var createLogType = function (ctx, type) {
if (logLevels.indexOf(type) < logLevels.indexOf(ctx.logLevel)) {
return noop;
}
return function (tag, info, cb) {
if (ctx.shutdown) {
throw new Error("Logger has been shut down!");
}
var time = new Date().toISOString();
var content;
try {
content = messageTemplate(type, time, tag, info);
} catch (e) {
return;
}
if (ctx.logToStdout && typeof(handlers[type]) === 'function') {
handlers[type](ctx, content);
}
write(ctx, content, cb);
};
};
var createMethods = function (ctx) {
var log = {};
logLevels.forEach(function (type) {
log[type] = createLogType(ctx, type);
});
return log;
};
Logger.create = function (config, cb) {
if (typeof(config.logLevel) !== 'string') {
config.logLevel = 'info';
}
var date = new Date();
var launchTime = ('' + date.getUTCFullYear()).slice(-2) + date.toISOString();
var ctx = {
channelName: launchTime,
logFeedback: Boolean(config.logFeedback),
logLevel: config.logLevel,
logToStdout: config.logToStdout,
};
if (!config.logPath) {
console.log("No logPath configured. Logging to file disabled");
var logger = createMethods(ctx);
logger.shutdown = noop;
return void cb(Object.freeze(logger));
}
Store.create({
filePath: config.logPath,
archivePath: config.archivePath,
}, function (err, store) {
if (err) {
throw err;
}
ctx.store = store;
var logger = createMethods(ctx);
logger.shutdown = function () {
delete ctx.store;
ctx.shutdown = true;
store.shutdown();
};
cb(Object.freeze(logger));
});
};

View File

@ -1,485 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
var Meta = module.exports;
var Core = require("./commands/core");
var deduplicate = require("./common-util").deduplicateString;
/* Metadata fields and the commands that can modify them
we assume that these commands can only be performed
by owners or in some cases pending owners. Thus
the owners field is guaranteed to exist.
* channel <STRING>
* validateKey <STRING>
* owners <ARRAY>
* ADD_OWNERS
* RM_OWNERS
* RESET_OWNERS
* pending_owners <ARRAY>
* ADD_PENDING_OWNERS
* RM_PENDING_OWNERS
* expire <NUMBER>
* UPDATE_EXPIRATION (NOT_IMPLEMENTED)
* restricted <BOOLEAN>
* RESTRICT_ACCESS
* allowed <ARRAY>
* ADD_ALLOWED
* RM_ALLOWED
* RESET_ALLOWED
* ADD_OWNERS
* RESET_OWNERS
* mailbox <STRING|MAP>
* ADD_MAILBOX
* RM_MAILBOX
* deleteLines <BOOLEAN>
* ALLOW_LINE_DELETION
*/
var commands = {};
var isValidPublicKey = Core.isValidPublicKey;
// isValidPublicKey is a better indication of what the above function does
// I'm preserving this function name in case we ever want to expand its
// criteria at a later time...
var isValidOwner = isValidPublicKey;
// ["RESTRICT_ACCESS", [true], 1561623438989]
// ["RESTRICT_ACCESS", [false], 1561623438989]
commands.RESTRICT_ACCESS = function (meta, args) {
if (!Array.isArray(args) || typeof(args[0]) !== 'boolean') {
throw new Error('INVALID_STATE');
}
var bool = args[0];
// reject the proposed command if there is no change in state
if (meta.restricted === bool) { return false; }
// apply the new state
meta.restricted = args[0];
// if you're disabling access restrictions then you can assume
// then there is nothing more to do. Leave the existing list as-is
if (!bool) { return true; }
// you're all set if an allow list already exists
if (Array.isArray(meta.allowed)) { return true; }
// otherwise define it
meta.allowed = [];
return true;
};
// ["ALLOW_LINE_DELETION", [true], 1561623438989]
// ["ALLOW_LINE_DELETION", [false], 1561623438989]
commands.ALLOW_LINE_DELETION = function (meta, args) {
if (!Array.isArray(args) || typeof(args[0]) !== 'boolean') {
throw new Error('INVALID_STATE');
}
var bool = args[0];
// reject the proposed command if there is no change in state
if (meta.deleteLines === bool) { return false; }
// apply the new state
meta.deleteLines = args[0];
return true;
};
// ["ADD_ALLOWED", ["7eEqelGso3EBr5jHlei6av4r9w2B9XZiGGwA1EgZ-5I=", ...], 1561623438989]
commands.ADD_ALLOWED = function (meta, args) {
if (!Array.isArray(args)) {
throw new Error("INVALID_ARGS");
}
var allowed = meta.allowed || [];
var changed = false;
args.forEach(function (arg) {
// don't add invalid public keys
if (!isValidPublicKey(arg)) { return; }
// don't add owners to the allow list
if (meta.owners.indexOf(arg) >= 0) { return; }
// don't duplicate entries in the allow list
if (allowed.indexOf(arg) >= 0) { return; }
allowed.push(arg);
changed = true;
});
if (changed) {
meta.allowed = meta.allowed || allowed;
}
return changed;
};
// ["RM_ALLOWED", ["7eEqelGso3EBr5jHlei6av4r9w2B9XZiGGwA1EgZ-5I=", ...], 1561623438989]
commands.RM_ALLOWED = function (meta, args) {
if (!Array.isArray(args)) {
throw new Error("INVALID_ARGS");
}
// there may not be anything to remove
if (!meta.allowed) { return false; }
var changed = false;
args.forEach(function (arg) {
var index = meta.allowed.indexOf(arg);
if (index < 0) { return; }
meta.allowed.splice(index, 1);
changed = true;
});
return changed;
};
var arrayHasChanged = function (A, B) {
var changed;
A.some(function (a) {
if (B.indexOf(a) < 0) { return (changed = true); }
});
if (changed) { return true; }
B.some(function (b) {
if (A.indexOf(b) < 0) { return (changed = true); }
});
return changed;
};
var filterInPlace = function (A, f) {
for (var i = A.length - 1; i >= 0; i--) {
if (f(A[i], i, A)) { A.splice(i, 1); }
}
};
// ["RESET_ALLOWED", ["7eEqelGso3EBr5jHlei6av4r9w2B9XZiGGwA1EgZ-5I=", ...], 1561623438989]
commands.RESET_ALLOWED = function (meta, args) {
if (!Array.isArray(args)) { throw new Error("INVALID_ARGS"); }
var updated = args.filter(function (arg) {
// don't allow invalid public keys
if (!isValidPublicKey(arg)) { return false; }
// don't ever add owners to the allow list
if (meta.owners.indexOf(arg)) { return false; }
return true;
});
// this is strictly an optimization...
// a change in length is a clear indicator of a functional change
if (meta.allowed && meta.allowed.length !== updated.length) {
meta.allowed = updated;
return true;
}
// otherwise we must check that the arrays contain distinct elements
// if there is no functional change, then return false
if (!arrayHasChanged(meta.allowed, updated)) { return false; }
// otherwise overwrite the in-memory data and indicate that there was a change
meta.allowed = updated;
return true;
};
// ["ADD_OWNERS", ["7eEqelGso3EBr5jHlei6av4r9w2B9XZiGGwA1EgZ-5I="], 1561623438989]
commands.ADD_OWNERS = function (meta, args) {
// bail out if args isn't an array
if (!Array.isArray(args)) {
throw new Error('METADATA_INVALID_OWNERS');
}
// you shouldn't be able to get here if there are no owners
// because only an owner should be able to change the owners
if (!Array.isArray(meta.owners)) {
throw new Error("METADATA_NONSENSE_OWNERS");
}
var changed = false;
args.forEach(function (owner) {
if (!isValidOwner(owner)) { return; }
if (meta.owners.indexOf(owner) >= 0) { return; }
meta.owners.push(owner);
changed = true;
});
if (changed && Array.isArray(meta.allowed)) {
// make sure owners are not included in the allow list
filterInPlace(meta.allowed, function (member) {
return meta.owners.indexOf(member) !== -1;
});
}
return changed;
};
// ["RM_OWNERS", ["CrufexqXcY-z+eKJlEbNELVy5Sb7E-EAAEFI8GnEtZ0="], 1561623439989]
commands.RM_OWNERS = function (meta, args) {
// what are you doing if you don't have owners to remove?
if (!Array.isArray(args)) {
throw new Error('METADATA_INVALID_OWNERS');
}
// if there aren't any owners to start, this is also pointless
if (!Array.isArray(meta.owners)) {
throw new Error("METADATA_NONSENSE_OWNERS");
}
var changed = false;
// remove owners one by one
// we assume there are no duplicates
args.forEach(function (owner) {
var index = meta.owners.indexOf(owner);
if (index < 0) { return; }
if (meta.mailbox) {
if (typeof(meta.mailbox) === "string") {
delete meta.mailbox;
} else {
delete meta.mailbox[owner];
}
}
meta.owners.splice(index, 1);
changed = true;
});
if (meta.owners.length === 0 && meta.restricted) {
meta.restricted = false;
}
return changed;
};
// ["ADD_PENDING_OWNERS", ["7eEqelGso3EBr5jHlei6av4r9w2B9XZiGGwA1EgZ-5I="], 1561623438989]
commands.ADD_PENDING_OWNERS = function (meta, args) {
// bail out if args isn't an array
if (!Array.isArray(args)) {
throw new Error('METADATA_INVALID_PENDING_OWNERS');
}
// you shouldn't be able to get here if there are no owners
// because only an owner should be able to change the owners
if (meta.pending_owners && !Array.isArray(meta.pending_owners)) {
throw new Error("METADATA_NONSENSE_PENDING_OWNERS");
}
var changed = false;
// Add pending_owners array if it doesn't exist
if (!meta.pending_owners) {
meta.pending_owners = deduplicate(args);
return true;
}
// or fill it
args.forEach(function (owner) {
if (!isValidOwner(owner)) { return; }
if (meta.pending_owners.indexOf(owner) >= 0) { return; }
meta.pending_owners.push(owner);
changed = true;
});
return changed;
};
// ["RM_PENDING_OWNERS", ["CrufexqXcY-z+eKJlEbNELVy5Sb7E-EAAEFI8GnEtZ0="], 1561623439989]
commands.RM_PENDING_OWNERS = function (meta, args) {
// what are you doing if you don't have owners to remove?
if (!Array.isArray(args)) {
throw new Error('METADATA_INVALID_PENDING_OWNERS');
}
// if there aren't any owners to start, this is also pointless
if (!Array.isArray(meta.pending_owners)) {
throw new Error("METADATA_NONSENSE_PENDING_OWNERS");
}
var changed = false;
// remove owners one by one
// we assume there are no duplicates
args.forEach(function (owner) {
var index = meta.pending_owners.indexOf(owner);
if (index < 0) { return; }
meta.pending_owners.splice(index, 1);
changed = true;
});
return changed;
};
// ["RESET_OWNERS", ["7eEqelGso3EBr5jHlei6av4r9w2B9XZiGGwA1EgZ-5I="], 1561623439989]
commands.RESET_OWNERS = function (meta, args) {
// expect a new array, even if it's empty
if (!Array.isArray(args)) {
throw new Error('METADATA_INVALID_OWNERS');
}
// assume there are owners to start
if (!Array.isArray(meta.owners)) {
throw new Error("METADATA_NONSENSE_OWNERS");
}
// overwrite the existing owners with the new one
meta.owners = deduplicate(args.filter(isValidOwner));
if (Array.isArray(meta.allowed)) {
// make sure owners are not included in the allow list
filterInPlace(meta.allowed, function (member) {
return meta.owners.indexOf(member) !== -1;
});
}
if (meta.owners.length === 0 && meta.restricted) {
meta.restricted = false;
}
return true;
};
// ["SET_LINKED", "9fb7485b8e824d07489ed5586d848232", 1561623439989]
commands.SET_LINKED = function (meta, linked) {
if (typeof(linked) !== "string") {
throw new Error('METADATA_INVALID_LINKED');
}
// reject the proposed command if there is no change in state
if (meta.linked === linked) { return false; }
// apply the new state
meta.linked = linked;
return true;
};
// ["ADD_MAILBOX", {"7eEqelGso3EBr5jHlei6av4r9w2B9XZiGGwA1EgZ-5I=": mailbox, ...}, 1561623439989]
commands.ADD_MAILBOX = function (meta, args) {
// expect a new array, even if it's empty
if (!args || typeof(args) !== "object") {
throw new Error('METADATA_INVALID_MAILBOX');
}
// assume there are owners to start
if (!Array.isArray(meta.owners)) {
throw new Error("METADATA_NONSENSE_OWNERS");
}
var changed = false;
// For each mailbox we try to add, check if the associated edPublic is an owner
// If they are, add or replace the mailbox
Object.keys(args).forEach(function (edPublic) {
if (meta.owners.indexOf(edPublic) === -1) { return; }
if (typeof(meta.mailbox) === "string") {
var str = meta.mailbox;
meta.mailbox = {};
meta.mailbox[meta.owners[0]] = str;
}
// Make sure mailbox is defined
if (!meta.mailbox) { meta.mailbox = {}; }
meta.mailbox[edPublic] = args[edPublic];
changed = true;
});
return changed;
};
commands.RM_MAILBOX = function (meta, args) {
if (!Array.isArray(args)) { throw new Error("INVALID_ARGS"); }
if (!meta.mailbox || typeof(meta.mailbox) === 'undefined') {
return false;
}
if (typeof(meta.mailbox) === 'string' && args.length === 0) {
delete meta.mailbox;
return true;
}
var changed = false;
args.forEach(function (arg) {
if (meta.mailbox[arg] === 'undefined') { return; }
delete meta.mailbox[arg];
changed = true;
});
return changed;
};
commands.UPDATE_EXPIRATION = function () {
throw new Error("E_NOT_IMPLEMENTED");
};
var handleCommand = Meta.handleCommand = function (meta, line) {
var command = line[0];
var args = line[1];
//var time = line[2];
if (typeof(commands[command]) !== 'function') {
throw new Error("METADATA_UNSUPPORTED_COMMAND");
}
return commands[command](meta, args);
};
Meta.commands = Object.keys(commands);
Meta.createLineHandler = function (ref, errorHandler) {
ref.meta = {};
ref.index = 0;
ref.logged = {};
var overwritten = false;
return function (err, line) {
if (err) {
// it's not abnormal that metadata exists without a corresponding log
// so ENOENT is fine
if (ref.index === 0 && err.code === 'ENOENT') { return; }
// any other errors are abnormal
return void errorHandler('METADATA_HANDLER_LINE_ERR', {
error: err,
index: ref.index,
line: JSON.stringify(line),
});
}
// the case above is special, everything else should increment the index
var index = ref.index++;
if (typeof(line) === 'undefined') { return; }
if (Array.isArray(line)) {
try {
handleCommand(ref.meta, line);
} catch (err2) {
var code = err2.message;
if (ref.logged[code]) { return; }
ref.logged[code] = true;
errorHandler("METADATA_COMMAND_ERR", {
error: err2.stack,
line: line,
});
}
return;
}
// the first line of a channel is processed before the dedicated metadata log.
// it can contain a map, in which case it should be used as the initial state.
// it's possible that a trim-history command was interrupted, in which case
// this first message might exist in parallel with the more recent metadata log
// which will contain the computed state of the previous metadata log
// which has since been archived.
// Thus, accept both the first and second lines you process as valid initial state
// preferring the second if it exists
if (index < 2 && line && typeof(line) === 'object') {
if (overwritten) { return; } // hack to avoid overwriting metadata a second time
overwritten = true;
// special case!
ref.meta = line;
return;
}
errorHandler("METADATA_HANDLER_WEIRDLINE", {
line: line,
index: index,
});
};
};

View File

@ -1,333 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
var Pins = module.exports;
const Fs = require("fs");
const Path = require("path");
const Util = require("./common-util");
const Plan = require("./plan");
const Store = require('./storage/file');
const Semaphore = require('saferphore');
const nThen = require('nthen');
/* Accepts a reference to an object, and...
either a string describing which log is being processed (backwards compatibility),
or a function which will log the error with all relevant data
*/
var createLineHandler = Pins.createLineHandler = function (ref, errorHandler) {
var fileName;
if (typeof(errorHandler) === 'string') {
fileName = errorHandler;
errorHandler = function (label, data) {
console.error(label, {
log: fileName,
data: data,
});
};
}
// passing the reference to an object allows us to overwrite accumulated pins
// make sure to get ref.pins as the result
// 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
// Extract metadata from the channel list (#block, #drive)
let sanitize = (id, isPin) => {
if (typeof(id) !== "string") { return; }
let idx = id.indexOf('#');
if (idx < 0) { return id; }
let type = id.slice(idx+1);
let sanitized = id.slice(0, idx);
if (!isPin) { return sanitized; }
if (type === 'block') { // Note: teams don't have a block
ref.block = sanitized;
return;
}
if (type === 'drive') {
ref.drive = sanitized;
return sanitized;
}
return sanitized;
};
return function (line, i) {
ref.index++;
if (!Boolean(line)) { return; }
var l;
try {
l = JSON.parse(line);
} catch (e) {
return void errorHandler('PIN_LINE_PARSE_ERROR', line);
}
if (!Array.isArray(l)) {
return void errorHandler('PIN_LINE_NOT_FORMAT_ERROR', l);
}
if (typeof(l[2]) === 'number') {
if (!ref.first) { ref.first = l[2]; }
ref.latest = l[2]; // date
}
switch (l[0]) {
case 'RESET': {
pins = ref.pins = {};
if (l[1] && l[1].length) {
l[1].forEach((x) => {
x = sanitize(x, true);
if (!x) { return; }
ref.pins[x] = 1;
});
}
ref.surplus = ref.index;
// fallthrough
}
case 'PIN': {
l[1].forEach((x) => {
x = sanitize(x, true);
if (!x) { return; }
pins[x] = 1;
});
break;
}
case 'UNPIN': {
l[1].forEach((x) => {
x = sanitize(x, false);
if (!x) { return; }
delete pins[x];
});
break;
}
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
}
};
};
var processPinFile = function (pinFile, fileName) {
var ref = {};
var handler = createLineHandler(ref, fileName);
pinFile.split('\n').forEach(handler);
return ref;
};
/*
takes contents of a pinFile (UTF8 string)
and the pin file's name
returns an array of of channel ids which are pinned
throw errors on pin logs with invalid pin data
*/
Pins.calculateFromLog = function (pinFile, fileName) {
var ref = processPinFile(pinFile, fileName);
return Object.keys(ref.pins);
};
/*
pins/
pins/A+/
pins/A+/A+hyhrQLrgYixOomZYxpuEhwfiVzKk1bBp+arH-zbgo=.ndjson
*/
const getSafeKeyFromPath = function (path) {
return path.replace(/^.*\//, '').replace(/\.ndjson/, '');
};
const addUserPinToState = Pins.addUserPinToState = function (state, safeKey, itemId) {
(state[itemId] = state[itemId] || {})[safeKey] = 1;
};
Pins.list = function (_done, config) {
// allow for a configurable pin store location
const pinPath = config.pinPath || './data/pins';
// allow for a configurable amount of parallelism
const plan = Plan(config.workers || 5);
// run a supplied handler whenever you finish reading a log
// or noop if not supplied.
const handler = config.handler || function () {};
// use and mutate a supplied object for state if it's passed
const pinned = config.pinned || {};
var isDone = false;
// ensure that 'done' is only called once
// that it calls back asynchronously
// and that it sets 'isDone' to true, so that pending processes
// know to abort
const done = Util.once(Util.both(Util.mkAsync(_done), function () {
isDone = true;
}));
const errorHandler = function (label, info) {
console.log(label, info);
};
// TODO replace this with lib-readline?
const streamFile = function (path, cb) {
const id = getSafeKeyFromPath(path);
return void Fs.readFile(path, 'utf8', function (err, body) {
if (err) { return void cb(err); }
const ref = {};
const pinHandler = createLineHandler(ref, errorHandler);
var lines = body.split('\n');
lines.forEach(pinHandler);
handler(ref, id, pinned);
cb(void 0, ref);
});
};
const scanDirectory = function (path, cb) {
Fs.readdir(path, function (err, list) {
if (err) {
return void cb(err);
}
cb(void 0, list.map(function (item) {
return {
path: Path.join(path, item),
id: item.replace(/\.ndjson$/, ''),
};
}));
});
};
scanDirectory(pinPath, function (err, dirs) {
if (err) {
if (err.code === 'ENOENT') { return void done(void 0, {}); }
return void done(err);
}
dirs.forEach(function (dir) {
plan.job(1, function (next) {
if (isDone) { return void next(); }
scanDirectory(dir.path, function (nested_err, logs) {
if (nested_err) {
return void done(err);
}
logs.forEach(function (log) {
if (!/\.ndjson$/.test(log.path)) { return; }
plan.job(0, function (next) {
if (isDone) { return void next(); }
streamFile(log.path, function (err, ref) {
if (err) { return void done(err); }
var set = ref.pins;
for (var item in set) {
addUserPinToState(pinned, log.id, item);
}
next();
});
});
});
next();
});
});
});
plan.done(function () {
// err ?
done(void 0, pinned);
}).start();
});
};
Pins.load = function (cb, config) {
const sema = Semaphore.create(config.workers || 5);
let dirList;
const fileList = [];
const pinned = {};
var pinPath = config.pinPath || './pins';
var done = Util.once(cb);
var handler = config.handler;
let store;
nThen((waitFor) => {
Store.create({
filePath: config.pinPath,
volumeId: 'pins'
}, waitFor((err, _) => {
if (err) {
waitFor.abort();
return void done(err);
}
store = _;
}));
}).nThen((waitFor) => {
// recurse over the configured pinPath, or the default
Fs.readdir(pinPath, waitFor((err, list) => {
if (err) {
if (err.code === 'ENOENT') {
dirList = [];
return; // this ends up calling back with an empty object
}
waitFor.abort();
return void done(err);
}
dirList = list;
}));
}).nThen((waitFor) => {
dirList.forEach((f) => {
sema.take((returnAfter) => {
// iterate over all the subdirectories in the pin store
Fs.readdir(Path.join(pinPath, f), waitFor(returnAfter((err, list2) => {
if (err) {
waitFor.abort();
return void done(err);
}
list2.forEach((ff) => {
if (config && config.exclude && config.exclude.indexOf(ff) > -1) { return; }
fileList.push(ff.replace(/(\.ndjson)$/, ''));
});
})));
});
});
}).nThen((waitFor) => {
fileList.forEach((id) => {
sema.take((returnAfter) => {
var next = waitFor(returnAfter());
var ref = {};
var h = createLineHandler(ref, id);
store.readMessagesBin(id, 0, (msgObj, next) => {
h(msgObj.buff.toString('utf8'));
next();
}, (err) => {
if (err) {
waitFor.abort();
return void done(err);
}
if (handler) {
return void handler(ref, id, next);
}
const hashes = Object.keys(ref.pins);
hashes.forEach((x) => {
(pinned[x] = pinned[x] || {})[id] = 1;
});
next();
});
});
});
}).nThen(() => {
done(void 0, pinned);
});
};

View File

@ -1,239 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
/*
There are many situations where we want to do lots of little jobs
in parallel and with few constraints as to their ordering.
One example is recursing over a bunch of directories and reading files.
The naive way to do this is to recurse over all the subdirectories
relative to a root while adding files to a list. Then to iterate over
the files in that list. Unfortunately, this means holding the complete
list of file paths in memory, which can't possible scale as our database grows.
A better way to do this is to recurse into one directory and
iterate over its contents until there are no more, then to backtrack
to the next directory and repeat until no more directories exist.
This kind of thing is easy enough when you perform one task at a time
and use synchronous code, but with multiple asynchronous tasks it's
easy to introduce subtle bugs.
This module is designed for these situations. It allows you to easily
and efficiently schedule a large number of tasks with an associated
degree of priority from 0 (highest priority) to Number.MAX_SAFE_INTEGER.
Initialize your scheduler with a degree of parallelism, and start planning
some initial jobs. Set it to run and it will keep going until all jobs are
complete, at which point it will optionally execute a 'done' callback.
Getting back to the original example:
List the contents of the root directory, then plan subsequent jobs
with a priority of 1 to recurse into subdirectories. The callback
of each of these recursions can then plan higher priority tasks
to actually process the contained files with a priority of 0.
As long as there are more files scheduled it will continue to process
them first. When there are no more files the scheduler will read
the next directory and repopulate the list of files to process.
This will repeat until everything is done.
// load the module
const Plan = require("./plan");
// instantiate a scheduler with a parallelism of 5
var plan = Plan(5)
// plan the first job which schedules more jobs...
.job(1, function (next) {
listRootDirectory(function (files) {
files.forEach(function (file) {
// highest priority, run as soon as there is a free worker
plan.job(0, function (next) {
processFile(file, function (result) {
console.log(result);
// don't forget to call next
next();
});
});
});
next(); // call 'next' to free up one worker
});
})
// chain commands together if you want
.done(function () {
console.log("DONE");
})
// it won't run unless you launch it
.start();
*/
module.exports = function (max) {
var plan = {};
max = max || 5;
// finds an id that isn't in use in a particular map
// accepts an id in case you have one already chosen
// otherwise generates random new ids if one is not passed
// or if there is a collision
var uid = function (map, id) {
if (typeof(id) === 'undefined') {
id = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
}
if (id && typeof(map[id]) === 'undefined') {
return id;
}
return uid(map);
};
// the queue of jobs is an array, which will be populated
// with maps for each level of priority
var jobs = [];
// the count of currently running jobs
var count = 0;
// a list of callbacks to be executed once everything is done
var completeHandlers = [];
// the recommended usage is to create a new scheduler for every job
// use it for internals in a scope, and let the garbage collector
// clean up when everything stops. This means you shouldn't
// go passing 'plan' around in a long-lived process!
var FINISHED = false;
var done = function () {
// 'done' gets called when there are no more jobs in the queue
// but other jobs might still be running...
// the count of running processes should never be less than zero
// because we guard against multiple callbacks
if (count < 0) { throw new Error("should never happen"); }
// greater than zero is definitely possible, it just means you aren't done yet
if (count !== 0) { return; }
// you will finish twice if you call 'start' a second time
// this behaviour isn't supported yet.
if (FINISHED) { throw new Error('finished twice'); }
FINISHED = true;
// execute all your 'done' callbacks
completeHandlers.forEach(function (f) { f(); });
};
var run;
// this 'next' is internal only.
// it iterates over all known jobs, running them until
// the scheduler achieves the desired amount of parallelism.
// If there are no more jobs it will call 'done'
// which will shortcircuit if there are still pending tasks.
// Whenever any tasks finishes it will return its lock and
// run as many new jobs as are allowed.
var next = function () {
// array.some skips over bare indexes in sparse arrays
var pending = jobs.some(function (bag /*, priority*/) {
if (!bag || typeof(bag) !== 'object') { return; }
// a bag is a map of jobs for any particular degree of priority
// iterate over jobs in the bag until you're out of 'workers'
for (var id in bag) {
// bail out if you hit max parallelism
if (count >= max) { return true; }
run(bag, id, next);
}
});
// check whether you're done if you hit the end of the array
if (!pending) { done(); }
};
// and here's the part that actually handles jobs...
run = function (bag, id) {
// this is just a sanity check.
// there should only ever be jobs in each bag.
if (typeof(bag[id]) !== 'function') {
throw new Error("expected function");
}
// keep a local reference to the function
var f = bag[id];
// remove it from the bag.
delete bag[id];
// increment the count of running jobs
count++;
// guard against it being called twice.
var called = false;
f(function () {
// watch out! it'll bite you.
// maybe this should just return?
// support that option for 'production' ?
if (called) { throw new Error("called twice"); }
// the code below is safe because we can't call back a second time
called = true;
// decrement the count of running jobs...
count--;
// and finally call next to replace this worker with more job(s)
next();
});
};
// this is exposed as API
plan.job = function (priority, cb) {
// you have to pass both the priority (a non-negative number) and an actual job
if (typeof(priority) !== 'number' || priority < 0) { throw new Error('expected a non-negative number'); }
// a job is an asynchronous function that takes a single parameter:
// a 'next' callback which will keep the whole thing going.
// forgetting to call 'next' means you'll never complete.
if (typeof(cb) !== 'function') { throw new Error('expected function'); }
// initialize the specified priority level if it doesn't already exist
var bag = jobs[priority] = jobs[priority] || {};
// choose a random id that isn't already in use for this priority level
var id = uid(bag);
// add the job to this priority level's bag
// most (all?) javascript engines will append this job to the bottom
// of the map. Meaning when we iterate it will be run later than
// other jobs that were scheduled first, effectively making a FIFO queue.
// However, this is undefined behaviour and you shouldn't ever rely on it.
bag[id] = function (next) {
cb(next);
};
// returning 'plan' lets us chain methods together.
return plan;
};
var started = false;
plan.start = function () {
// don't allow multiple starts
// even though it should work, it's simpler not to.
if (started) { return plan; }
// this seems to imply a 'stop' method
// but I don't need it, so I'm not implementing it now --ansuz
started = true;
// start asynchronously, otherwise jobs will start running
// before you've had a chance to return 'plan', and weird things
// happen.
setTimeout(function () {
next();
});
return plan;
};
// you can pass any number of functions to be executed
// when all pending jobs are complete.
// We don't pass any arguments, so you need to handle return values
// yourself if you want them.
plan.done = function (f) {
if (typeof(f) !== 'function') { throw new Error('expected function'); }
completeHandlers.push(f);
return plan;
};
// That's all! I hope you had fun reading this!
return plan;
};

View File

@ -1,37 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const fs = require('node:fs');
const plugins = {};
const extensions = plugins._extensions = [];
const styles = plugins._styles = [];
try {
let pluginsDir = fs.readdirSync(__dirname + '/plugins');
pluginsDir.forEach((name) => {
if (name=== "README.md") { return; }
try {
let plugin = require(`./plugins/${name}/index`);
plugins[plugin.name] = plugin.modules;
try {
let hasExt = fs.existsSync(`lib/plugins/${name}/client/extensions.js`);
if (hasExt) {
extensions.push(plugin.name.toLowerCase());
}
} catch (e) {}
try {
let hasStyle = fs.existsSync(`lib/plugins/${name}/client/style.less`);
if (hasStyle) {
styles.push(plugin.name.toLowerCase());
}
} catch (e) {}
} catch (err) {
console.error(err);
}
});
} catch (err) {
if (err.code !== 'ENOENT') { console.error(err); }
}
module.exports = plugins;

View File

@ -1,7 +0,0 @@
<!--
SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
SPDX-License-Identifier: AGPL-3.0-or-later
-->
# CryptPad's plugins directory

View File

@ -1,253 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Util = require("./common-util");
const Core = require("./commands/core");
const Admin = require("./commands/admin-rpc");
const Pinning = require("./commands/pin-rpc");
const Quota = require("./commands/quota");
const Metadata = require("./commands/metadata");
const Channel = require("./commands/channel");
const Upload = require("./commands/upload");
const Linked = require("./commands/linked");
const HK = require("./hk-util");
var RPC = module.exports;
const UNAUTHENTICATED_CALLS = {
GET_FILE_SIZE: Pinning.getFileSize,
GET_MULTIPLE_FILE_SIZE: Pinning.getMultipleFileSize,
GET_DELETED_PADS: Pinning.getDeletedPads,
IS_CHANNEL_PINNED: Pinning.isChannelPinned, // FIXME drop this RPC
IS_NEW_CHANNEL: Channel.isNewChannel,
WRITE_PRIVATE_MESSAGE: Channel.writePrivateMessage,
DELETE_MAILBOX_MESSAGE: Channel.deleteMailboxMessage,
GET_METADATA: Metadata.getMetadata,
IS_PREMIUM: Pinning.isPremium,
ADD_FIRST_ADMIN: Admin.addFirstAdmin,
GET_LINKED_DOCUMENTS: Linked.getLinkedDocuments,
ADD_LINKED_DOCUMENT: Linked.addLinkedDocument,
RESET_LINKED_DOCUMENTS: Linked.resetLinkedDocuments,
GET_HISTORY_SIZE: Linked.getHistorySize
};
var isUnauthenticateMessage = function (msg) {
return msg && msg.length === 2 && typeof(UNAUTHENTICATED_CALLS[msg[0]]) === 'function';
};
var handleUnauthenticatedMessage = function (Env, msg, respond, Server, netfluxId) {
Env.Log.silly('LOG_RPC', msg[0]);
Env.plugins?.MONITORING?.increment(`rpc_${msg[0]}`);
var method = UNAUTHENTICATED_CALLS[msg[0]];
method(Env, msg[1], function (err, value) {
if (err) {
Env.WARN(err, msg[1]);
return void respond(err);
}
respond(err, [null, value, null]);
}, Server, netfluxId);
};
const AUTHENTICATED_USER_TARGETED = {
RESET: Pinning.resetUserPins,
PIN: Pinning.pinChannel,
UNPIN: Pinning.unpinChannel,
CLEAR_OWNED_CHANNEL: Channel.clearOwnedChannel,
REMOVE_OWNED_CHANNEL: Channel.removeOwnedChannel,
TRIM_HISTORY: Channel.trimHistory,
UPLOAD_STATUS: Upload.status,
UPLOAD: Upload.upload,
UPLOAD_COMPLETE: Upload.complete,
UPLOAD_CANCEL: Upload.cancel,
OWNED_UPLOAD_COMPLETE: Upload.complete_owned,
ADMIN: Admin.command,
SET_METADATA: Metadata.setMetadata,
};
const AUTHENTICATED_USER_SCOPED = {
GET_HASH: Pinning.getHash,
GET_TOTAL_SIZE: Pinning.getTotalSize,
UPDATE_LIMITS: Quota.getUpdatedLimit,
GET_LIMIT: Pinning.getLimit,
EXPIRE_SESSION: Core.expireSessionAsync,
REMOVE_PINS: Pinning.removePins,
TRIM_PINS: Pinning.trimPins,
COOKIE: Core.haveACookie,
DESTROY: () => {}
};
var isAuthenticatedCall = function (call) {
if (call === 'UPLOAD') { return false; }
return typeof(AUTHENTICATED_USER_TARGETED[call] || AUTHENTICATED_USER_SCOPED[call]) === 'function';
};
var handleAuthenticatedMessage = function (Env, unsafeKey, msg, respond, Server) {
/* If you have gotten this far, you have signed the message with the
public key which you provided.
*/
var safeKey = Util.escapeKeyCharacters(unsafeKey);
var Respond = function (e, value) {
var session = Env.Sessions[safeKey];
var token = session? session.tokens.slice(-1)[0]: '';
var cookie = Core.makeCookie(token).join('|');
respond(e ? String(e): e, [cookie].concat(typeof(value) !== 'undefined' ?value: []));
};
msg.shift();
// discard validated cookie from message
if (!msg.length) {
return void Respond('INVALID_MSG');
}
var TYPE = msg[0];
Env.Log.silly('LOG_RPC', TYPE);
if (typeof(AUTHENTICATED_USER_TARGETED[TYPE]) === 'function') {
return void AUTHENTICATED_USER_TARGETED[TYPE](Env, safeKey, msg[1], function (e, value) {
Env.WARN(e, value);
return void Respond(e, value);
}, Server);
}
if (typeof(AUTHENTICATED_USER_SCOPED[TYPE]) === 'function') {
return void AUTHENTICATED_USER_SCOPED[TYPE](Env, safeKey, function (e, value) {
if (e) {
Env.WARN(e, safeKey);
return void Respond(e);
}
Respond(e, value);
});
}
return void Respond('UNSUPPORTED_RPC_CALL', msg);
};
var rpc = function (Env, Server, userId, data, respond) {
if (!Array.isArray(data)) {
Env.Log.debug('INVALID_ARG_FORMET', data);
return void respond('INVALID_ARG_FORMAT');
}
if (!data.length) {
return void respond("INSUFFICIENT_ARGS");
} else if (data.length !== 1) {
Env.Log.debug('UNEXPECTED_ARGUMENTS_LENGTH', data);
}
var msg = data[0].slice(0);
if (!Array.isArray(msg)) {
return void respond('INVALID_ARG_FORMAT');
}
if (isUnauthenticateMessage(msg)) {
return handleUnauthenticatedMessage(Env, msg, respond, Server, userId);
}
var signature = msg.shift();
var publicKey = msg.shift();
var safeKey = Util.escapeKeyCharacters(publicKey);
var hadSession = Boolean(Env.Sessions[safeKey]);
// make sure a user object is initialized in the cookie jar
if (publicKey) {
Core.getSession(Env.Sessions, publicKey);
} else {
Env.Log.debug("NO_PUBLIC_KEY_PROVIDED", publicKey);
}
var cookie = msg[0];
if (!Core.isValidCookie(Env.Sessions, publicKey, cookie)) {
// no cookie is fine if the RPC is to get a cookie
if (msg[1] !== 'COOKIE') {
return void respond('NO_COOKIE');
}
}
var serialized = JSON.stringify(msg);
if (!(serialized && typeof(publicKey) === 'string')) {
return void respond('INVALID_MESSAGE_OR_PUBLIC_KEY');
}
var command = msg[1];
Env.plugins?.MONITORING?.increment(`rpc_${command}`);
if (command === 'UPLOAD') {
// UPLOAD is a special case that skips signature validation
// intentional fallthrough behaviour
return void handleAuthenticatedMessage(Env, publicKey, msg, respond, Server);
}
if (isAuthenticatedCall(command)) {
// check the signature on the message
// refuse the command if it doesn't validate
return void Env.checkSignature(serialized, signature, publicKey, function (err) {
if (err) {
return void respond("INVALID_SIGNATURE_OR_PUBLIC_KEY");
}
if (command === 'COOKIE' && !hadSession && Env.logIP) {
Env.Log.info('NEW_RPC_SESSION', {userId: userId, publicKey: publicKey});
}
if (command === "DESTROY") {
HK.unauthenticateNetfluxSession(Env, userId, publicKey);
return; // No need to respond, user will close the session
}
HK.authenticateNetfluxSession(Env, userId, publicKey);
return void handleAuthenticatedMessage(Env, publicKey, msg, respond, Server);
});
}
Env.Log.warn('INVALID_RPC_CALL', command);
return void respond("INVALID_RPC_CALL");
};
RPC.create = function (Env, cb) {
var Sessions = Env.Sessions;
var pingAccountsDaily = function () {
Quota.pingAccountsDaily(Env, function (e) {
if (e) {
Env.WARN('dailyPing', e);
}
});
};
pingAccountsDaily();
Env.intervals.dailyPing = setInterval(pingAccountsDaily, 24*3600*1000);
var updateLimitInterval = function () {
Quota.updateCachedLimits(Env, function (e) {
// failure is expected if they have not specified a quota API endpoint
if (!Env.accounts_api) { return; }
if (e) {
Env.WARN('LIMIT_UPDATE', e);
}
});
};
Quota.applyCustomLimits(Env);
updateLimitInterval();
if (Env.accounts_api) {
Env.intervals.quotaUpdate = setInterval(updateLimitInterval, 3600*1000);
}
// expire old sessions once per minute
Env.intervals.sessionExpirationInterval = setInterval(function () {
Core.expireSessions(Sessions);
}, Core.SESSION_EXPIRATION_TIME);
cb(void 0, function (Server, userId, data, respond) {
try {
return rpc(Env, Server, userId, data, respond);
} catch (e) {
console.log("Error from RPC with data " + JSON.stringify(data));
console.log(e.stack);
}
});
};

View File

@ -1,176 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
var WriteQueue = require("./write-queue");
var Util = require("./common-util");
/* This module provides implements a FIFO scheduler
which assumes the existence of three types of async tasks:
1. ordered tasks which must be executed sequentially
2. unordered tasks which can be executed in parallel
3. blocking tasks which must block the execution of all other tasks
The scheduler assumes there will be many resources identified by strings,
and that the constraints described above will only apply in the context
of identical string ids.
Many blocking tasks may be executed in parallel so long as they
concern resources identified by different ids.
USAGE:
const schedule = require("./schedule")();
// schedule two sequential tasks using the resource 'pewpew'
schedule.ordered('pewpew', function (next) {
appendToFile('beep\n', next);
});
schedule.ordered('pewpew', function (next) {
appendToFile('boop\n', next);
});
// schedule a task that can happen whenever
schedule.unordered('pewpew', function (next) {
displayFileSize(next);
});
// schedule a blocking task which will wait
// until the all unordered tasks have completed before commencing
schedule.blocking('pewpew', function (next) {
deleteFile(next);
});
// this will be queued for after the blocking task
schedule.ordered('pewpew', function (next) {
appendFile('boom', next);
});
*/
// return a uid which is not already in a map
var unusedUid = function (set) {
var uid = Util.uid();
if (set[uid]) { return unusedUid(); }
return uid;
};
// return an existing session, creating one if it does not already exist
var lookup = function (map, id) {
return (map[id] = map[id] || {
//blocking: [],
active: {},
blocked: {},
});
};
var isEmpty = function (map) {
for (var key in map) {
if (map.hasOwnProperty(key)) { return false; }
}
return true;
};
module.exports = function () {
// every scheduler instance has its own queue
var queue = WriteQueue();
// ordered tasks don't require any extra logic
var Ordered = function (id, task) {
queue(id, task);
};
// unordered and blocking tasks need a little extra state
var map = {};
// regular garbage collection keeps memory consumption low
var collectGarbage = function (id) {
// avoid using 'lookup' since it creates a session implicitly
var local = map[id];
// bail out if no session
if (!local) { return; }
// bail out if there are blocking or active tasks
if (local.lock) { return; }
if (!isEmpty(local.active)) { return; }
// if there are no pending actions then delete the session
delete map[id];
};
// unordered tasks run immediately if there are no blocking tasks scheduled
// or immediately after blocking tasks finish
var runImmediately = function (local, task) {
// set a flag in the map of active unordered tasks
// to prevent blocking tasks from running until you finish
var uid = unusedUid(local.active);
local.active[uid] = true;
task(function () {
// remove the flag you set to indicate that your task completed
delete local.active[uid];
// don't do anything if other unordered tasks are still running
if (!isEmpty(local.active)) { return; }
// bail out if there are no blocking tasks scheduled or ready
if (typeof(local.waiting) !== 'function') {
return void collectGarbage();
}
setTimeout(local.waiting);
});
};
var runOnceUnblocked = function (local, task) {
var uid = unusedUid(local.blocked);
local.blocked[uid] = function () {
runImmediately(local, task);
};
};
// 'unordered' tasks are scheduled to run in after the most recently received blocking task
// or immediately and in parallel if there are no blocking tasks scheduled.
var Unordered = function (id, task) {
var local = lookup(map, id);
if (local.lock) { return runOnceUnblocked(local, task); }
runImmediately(local, task);
};
var runBlocked = function (local) {
for (var task in local.blocked) {
runImmediately(local, local.blocked[task]);
}
};
// 'blocking' tasks must be run alone.
// They are queued alongside ordered tasks,
// and wait until any running 'unordered' tasks complete before commencing.
var Blocking = function (id, task) {
var local = lookup(map, id);
queue(id, function (next) {
// start right away if there are no running unordered tasks
if (isEmpty(local.active)) {
local.lock = true;
return void task(function () {
delete local.lock;
runBlocked(local);
next();
});
}
// otherwise wait until the running tasks have completed
local.waiting = function () {
local.lock = true;
task(function () {
delete local.lock;
delete local.waiting;
runBlocked(local);
next();
});
};
});
};
return {
ordered: Ordered,
unordered: Unordered,
blocking: Blocking,
};
};

View File

@ -1,88 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Stats = module.exports;
var truthyStringOrNothing = function (s) {
if (typeof(s) !== 'string' || !s) { return undefined; }
return s.trim() || undefined;
};
Stats.instanceData = function (Env) {
var data = {
version: Env.version,
installMethod: Env.installMethod,
domain: Env.accounts_domain,
subdomain: Env.accounts_subdomain,
httpUnsafeOrigin: Env.httpUnsafeOrigin,
httpSafeOrigin: Env.httpSafeOrigin,
adminEmail: Env.consentToContact? Env.adminEmail: undefined,
consentToContact: Boolean(Env.consentToContact),
instancePurpose: Env.instancePurpose === 'noanswer'? undefined: Env.instancePurpose,
};
/* We reserve the right to choose not to include instances
in our public directory at our discretion.
The following details will be included in your telemetry
as factors that may contribute to that decision.
These values are publicly available via /api/config
posting them to our server just makes it easier for us.
*/
if (Env.listMyInstance) {
// clearly indicate that you want to be listed
data.listMyInstance = Env.listMyInstance;
// you should have enabled your admin panel
data.adminKeys = Env.admins.length > 0;
// we expect that you enable your support mailbox
data.supportMailbox = Boolean(Env.supportMailbox);
data.supportMailboxKey = Boolean(Env.supportMailboxKey);
// do you allow registration?
data.restrictRegistration = Boolean(Env.restrictRegistration);
// have you removed the donate button?
data.removeDonateButton = Boolean(Env.removeDonateButton);
// after how long do you consider a document to be inactive?
data.inactiveTime = Env.inactiveTime;
// how much storage do you offer to registered users?
data.defaultStorageLimit = Env.defaultStorageLimit;
// what size file upload do you permit
data.maxUploadSize = Env.maxUploadSize;
// how long do you retain inactive accounts?
data.accountRetentionTime = Env.accountRetentionTime;
// does this instance have a name???
data.instanceName = truthyStringOrNothing(Env.instanceName.default);
// does this instance have a jurisdiction ???
data.instanceJurisdiction = truthyStringOrNothing(Env.instanceJurisdiction.default);
// does this instance have a description???
data.instanceDescription = truthyStringOrNothing(Env.instanceDescription.default);
// how long do you retain archived data?
//data.archiveRetentionTime = Env.archiveRetentionTime,
}
// Admins can opt-in to providing more detailed information about the extent of the instance's usage
if (Env.provideAggregateStatistics) {
// check how many instances provide stats before we put more work into it
data.providesAggregateStatistics = true;
data.statistics = {}; // Filled in lib/commands/quota.js because of async calls
}
return data;
};

View File

@ -1,95 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
/* Mulfi-factor auth requires some rudimentary storage methods
for a number of data types:
* "challenges" (described in challenge.js)
* account settings for MFA (described in mfa.js)
* session tokens (described in sessions.js)
Each data type requires the same three simple methods:
* read
* write
* delete
These could be implemented as tables in a relational database, but committing to a relational DB
is a big decision, so these methods are instead implemented using the filesystem, with each
file's path and naming convention implemented outside of this module.
Feel free to migrate all of these to a relational DB at some point in the future if you like.
*/
const Basic = module.exports;
const Fs = require("node:fs");
const Fse = require("fs-extra");
const Path = require("node:path");
var pathError = (cb) => {
setTimeout(function () {
cb(new Error("INVALID_PATH"));
});
};
Basic.read = function (Env, path, cb) {
if (!path) { return void pathError(cb); }
Fs.readFile(path, 'utf8', (err, content) => {
if (err) { return void cb(err); }
cb(void 0, content);
});
};
Basic.readDir = function (Env, path, cb) {
if (!path) { return void pathError(cb); }
Fs.readdir(path, cb);
};
Basic.readDirSync = function (Env, path) {
if (!path) { return []; }
return Fs.readdirSync(path);
};
Basic.write = function (Env, path, data, cb) {
if (!path) { return void pathError(cb); }
var dirpath = Path.dirname(path);
Fs.mkdir(dirpath, { recursive: true }, function (err) {
if (err) { return void cb(err); }
// the 'wx' flag causes writes to fail with EEXIST if a file is already present at the given path
// this could be overridden with options in the future if necessary, but it seems like a sensible default
Fs.writeFile(path, data, { flag: 'wx', }, cb);
});
};
// TODO I didn't bother implementing the usual "archive/restore/delete-from-archives" methods
// because they didn't seem particularly important for the data implemented with this module.
// They're still worth considering, though, so don't let my ommission stop you.
// Login blocks could probably be implemented with this module if these methods were supported.
// --Aaron
Basic.delete = function (Env, path, cb) {
if (!path) { return void pathError(cb); }
Fs.rm(path, cb);
};
Basic.deleteDir = function (Env, path, cb) {
if (!path) { return void pathError(cb); }
Fs.rm(path, { recursive: true, force: true }, cb);
};
Basic.archive = function (Env, path, archivePath, cb) {
Fse.move(path, archivePath, {
overwrite: true,
}, (err) => {
cb(err);
});
};
Basic.restore = function (Env, archivePath, path, cb) {
Fse.move(archivePath, path, {
//overwrite: true,
}, (err) => {
cb(err);
});
};
Basic.isValidId = id => {
return id && typeof(id) === "string" && /^[a-zA-Z0-9-+_=]+$/.test(id);
};

View File

@ -1,946 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
var Fs = require("fs");
var Fse = require("fs-extra");
var Path = require("path");
var BlobStore = module.exports;
var nThen = require("nthen");
var Semaphore = require("saferphore");
var Util = require("../common-util");
const Crypto = require('crypto');
const PERMISSIVE = 511;
const readFileBin = require("../stream-file").readFileBin;
const BLOB_LENGTH = 48;
var isValidSafeKey = function (safeKey) {
return typeof(safeKey) === 'string' && !/\//.test(safeKey) && safeKey.length === 44;
};
var isValidId = function (id) {
return typeof(id) === 'string' && id.length === BLOB_LENGTH && !/[^a-f0-9]/.test(id);
};
// helpers
var prependArchive = function (Env, path) {
// Env has an absolute path to the blob storage
// we want the path to the blob relative to that
var relativePathToBlob = Path.relative(Env.blobPath, path);
// the new path structure is the same, but relative to the blob archive root
return Path.join(Env.archivePath, 'blob', relativePathToBlob);
};
// /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);
};
var mkPlaceholderPath = function (Env, blobId) {
return makeBlobPath(Env, blobId) + '.placeholder';
};
// Placeholder for deleted files
var addPlaceholder = function (Env, blobId, reason, cb) {
if (!reason) { return cb(); }
var path = mkPlaceholderPath(Env, blobId);
var s_data = typeof(reason) === "string" ? reason : `${reason.code}:${reason.txt}`;
Fs.writeFile(path, s_data, cb);
};
var clearPlaceholder = function (Env, blobId, cb) {
var path = mkPlaceholderPath(Env, blobId);
Fs.unlink(path, cb);
};
var readPlaceholder = function (Env, blobId, cb) {
var path = mkPlaceholderPath(Env, blobId);
Fs.readFile(path, function (err, content) {
if (err) { return void cb(); }
cb(content.toString('utf8'));
});
};
// getUploadSize: used by
// getFileSize
var getUploadSize = function (Env, blobId, cb) {
var path = makeBlobPath(Env, blobId);
if (!path) { return cb('INVALID_UPLOAD_ID'); }
Fs.stat(path, function (err, stats) {
if (err) {
// if a file was deleted, its size is 0 bytes
if (err.code === 'ENOENT') {
return readPlaceholder(Env, blobId, (content) => {
if (!content) { return cb(void 0, 0); }
cb({
code: err.code,
reason: content
});
});
}
return void cb(err.code);
}
cb(void 0, stats.size);
});
};
// isFile: used by
// removeOwnedBlob
// uploadComplete
// uploadStatus
var isFile = function (filePath, cb) {
Fs.stat(filePath, function (e, stats) {
if (e) {
if (e.code === 'ENOENT') { return void cb(void 0, false); }
return void cb(e.message);
}
return void cb(void 0, stats.isFile());
});
};
// 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) {
if (e || !full) { // !full for pleasing flow, it's already checked
return void cb(e ? e.message : 'INTERNAL_ERROR');
}
try {
var stream = Fs.createWriteStream(full, {
flags: 'a',
encoding: 'binary',
highWaterMark: Math.pow(2, 16),
});
stream.on('open', function () {
cb(void 0, stream);
});
stream.on('error', function (err) {
cb(err);
});
} catch (err) {
cb('BAD_STREAM');
}
});
};
var clearActivity = function (Env, blobId, cb) {
var path = makeActivityPath(Env, blobId);
// if we fail to delete the activity file, it can still be removed later by the eviction script
Fs.unlink(path, cb);
};
var updateActivity = function (Env, blobId, cb) {
var path = makeActivityPath(Env, blobId);
var blobPath = makeBlobPath(Env, blobId);
isFile(blobPath, (err, state) => {
if (err || !state) { return void cb(); }
var s_data = String(+new Date());
Fs.writeFile(path, s_data, cb);
});
};
var archiveActivity = function (Env, blobId, cb) {
var path = makeActivityPath(Env, blobId);
var archivePath = prependArchive(Env, path);
// if we fail to delete the activity file, it can still be removed later by the eviction script
Fse.move(path, archivePath, { overwrite: true }, cb);
};
var removeArchivedActivity = function (Env, blobId, cb) {
var path = makeActivityPath(Env, blobId);
var archivePath = prependArchive(Env, path);
Fs.unlink(archivePath, cb);
};
var restoreActivity = function (Env, blobId, cb) {
var path = makeActivityPath(Env, blobId);
var archivePath = prependArchive(Env, path);
Fse.move(archivePath, path, cb);
};
var getActivity = function (Env, blobId, cb) {
var path = makeActivityPath(Env, blobId);
Fs.readFile(path, function (err, content) {
if (err) { return void cb(err); }
try {
var date = new Date(+content);
cb(void 0, date);
} catch (err2) {
cb(err2);
}
});
};
// 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);
// TODO 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 uploadWs = function (Env, safeKey, content, cb) {
var dec;
try { dec = Buffer.from(content, 'base64'); }
catch (e) { return void cb('DECODE_BUFFER'); }
var len = dec.length;
var session = Env.getSession(safeKey);
/*
if (typeof(session.currentUploadSize) !== 'number' ||
typeof(session.pendingUploadSize) !== 'number') {
// improperly initialized... maybe they didn't check before uploading?
// reject it, just in case
return cb('NOT_READY');
}
if (session.currentUploadSize > session.pendingUploadSize) {
return cb('E_OVER_LIMIT');
}
*/
var stagePath = makeStagePath(Env, safeKey);
if (!session.blobstage) {
makeFileStream(stagePath, function (e, stream) {
if (!stream) { return void cb(e); }
var blobstage = session.blobstage = stream;
blobstage.write(dec);
session.currentUploadSize += len;
cb(void 0, dec.length);
});
} else {
session.blobstage.write(dec);
session.currentUploadSize += len;
cb(void 0, dec.length);
}
};
var upload = function (Env, safeKey, content, cb) {
var dec;
try { dec = Buffer.from(content, 'base64'); }
catch (e) { return void cb('DECODE_BUFFER'); }
var path = makeStagePath(Env, safeKey);
Fs.appendFile(path, dec, cb);
};
const getRandomCookie = function () {
return Crypto.randomBytes(16).toString('hex');
};
var uploadCookie = function (Env, safeKey, cb) {
var stagePath = makeStagePath(Env, safeKey);
var cookiePath = stagePath + '.cookie';
const cookie = getRandomCookie();
Fse.mkdirp(Path.dirname(cookiePath), PERMISSIVE, function (err) {
if (err && err.code !== 'EEXIST') { return void cb(err); }
Fs.writeFile(cookiePath, cookie, err => {
cb(err, cookie);
});
});
};
var checkUploadCookie = function (Env, safeKey, cb) {
var stagePath = makeStagePath(Env, safeKey);
var cookiePath = stagePath + '.cookie';
Fs.readFile(cookiePath, function (err, content) {
if (err) { return void cb(); }
let expireTime = +new Date() - (5*60*1000);
Fs.stat(cookiePath, function (err, stats) {
if (stats.mtime < expireTime) { return void cb(); }
cb(content.toString('utf8'));
});
});
};
var closeBlobstage = function (Env, safeKey) {
var session = Env.getSession(safeKey);
if (!(session && session.blobstage && typeof(session.blobstage.close) === 'function')) {
return;
}
session.blobstage.close();
delete session.blobstage;
};
// upload_cancel
var upload_cancel = function (Env, safeKey, fileSize, cb) {
var session = Env.getSession(safeKey);
session.pendingUploadSize = fileSize;
session.currentUploadSize = 0;
if (session.blobstage) {
session.blobstage.close();
delete session.blobstage;
}
var path = makeStagePath(Env, safeKey);
Fs.unlink(path, function (e) {
if (e) { return void cb('E_UNLINK'); }
cb(void 0);
});
};
// upload_complete
var upload_complete = function (Env, safeKey, id, cb, linked) {
closeBlobstage(Env, safeKey);
var oldPath = makeStagePath(Env, safeKey);
var newPath = makeBlobPath(Env, id);
nThen(function (w) {
// make sure the path to your final location exists
Fse.mkdirp(Path.dirname(newPath), w(function (e) {
if (e) {
w.abort();
return void cb('RENAME_ERR');
}
}));
}).nThen(function (w) {
// make sure there's not already something in that exact location
isFile(newPath, w(function (e, yes) {
if (e) {
w.abort();
return void cb(e);
}
if (yes) {
w.abort();
return void cb('RENAME_ERR');
}
cb(void 0, id);
}));
}).nThen(function (w) {
if (!linked) { return; }
// Write the metadata
let meta = { linked };
let md = JSON.stringify(meta);
writeMetadata(Env, id, md, w());
}).nThen(function () {
// finally, move the old file to the new path
// FIXME we could just move and handle the EEXISTS instead of the above block
Fse.move(oldPath, newPath, function (e) {
if (e) { return void cb('RENAME_ERR'); }
// clear upload cookie
Fs.unlink(oldPath+'.cookie', function () {});
cb(void 0, id);
});
});
};
var tryId = function (path, cb) {
Fs.access(path, Fs.constants.R_OK | Fs.constants.W_OK, function (e) {
if (!e) {
// generate a new id (with the same prefix) and recurse
return void cb('EEXISTS');
} else if (e.code === 'ENOENT') {
// no entry, so it's safe for us to proceed
return void cb();
} else {
// it failed in an unexpected way. log it
return void cb(e.code);
}
});
};
// owned_upload_complete
let unescapeKeyCharacters = function (key) {
return key.replace(/\-/g, '/');
};
var owned_upload_complete = function (Env, safeKey, id, cb, linked) {
closeBlobstage(Env, safeKey);
if (!isValidId(id)) {
return void cb('EINVAL_ID');
}
var oldPath = makeStagePath(Env, safeKey);
if (typeof(oldPath) !== 'string') {
return void cb('EINVAL_CONFIG');
}
var finalPath = makeBlobPath(Env, id);
let unsafeKey = unescapeKeyCharacters(safeKey);
// 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
Fse.mkdirp(Path.dirname(finalPath), 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) {
if (e) {
w.abort();
return void cb(e);
}
}));
}).nThen(function (w) {
// Write the metadata
let meta = { owners: [unsafeKey] };
if (linked) { meta.linked = linked; }
let md = JSON.stringify(meta);
writeMetadata(Env, id, md, w((e) => {
if (e) {
w.abort();
return void cb(e.code);
}
// otherwise it worked...
}));
}).nThen(function (w) {
// move the existing file to its new path
Fse.move(oldPath, finalPath, w(function (e) {
if (e) {
w.abort();
return void cb(e.code);
}
// otherwise it worked...
}));
}).nThen(function () {
// clear upload cookie
Fs.unlink(oldPath+'.cookie', function () {});
// clean up their session when you're done
// call back with the blob id...
cb(void 0, id);
});
};
// removeBlob
var remove = function (Env, blobId, cb) {
var blobPath = makeBlobPath(Env, blobId);
Fs.unlink(blobPath, cb);
clearActivity(Env, blobId, () => {});
};
// 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, () => {});
};
var removeArchivedBlob = function (Env, blobId, cb) {
var CB = Util.once(cb);
var archivePath = prependArchive(Env, makeBlobPath(Env, blobId));
var metadataPath = prependArchive(Env, mkMetadataPath(Env, blobId));
Fs.unlink(archivePath, cb);
nThen(function (w) {
Fs.unlink(archivePath, w(function (err) {
if (err) {
if (err.code === "ENOENT") { return; }
w.abort();
CB("E_ARCHIVED_BLOB_REMOVAL_"+ err.code);
}
}));
Fs.unlink(metadataPath, w(function (err) {
if (err) {
if (err.code === "ENOENT") { return; }
w.abort();
CB("E_ARCHIVED_BLOBMD_REMOVAL_"+ err.code);
}
}));
removeArchivedActivity(Env, blobId, () => {});
}).nThen(function () {
CB();
});
};
// restoreBlob
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, () => {});
};
var makeWalker = function (n, handleChild, done) {
if (!n || typeof(n) !== 'number' || n < 2) { n = 2; }
var W;
nThen(function (w) {
// this asynchronous bit defers the completion of this block until
// synchronous execution has completed. This means you must create
// the walker and start using it synchronously or else it will call back
// prematurely
setTimeout(w());
W = w;
}).nThen(function () {
done();
});
// do no more than 20 jobs at a time
var tasks = Semaphore.create(n);
var recurse = function (path, dir) {
tasks.take(function (give) {
var next = give(W());
nThen(function (w) {
// check if the path is a directory...
Fs.stat(path, w(function (err, stats) {
if (err) {
w.abort();
return next();
}
if (!stats.isDirectory()) {
w.abort();
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.
if (!dir.includes(Path.basename(path.replace(/\.activity$/, '')))) {
return void handleChild(void 0, path, next, true);
}
// Ignore valid activity files
return next();
}
// Ignore placeholder files
if (/\.placeholder$/.test(path)) { return next(); }
return void handleChild(void 0, path, next, false);
}
// fall through
}));
}).nThen(function () {
// handle directories
Fs.readdir(path, function (err, dir) {
if (err) { return next(); }
// everything is fine and it's a directory...
dir.forEach(function (d) {
recurse(Path.join(path, d), dir);
});
next();
});
});
});
};
return recurse;
};
var getActivityStat = function (path, base, cb) {
var suffix = base ? '' : '.activity';
Fs.stat(path+suffix, function (err, stats) {
if (err && err.code === 'ENOENT' && !base) { return getActivityStat(path, true, cb); }
cb(err, stats);
});
};
var getStats = function (Env, blobId, cb) {
var path = makeBlobPath(Env, blobId);
getActivityStat(path, false, cb);
};
let blobRegex = /^[0-9a-fA-F]{48}(\.metadata)*(\.ndjson)*$/;
var listBlobs = function (root, handler, fast, cb) {
var dirList = [];
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?
const s = new Set(list);
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 (s.has(blobName)) { 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();
});
};
var cleanLoneActivity = function (root, 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(); }
Fs.unlink(path, function (err) {
if (err) {
return console.error('ERROR', path, err);
}
console.log('DELETED', path);
next();
});
}, function () {
cb();
});
dir.forEach(function (d) {
if (d.length !== 2) { return; }
walk(Path.join(root, d));
});
});
};
BlobStore.create = function (config, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (typeof(config.getSession) !== 'function') {
return void cb("getSession method required");
}
var Env = {
blobPath: config.blobPath || './blob',
blobStagingPath: config.blobStagingPath || './blobstage',
archivePath: config.archivePath || './data/archive',
getSession: config.getSession,
};
nThen(function (w) {
var CB = Util.both(w.abort, cb);
Fse.mkdirp(Env.blobPath, w(function (e) {
if (e) { CB(e); }
}));
Fse.mkdirp(Env.blobStagingPath, w(function (e) {
if (e) { CB(e); }
}));
Fse.mkdirp(Path.join(Env.archivePath, './blob'), w(function (e) {
if (e) { CB(e); }
}));
}).nThen(function (w) {
// make a placeholder file in the root of the blob path
// so that the checkup page always has a resource it can check
var fullPath = Path.join(Env.blobPath, 'placeholder.txt');
Fse.writeFile(fullPath, 'PLACEHOLDER\n', w());
}).nThen(function () {
var methods = {
BLOB_LENGTH: BLOB_LENGTH,
isFileId: isValidId,
status: function (safeKey, _cb) {
// TODO check if the final destination is a file
// because otherwise two people can try to upload to the same location
// and one will fail, invalidating their hard work
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
isFile(makeStagePath(Env, safeKey), cb);
},
uploadWs: function (safeKey, content, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
uploadWs(Env, safeKey, content, Util.once(Util.mkAsync(cb)));
},
upload: function (safeKey, content, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
upload(Env, safeKey, content, Util.once(Util.mkAsync(cb)));
},
uploadCookie: function (safeKey, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
uploadCookie(Env, safeKey, Util.once(Util.mkAsync(cb)));
},
checkUploadCookie: function (safeKey, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
checkUploadCookie(Env, safeKey, Util.once(Util.mkAsync(cb)));
},
cancel: function (safeKey, fileSize, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
if (typeof(fileSize) !== 'number' || isNaN(fileSize) || fileSize <= 0) { return void cb("INVALID_FILESIZE"); }
upload_cancel(Env, safeKey, fileSize, cb);
},
isOwnedBy: function (safeKey, blobId, _cb) {
var cb = Util.once(Util.mkAsync(_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) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
remove(Env, 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);
},
},
loneActivity: function (_cb) {
var cb = Util.once(Util.mkAsync(_cb));
cleanLoneActivity(Env.blobPath, cb);
}
},
archive: {
blob: function (blobId, reason, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
archiveBlob(Env, blobId, reason, cb);
},
},
restore: {
blob: function (blobId, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
restoreBlob(Env, blobId, cb);
},
},
isBlobAvailable: function (blobId, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
var path = makeBlobPath(Env, blobId);
isFile(path, cb);
},
isBlobArchived: function (blobId, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
var path = prependArchive(Env, makeBlobPath(Env, blobId));
isFile(path, cb);
},
getPlaceholder: function (blobId, cb) {
readPlaceholder(Env, blobId, cb);
},
closeBlobstage: function (safeKey) {
closeBlobstage(Env, safeKey);
},
complete: function (safeKey, id, _cb, linked) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
if (!isValidId(id)) { return void cb("INVALID_ID"); }
upload_complete(Env, safeKey, id, cb, linked);
},
completeOwned: function (safeKey, id, _cb, linked) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
if (!isValidId(id)) { return void cb("INVALID_ID"); }
owned_upload_complete(Env, safeKey, id, cb, linked);
},
size: function (id, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidId(id)) { return void cb("INVALID_ID"); }
getUploadSize(Env, id, cb);
},
// ACTIVITY
updateActivity: function (id, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidId(id)) { return void cb("INVALID_ID"); }
updateActivity(Env, id, cb);
},
getActivity: function (id, _cb) {
var cb = Util.once(Util.mkAsync(_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, fast) {
var cb = Util.once(Util.mkAsync(_cb));
listBlobs(Env.blobPath, handler, fast, cb);
},
archived: {
blobs: function (handler, _cb, fast) {
var cb = Util.once(Util.mkAsync(_cb));
listBlobs(prependArchive(Env, Env.blobPath), handler, fast, cb);
},
}
},
};
cb(void 0, methods);
});
};

View File

@ -1,177 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Block = module.exports;
const Util = require("../common-util");
const Path = require("path");
const Fs = require("fs");
const Fse = require("fs-extra");
const nThen = require("nthen");
Block.mkPath = function (Env, publicKey) {
// prepare publicKey to be used as a file name
var safeKey = Util.escapeKeyCharacters(publicKey);
// validate safeKey
if (typeof(safeKey) !== 'string') { return; }
// derive the full path
// /home/cryptpad/cryptpad/block/fg/fg32kefksjdgjkewrjksdfksjdfsdfskdjfsfd
return Path.join(Env.paths.block, safeKey.slice(0, 2), safeKey);
};
Block.mkArchivePath = function (Env, publicKey) {
// prepare publicKey to be used as a file name
var safeKey = Util.escapeKeyCharacters(publicKey);
// validate safeKey
if (typeof(safeKey) !== 'string') {
return;
}
// derive the full path
// /home/cryptpad/cryptpad/block/fg/fg32kefksjdgjkewrjksdfksjdfsdfskdjfsfd
return Path.join(Env.paths.archive, 'block', safeKey.slice(0, 2), safeKey);
};
var mkPlaceholderPath = function (Env, publicKey) {
return Block.mkPath(Env, publicKey) + '.placeholder';
};
var addPlaceholder = function (Env, publicKey, reason, cb) {
if (!reason) { return cb(); }
var path = mkPlaceholderPath(Env, publicKey);
var s_data = typeof(reason) === "string" ? reason : `${reason.code}:${reason.txt}`;
Fs.writeFile(path, s_data, cb);
};
var clearPlaceholder = function (Env, publicKey, cb) {
var path = mkPlaceholderPath(Env, publicKey);
Fs.unlink(path, cb);
};
Block.readPlaceholder = function (Env, publicKey, cb) {
var path = mkPlaceholderPath(Env, publicKey);
Fs.readFile(path, function (err, content) {
if (err) { return void cb(); }
cb(content.toString('utf8'));
});
};
Block.archive = function (Env, publicKey, reason, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
// derive the filepath
var currentPath = Block.mkPath(Env, publicKey);
// make sure the path is valid
if (typeof(currentPath) !== 'string') {
return void cb('E_INVALID_BLOCK_PATH');
}
var archivePath = Block.mkArchivePath(Env, publicKey);
// make sure the path is valid
if (typeof(archivePath) !== 'string') {
return void cb('E_INVALID_BLOCK_ARCHIVAL_PATH');
}
// TODO Env.incrementBytesWritten
Fse.move(currentPath, archivePath, {
overwrite: true,
}, (err) => {
cb(err);
if (!err && reason) { addPlaceholder(Env, publicKey, reason, () => {}); }
});
};
Block.restore = function (Env, publicKey, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
// derive the filepath
var livePath = Block.mkPath(Env, publicKey);
// make sure the path is valid
if (typeof(livePath) !== 'string') {
return void cb('E_INVALID_BLOCK_PATH');
}
var archivePath = Block.mkArchivePath(Env, publicKey);
// make sure the path is valid
if (typeof(archivePath) !== 'string') {
return void cb('E_INVALID_BLOCK_ARCHIVAL_PATH');
}
// TODO Env.incrementBytesWritten
Fse.move(archivePath, livePath, {
//overwrite: true,
}, (err) => {
cb(err);
if (!err) { clearPlaceholder(Env, publicKey, () => {}); }
});
};
var isValidKey = Block.isValidKey = function (publicKey) {
return typeof(publicKey) === 'string' && publicKey.length === 44;
};
var exists = function (path, cb) {
Fs.stat(path, function (err, stat) {
if (err) {
if (err.code === 'ENOENT') {
return void cb(void 0, false);
}
return void cb(err);
}
if (!stat.isFile()) { return void cb('E_NOT_FILE'); }
return void cb(void 0, true);
});
};
var checkPath = function (Env, publicKey, pathFunction, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidKey(publicKey)) { return void cb("INVALID_ARGS"); }
var path = pathFunction(Env, publicKey);
exists(path, cb);
};
Block.isAvailable = function (Env, publicKey, _cb) {
checkPath(Env, publicKey, Block.mkPath, _cb);
};
Block.isArchived = function (Env, publicKey, _cb) {
checkPath(Env, publicKey, Block.mkArchivePath, _cb);
};
Block.check = function (Env, publicKey, _cb) { // 'check' because 'exists' implies boolean
var cb = Util.once(Util.mkAsync(_cb));
var path = Block.mkPath(Env, publicKey);
Fs.access(path, Fs.constants.F_OK, cb);
};
Block.MAX_SIZE = 256;
Block.write = function (Env, publicKey, buffer, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
var path = Block.mkPath(Env, publicKey);
if (typeof(path) !== 'string') { return void cb('INVALID_PATH'); }
var parsed = Path.parse(path);
nThen(function (w) {
Fse.mkdirp(parsed.dir, w(function (err) {
if (!err) { return; }
w.abort();
cb(err);
}));
}).nThen(function (w) {
Block.archive(Env, publicKey, 'PASSWORD_CHANGE', w(function (/* err */) {
/*
we proceed even if there are errors.
it might be ENOENT (there is no file to archive)
or EACCES (bad filesystem permissions for the existing archived block?)
or lots of other things, none of which justify preventing the write
*/
}));
}).nThen(function () {
Fs.writeFile(path, buffer, { encoding: 'binary' }, cb);
Env.incrementBytesWritten(buffer && buffer.length);
});
};

View File

@ -1,45 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Basic = require("./basic.js");
const Path = require("node:path");
const Challenge = module.exports;
/* This module manages storage used to implement a public-key authenticated
challenge-response protocol.
Each 'challenge' is only intended to be valid for a short period of time.
1. A client makes a request of the server
2. The server stores their request with a nonce and challenges them to sign for this request
3. If the client successfully signs for the request within a short window then the request is executed
4. Whether the signature is valid or not, the challenge is removed
Thus, we only expect challenges to remain in storage if the request was aborted or interrupted
for some unexpected reason.
Some form of garbage collection should be implemented in the future.
*/
const pathFromId = function (Env, id) {
if (!Basic.isValidId(id)) {
return void console.error('CHALLENGE_BAD_ID', id);
}
return Path.join(Env.paths.base, "challenges", id.slice(0, 2), id);
};
Challenge.read = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.read(Env, path, cb);
};
Challenge.write = function (Env, id, data, cb) {
var path = pathFromId(Env, id);
Basic.write(Env, path, data, cb);
};
Challenge.delete = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.delete(Env, path, cb);
};

File diff suppressed because it is too large Load Diff

View File

@ -1,84 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Basic = require("./basic.js");
const Path = require("node:path");
const nThen = require('nthen');
const Util = require('../common-util');
const Invite = module.exports;
/* This module manages storage used to implement instance invitations when registration
is closed. This "database" will store individual invitation and their state.
An invitation is created with a random uid and an alias (username, email, etc.)
Once it is used by the user, their newly created blockId is added which will mark
it as completed.
*/
const pathFromId = function (Env, id) {
if (!Basic.isValidId(id)) {
return void console.error('INVITE_BAD_ID', id);
}
return Path.join(Env.paths.base, "invitations", id.slice(0, 2), id);
};
Invite.read = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.read(Env, path, (err, data) => {
if (err) { return void cb(err.code); }
cb(void 0, Util.tryParse(data));
});
};
Invite.getAll = function (Env, cb) {
let invitations = {};
nThen((waitFor) => {
let dirPath = Path.join(Env.paths.base, "invitations");
Basic.readDir(Env, dirPath, waitFor((err, prefixes) => {
if (err && err.code === 'ENOENT') { return void cb(void 0, {}); }
if (err) { waitFor.abort(); return void cb(err.code); }
prefixes.forEach((prefix) => {
var dirPath2 = Path.join(Env.paths.base, "invitations", prefix);
Basic.readDir(Env, dirPath2, waitFor((err, files) => {
if (err) { waitFor.abort(); return void cb(err.code); }
files.forEach((id) => {
Invite.read(Env, id, waitFor((err, data) => {
invitations[id] = data || { error: err };
}));
});
}));
});
}));
}).nThen(() => {
cb(null, invitations);
});
};
Invite.write = function (Env, id, data, cb) {
var path = pathFromId(Env, id);
Basic.write(Env, path, JSON.stringify(data), (err) => {
if (err) { return void cb(err.code); }
cb();
});
};
Invite.delete = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.delete(Env, path, (err) => {
if (err) { return void cb(err.code); }
cb();
});
};
Invite.update = function (Env, id, data, cb) {
Invite.delete(Env, id, (err) => {
if (err) { return void cb(err); }
Invite.write(Env, id, data, cb);
});
};

View File

@ -1,93 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Basic = require("./basic");
const Path = require("node:path");
const Util = require("../common-util");
const Sessions = require("./sessions");
const nThen = require("nthen");
const MFA = module.exports;
/*
This module manages storage related to accounts' multi-factor authentication settings.
These settings are checked every time a block is accessed, so we do as little as possible
so that it can be accessed quickly.
*/
/* The path for a given account's settings is based on the public signing key
which identifies its "login block". We expect that any action to create or access
a block will be authenticated with a challenge-response protocol, so we
don't bother checking the validity of an identifier in here aside from
ensuring that it won't throw when using string methods.
*/
var pathFromId = function (Env, id) {
if (!id || typeof(id) !== 'string') { return; }
id = Util.escapeKeyCharacters(id);
if (!Basic.isValidId(id)) { return; }
return Path.join(Env.paths.base, "mfa", id.slice(0, 2), `${id}.json`);
};
MFA.read = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.read(Env, path, cb);
};
// data should be a string
MFA.write = function (Env, id, data, cb) {
var path = pathFromId(Env, id);
Basic.write(Env, path, data, cb);
};
MFA.delete = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.delete(Env, path, cb);
};
MFA.revoke = function (Env, publicKey, cb) {
nThen(function (w) {
MFA.delete(Env, publicKey, w(function (err) {
if (!err) { return; }
w.abort();
Env.Log.error('TOTP_REVOKE_MFA_DELETE', {
error: err,
publicKey: publicKey,
});
cb('MFA_ERROR');
}));
}).nThen(function () {
Sessions.deleteUser(Env, publicKey, function (err) {
if (!err) { return; }
// If we can't delete the sessions, don't send an error, just log to the server.
// The MFA will still be correctly disabled as long as the first step is done.
Env.Log.error('TOTP_REVOKE_SESSIONS__DELETE', {
error: err,
publicKey: publicKey,
});
});
}).nThen(function () {
cb(void 0, {
success: true
});
});
};
MFA.copy = function (Env, oldKey, newKey, cb) {
let content;
nThen(function (w) {
MFA.read(Env, oldKey, w(function (err, c) {
if (err) {
// No MFA configured, nothing to copy
w.abort();
return void cb();
}
content = c;
}));
}).nThen(function () {
MFA.write(Env, newKey, content, cb);
});
};

View File

@ -1,91 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Basic = require("./basic.js");
const Path = require("node:path");
const nThen = require('nthen');
const Util = require('../common-util');
const Moderator = module.exports;
/* This module manages storage used to implement user management. "Known users" can
be added here in order to store their public key, their block ID and an alias
used to recognize them.
*/
const pathFromId = function (Env, id) {
id = Util.escapeKeyCharacters(id || '');
if (!Basic.isValidId(id)) {
return void console.error('MODERATOR_BAD_ID', id);
}
return Path.join(Env.paths.base, "support", id.slice(0, 2), id);
};
Moderator.read = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.read(Env, path, (err, data) => {
if (err) { return void cb(err.code); }
cb(void 0, Util.tryParse(data));
});
};
Moderator.getAllKeys = function (Env) {
let keys = [];
let dirPath = Path.join(Env.paths.base, "support");
try {
let prefixes = Basic.readDirSync(Env, dirPath);
prefixes.forEach((prefix) => {
let dirPath2 = Path.join(Env.paths.base, "support", prefix);
try {
let newKeys = Basic.readDirSync(Env, dirPath2);
keys.push(...newKeys);
} catch (e) {}
});
return keys;
} catch (e) {
// ENOENT, return empty array
return [];
}
};
Moderator.getAll = function (Env, cb) {
let users = {};
nThen((waitFor) => {
let dirPath = Path.join(Env.paths.base, "support");
Basic.readDir(Env, dirPath, waitFor((err, prefixes) => {
if (err && err.code === 'ENOENT') { return void cb(void 0, {}); }
if (err) { waitFor.abort(); return void cb(err.code); }
prefixes.forEach((prefix) => {
var dirPath2 = Path.join(Env.paths.base, "support", prefix);
Basic.readDir(Env, dirPath2, waitFor((err, files) => {
if (err) { waitFor.abort(); return void cb(err.code); }
files.forEach((id) => {
Moderator.read(Env, id, waitFor((err, data) => {
users[id] = data || { error: err };
}));
});
}));
});
}));
}).nThen(() => {
cb(null, users);
});
};
Moderator.write = function (Env, id, data, cb) {
var path = pathFromId(Env, id);
Basic.write(Env, path, JSON.stringify(data), (err) => {
if (err) { return void cb(err.code); }
cb();
});
};
Moderator.delete = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.delete(Env, path, (err) => {
if (err) { return void cb(err.code); }
cb();
});
};

View File

@ -1,76 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Basic = require("./basic");
const Path = require("node:path");
const Nacl = require("tweetnacl/nacl-fast");
const Util = require("../common-util");
const Sessions = module.exports;
/* This module manages storage for per-acccount session tokens - currently assumed to be
JSON Web Tokens (JWTs).
Decisions about what goes into each of those JWTs happens upstream, so the storage
itself is relatively unopinionated.
The key things to understand are:
* valid sessions allow the holder of a given JWT to access a given "login block"
* JWTs are signed with a key held in the server's memory. If that key leaks then it should be rotated (with the SET_BEARER_SECRET decree) to invalidate all existing JWTs. Under these conditions then all tokens signed with the old key can be removed. Garbage collection of these older tokens is not implemented.
* it is expected that any given login-block can have multiple active sessions (for different devices, or if their browser clears its cache automatically). All sessions for a given block are stored in a per-user directory which is intended to make listing or iterating over them simple.
* It could be desirable to expose the list of sessions to the relevant user and allow them to revoke sessions individually or en-masse, though this is not currently implemented.
*/
var pathFromId = function (Env, id, ref) {
if (!id || typeof(id) !== 'string') { return; }
if (!Basic.isValidId(ref)) { return; }
id = Util.escapeKeyCharacters(id);
return Path.join(Env.paths.base, "sessions", id.slice(0, 2), id, ref);
};
Sessions.randomId = () => Util.encodeBase64(Nacl.randomBytes(24)).replace(/\//g, '-');
Sessions.read = function (Env, id, ref, cb) {
var path = pathFromId(Env, id, ref);
Basic.read(Env, path, cb);
};
Sessions.write = function (Env, id, ref, data, cb) {
var path = pathFromId(Env, id, ref);
Basic.write(Env, path, data, cb);
};
Sessions.delete = function (Env, id, ref, cb) {
var path = pathFromId(Env, id, ref);
Basic.delete(Env, path, cb);
};
Sessions.update = function (Env, id, oldId, ref, dataStr, cb) {
var data = Util.tryParse(dataStr);
Sessions.read(Env, oldId, ref, (err, oldData) => {
let content = Util.tryParse(oldData) || {};
Object.keys(data || {}).forEach((type) => {
content[type] = data[type];
});
Sessions.delete(Env, oldId, ref, () => {
Sessions.write(Env, id, ref, JSON.stringify(content), cb);
});
});
};
Sessions.deleteUser = function (Env, id, cb) {
if (!id || typeof(id) !== 'string') { return; }
id = Util.escapeKeyCharacters(id);
var dirPath = Path.join(Env.paths.base, "sessions", id.slice(0, 2), id);
Basic.readDir(Env, dirPath, (err, files) => {
var checkContent = !files || (Array.isArray(files) && files.every((file) => {
return file && file.length === 32;
}));
if (!checkContent) { return void cb('INVALID_SESSIONS_DIR'); }
Basic.deleteDir(Env, dirPath, cb);
});
};

View File

@ -1,111 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Basic = require("./basic");
const Path = require("node:path");
const Util = require("../common-util");
const SSO = module.exports;
/* This module manages storage related to Single Sign-On (SSO) settings.
A first part (sso-requests) contains temporary files for sso authentication with a remote service
A second part (sso-users) is a database of accounts registered via SSO (SSO id ==> block seed)
A third part (sso-blocks) is a database of blocks that are sso-protected (block id ==> SSO id...)
The path for requests is based on the "authentication request" token depending on the type of SSO.
The path for the user database is based on their persistent identifier (id) from the SSO.
*/
var pathFromId = function (Env, id, subPath) {
if (!id || typeof(id) !== 'string') { return; }
id = Util.escapeKeyCharacters(id);
if (!Basic.isValidId(id)) { return; }
return Path.join(Env.paths.base, subPath, id.slice(0, 2), `${id}.json`);
};
var reqPathFromId = function (Env, id) {
return pathFromId(Env, id, 'sso_request');
};
var blockPathFromId = function (Env, id) {
return pathFromId(Env, id, 'sso_block');
};
var userPathFromId = function (Env, id, provider) {
if (!id || typeof(id) !== 'string') { return; }
if (!provider || typeof(provider) !== 'string') { return; }
id = Util.escapeKeyCharacters(id);
return Path.join(Env.paths.base, 'sso_user', provider, id.slice(0, 2), `${id}.json`);
};
var blockArchivePath = function (Env, id) {
return Path.join(Env.paths.archive, 'sso_block', id.slice(0, 2), `${id}.json`);
};
var userArchivePath = function (Env, id, provider) {
return Path.join(Env.paths.archive, 'sso_user', provider, id.slice(0, 2), `${id}.json`);
};
const Req = SSO.request = {};
Req.read = function (Env, id, cb) {
var path = reqPathFromId(Env, id);
Basic.read(Env, path, cb);
};
Req.write = function (Env, id, data, cb) {
var path = reqPathFromId(Env, id);
Basic.write(Env, path, data, cb);
};
Req.delete = function (Env, id, cb) {
var path = reqPathFromId(Env, id);
Basic.delete(Env, path, cb);
};
const User = SSO.user = {};
User.read = function (Env, provider, id, cb) {
var path = userPathFromId(Env, id, provider);
Basic.read(Env, path, cb);
};
User.write = function (Env, provider, id, data, cb) {
var path = userPathFromId(Env, id, provider);
Basic.write(Env, path, data, cb);
};
User.delete = function (Env, provider, id, cb) {
var path = userPathFromId(Env, id, provider);
Basic.delete(Env, path, cb);
};
User.archive = function (Env, provider, id, cb) {
var path = userPathFromId(Env, id, provider);
var archivePath = userArchivePath(Env, id, provider);
Basic.archive(Env, path, archivePath, cb);
};
User.restore = function (Env, provider, id, cb) {
var path = userPathFromId(Env, id, provider);
var archivePath = userArchivePath(Env, id, provider);
Basic.restore(Env, archivePath, path, cb);
};
const Block = SSO.block = {};
Block.read = function (Env, id, cb) {
var path = blockPathFromId(Env, id);
Basic.read(Env, path, cb);
};
Block.write = function (Env, id, data, cb) {
var path = blockPathFromId(Env, id);
Basic.write(Env, path, data, cb);
};
Block.delete = function (Env, id, cb) {
var path = blockPathFromId(Env, id);
Basic.delete(Env, path, cb);
};
Block.archive = function (Env, id, cb) {
var path = blockPathFromId(Env, id);
var archivePath = blockArchivePath(Env, id);
Basic.archive(Env, path, archivePath, cb);
};
Block.restore = function (Env, id, cb) {
var path = blockPathFromId(Env, id);
var archivePath = blockArchivePath(Env, id);
Basic.restore(Env, archivePath, path, cb);
};

View File

@ -1,403 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
var Fs = require("fs");
var Fse = require("fs-extra");
var Path = require("path");
var nacl = require("tweetnacl/nacl-fast");
var nThen = require("nthen");
var Util = require('../common-util');
var Tasks = module.exports;
var tryParse = function (s) {
try { return JSON.parse(s); }
catch (e) { return null; }
};
var encode = function (time, command, args) {
if (typeof(time) !== 'number') { return null; }
if (typeof(command) !== 'string') { return null; }
if (!Array.isArray(args)) { return [time, command]; }
return [time, command].concat(args);
};
/*
var randomId = function () {
var bytes = Array.prototype.slice.call(nacl.randomBytes(16));
return bytes.map(function (b) {
var n = Number(b & 0xff).toString(16);
return n.length === 1? '0' + n: n;
}).join('');
};
var mkPath = function (env, id) {
return Path.join(env.root, id.slice(0, 2), id) + '.ndjson';
};
*/
// make a new folder every MODULUS ms
var MODULUS = 1000 * 60 * 60 * 24; // one day
var moduloTime = function (d) {
return d - (d % MODULUS);
};
var makeDirectoryId = function (d) {
return '' + moduloTime(d);
};
var write = function (env, task, cb) {
var str = JSON.stringify(task) + '\n';
var id = Util.encodeBase64(nacl.hash(Util.decodeUTF8(str))).replace(/\//g, '-');
var dir = makeDirectoryId(task[0]);
var path = Path.join(env.root, dir);
nThen(function (w) {
// create the parent directory if it does not exist
Fse.mkdirp(path, 0x1ff, w(function (err) {
if (err) {
w.abort();
return void cb(err);
}
}));
}).nThen(function () {
// write the file to the path
var fullPath = Path.join(path, id + '.ndjson');
// the file ids are based on the hash of the file contents to be written
// as such, writing an exact task a second time will overwrite the first with the same contents
// this shouldn't be a problem
Fs.writeFile(fullPath, str, function (e) {
if (e) {
env.log.error("TASK_WRITE_FAILURE", {
error: e,
path: fullPath,
});
return void cb(e);
}
env.log.info("SUCCESSFUL_WRITE", {
path: fullPath,
});
cb();
});
});
};
var remove = function (env, path, cb) {
// FIXME COLDSTORAGE?
Fs.unlink(path, cb);
};
var removeDirectory = function (env, path, cb) {
Fs.rmdir(path, cb);
};
var list = Tasks.list = function (env, cb, migration) {
var rootDirs;
nThen(function (w) {
// read the root directory
Fs.readdir(env.root, w(function (e, list) {
if (e) {
env.log.error("TASK_ROOT_DIR", {
root: env.root,
error: e,
});
w.abort();
return void cb(e);
}
if (list.length === 0) {
w.abort();
return void cb(void 0, []);
}
rootDirs = list;
}));
}).nThen(function () {
// schedule the nested directories for exploration
// return a list of paths to tasks
var queue = nThen(function () {});
var allPaths = [];
var currentWindow = moduloTime(+new Date() + MODULUS);
// We prioritize a small footprint over speed, so we
// iterate over directories in serial rather than parallel
rootDirs.forEach(function (dir) {
// if a directory is two characters, it's the old format
// otherwise, it indicates when the file is set to expire
// so we can ignore directories which are clearly in the future
var dirTime;
if (migration) {
// this block handles migrations. ignore new formats
if (dir.length !== 2) {
return;
}
} else {
// not in migration mode, check if it's a new format
if (dir.length >= 2) {
// might be the new format.
// check its time to see if it should be skipped
dirTime = parseInt(dir);
if (!isNaN(dirTime) && dirTime >= currentWindow) {
return;
}
}
}
queue.nThen(function (w) {
var subPath = Path.join(env.root, dir);
Fs.readdir(subPath, w(function (e, paths) {
if (e) {
env.log.error("TASKS_INVALID_SUBDIR", {
path: subPath,
error: e,
});
return;
}
if (paths.length === 0) {
removeDirectory(env, subPath, function (err) {
if (err) {
env.log.error('TASKS_REMOVE_EMPTY_DIRECTORY', {
error: err,
path: subPath,
});
}
});
}
// concat in place
Array.prototype.push.apply(allPaths, paths.map(function (p) {
return Path.join(subPath, p);
}));
}));
});
});
queue.nThen(function () {
cb(void 0, allPaths);
});
});
};
var read = function (env, filePath, cb) {
Fs.readFile(filePath, 'utf8', function (e, str) {
if (e) { return void cb(e); }
var task = tryParse(str);
if (!Array.isArray(task) || task.length < 2) {
env.log("INVALID_TASK", {
path: filePath,
task: task,
});
return cb(new Error('INVALID_TASK'));
}
cb(void 0, task);
});
};
var expire = function (env, task, cb) {
// TODO magic numbers, maybe turn task parsing into a function
// and also maybe just encode tasks in a better format to start...
var Log = env.log;
var args = task.slice(2);
Log.info('ARCHIVAL_SCHEDULED_EXPIRATION', {
task: task,
});
env.store.archiveChannel(args[0], 'EXPIRED', function (err) {
if (err) {
Log.error('ARCHIVE_SCHEDULED_EXPIRATION_ERROR', {
task: task,
error: err,
});
}
cb();
});
};
var run = Tasks.run = function (env, path, cb) {
var CURRENT = +new Date();
var Log = env.log;
var task, time, command;
//var args;
nThen(function (w) {
read(env, path, w(function (err, _task) {
if (err) {
w.abort();
// there was a file but it wasn't valid?
return void cb(err);
}
task = _task;
time = task[0];
if (time > CURRENT) {
w.abort();
return cb();
}
command = task[1];
//args = task.slice(2);
}));
}).nThen(function (w) {
switch (command) {
case 'EXPIRE':
return void expire(env, task, w());
default:
Log.warn("TASKS_UNKNOWN_COMMAND", task);
}
}).nThen(function () {
// remove the task file...
remove(env, path, function (err) {
if (err) {
Log.error('TASKS_RECORD_REMOVAL', {
path: path,
err: err,
});
}
cb();
});
});
};
var runAll = function (env, cb) {
// check if already running and bail out if so
if (env.running) {
return void cb("TASK_CONCURRENCY");
}
// if not, set a flag to block concurrency and proceed
env.running = true;
var paths;
nThen(function (w) {
list(env, w(function (err, _paths) {
if (err) {
w.abort();
env.running = false;
return void cb(err);
}
paths = _paths;
}));
}).nThen(function (w) {
var done = w();
var nt = nThen(function () {});
paths.forEach(function (path) {
nt = nt.nThen(function (w) {
run(env, path, w(function (err) {
if (err) {
// Any errors are already logged in 'run'
// the admin will need to review the logs and clean up
}
}));
});
});
nt = nt.nThen(function () {
done();
});
}).nThen(function (/*w*/) {
env.running = false;
cb();
});
};
var migrate = function (env, cb) {
// list every task
list(env, function (err, paths) {
if (err) {
return void cb(err);
}
var nt = nThen(function () {});
paths.forEach(function (path) {
var bypass;
var task;
nt = nt.nThen(function (w) {
// read
read(env, path, w(function (err, _task) {
if (err) {
bypass = true;
env.log.error("TASK_MIGRATION_READ", {
error: err,
path: path,
});
return;
}
task = _task;
}));
}).nThen(function (w) {
if (bypass) { return; }
// rewrite in new format
write(env, task, w(function (err) {
if (err) {
bypass = true;
env.log.error("TASK_MIGRATION_WRITE", {
error: err,
task: task,
});
}
}));
}).nThen(function (w) {
if (bypass) { return; }
// remove
remove(env, path, w(function (err) {
if (err) {
env.log.error("TASK_MIGRATION_REMOVE", {
error: err,
path: path,
});
}
}));
});
});
nt = nt.nThen(function () {
cb();
});
}, true);
};
Tasks.create = function (config, cb) {
if (!config.store) { throw new Error("E_STORE_REQUIRED"); }
if (!config.log) { throw new Error("E_LOG_REQUIRED"); }
var env = {
root: config.taskPath || './tasks',
log: config.log,
store: config.store,
};
// make sure the path exists...
Fse.mkdirp(env.root, 0x1ff, function (err) {
if (err) { return void cb(err); }
cb(void 0, {
write: function (time, command, args, cb) {
var task = encode(time, command, args);
write(env, task, cb);
},
list: function (olderThan, cb) {
list(env, olderThan, cb);
},
remove: function (id, cb) {
remove(env, id, cb);
},
run: function (id, cb) {
run(env, id, cb);
},
runAll: function (cb) {
runAll(env, cb);
},
migrate: function (cb) {
migrate(env, cb);
},
});
});
};

View File

@ -1,83 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Basic = require("./basic.js");
const Path = require("node:path");
const nThen = require('nthen');
const Util = require('../common-util');
const User = module.exports;
/* This module manages storage used to implement user management. "Known users" can
be added here in order to store their public key, their block ID and an alias
used to recognize them.
*/
const pathFromId = function (Env, id) {
if (!id || typeof(id) !== 'string') { return; }
id = Util.escapeKeyCharacters(id);
if (!Basic.isValidId(id)) {
return void console.error('USER_BAD_ID', id);
}
return Path.join(Env.paths.base, "users", id.slice(0, 2), id);
};
User.read = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.read(Env, path, (err, data) => {
if (err) { return void cb(err.code); }
cb(void 0, Util.tryParse(data));
});
};
User.getAll = function (Env, cb) {
let users = {};
nThen((waitFor) => {
let dirPath = Path.join(Env.paths.base, "users");
Basic.readDir(Env, dirPath, waitFor((err, prefixes) => {
if (err && err.code === 'ENOENT') { return void cb(void 0, {}); }
if (err) { waitFor.abort(); return void cb(err.code); }
prefixes.forEach((prefix) => {
var dirPath2 = Path.join(Env.paths.base, "users", prefix);
Basic.readDir(Env, dirPath2, waitFor((err, files) => {
if (err) { waitFor.abort(); return void cb(err.code); }
files.forEach((id) => {
User.read(Env, id, waitFor((err, data) => {
users[id] = data || { error: err };
}));
});
}));
});
}));
}).nThen(() => {
cb(null, users);
});
};
User.write = function (Env, id, data, cb) {
var path = pathFromId(Env, id);
Basic.write(Env, path, JSON.stringify(data), (err) => {
if (err) { return void cb(err.code); }
cb();
});
};
User.delete = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.delete(Env, path, (err) => {
if (err) { return void cb(err.code); }
cb();
});
};
User.update = function (Env, id, data, cb) {
User.delete(Env, id, (err) => {
if (err) { return void cb(err); }
User.write(Env, id, data, cb);
});
};

View File

@ -1,86 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const ToPull = require('stream-to-pull-stream');
const Pull = require('pull-stream');
const Stream = module.exports;
// transform a stream of arbitrarily divided data
// into a stream of buffers divided by newlines in the source stream
// TODO see if we could improve performance by using libnewline
const NEWLINE_CHR = ('\n').charCodeAt(0);
const mkBufferSplit = () => {
let remainder = null;
return Pull((read) => {
return (abort, cb) => {
read(abort, function (end, data) {
if (end) {
if (data) { console.log("mkBufferSplit() Data at the end"); }
cb(end, remainder ? [remainder, data] : [data]);
remainder = null;
return;
}
const queue = [];
for (;;) {
const offset = data.indexOf(NEWLINE_CHR);
if (offset < 0) {
remainder = remainder ? Buffer.concat([remainder, data]) : data;
break;
}
let subArray = data.slice(0, offset);
if (remainder) {
subArray = Buffer.concat([remainder, subArray]);
remainder = null;
}
queue.push(subArray);
data = data.slice(offset + 1);
}
cb(end, queue);
});
};
}, Pull.flatten());
};
// return a streaming function which transforms buffers into objects
// containing the buffer and the offset from the start of the stream
const mkOffsetCounter = (offset) => {
offset = offset || 0;
return Pull.map((buff) => {
const out = { offset: offset, buff: buff };
// +1 for the eaten newline
offset += buff.length + 1;
return out;
});
};
// readMessagesBin asynchronously iterates over the messages in a channel log
// the handler for each message must call back to read more, which should mean
// that this function has a lower memory profile than our classic method
// of reading logs line by line.
// it also allows the handler to abort reading at any time
Stream.readFileBin = (stream, msgHandler, cb, opt) => {
opt = opt || {};
//const stream = Fs.createReadStream(path, { start: start });
let keepReading = true;
Pull(
ToPull.read(stream),
mkBufferSplit(),
mkOffsetCounter(opt.offset),
Pull.asyncMap((data, moreCb) => {
msgHandler(data, moreCb, () => {
try {
stream.close();
} catch (err) {
console.error("READ_FILE_BIN_ERR", err);
}
keepReading = false;
moreCb();
});
}),
Pull.drain(() => (keepReading), (err) => {
cb((keepReading) ? err : undefined);
})
);
};

View File

@ -1,928 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const HK = require("../hk-util");
const Store = require("../storage/file");
const BlobStore = require("../storage/blob");
const Block = require("../commands/block");
const Util = require("../common-util");
const nThen = require("nthen");
const Meta = require("../metadata");
const Pins = require("../pins");
const Core = require("../commands/core");
const Saferphore = require("saferphore");
const Logger = require("../log");
const Tasks = require("../storage/tasks");
const Nacl = require('tweetnacl/nacl-fast');
const Eviction = require("../eviction");
const CPCrypto = require('../crypto');
const plugins = require("../plugin-manager");
const Env = {
Log: {},
};
const Monitoring = plugins && plugins.MONITORING;
const monitoringIncrement = key => {
if (!Monitoring || !Monitoring.increment) { return; }
Monitoring.increment(key);
};
// support the usual log API but pass it to the main process
Logger.levels.forEach(function (level) {
Env.Log[level] = function (label, info) {
process.send({
log: level,
label: label,
info: info,
});
};
});
//const HISTORY_SIZE_LIMIT = 1024 * 1024 * 1024; // 1GB
var DETAIL = 1000;
var round = function (n) {
return Math.floor(n * DETAIL) / DETAIL;
};
var ready = false;
var store;
var pinStore;
var blobStore;
const init = function (config, _cb) {
const cb = Util.once(Util.mkAsync(_cb));
if (!config) {
return void cb('E_INVALID_CONFIG');
}
Env.paths = {
pin: config.pinPath,
block: config.blockPath,
};
Env.inactiveTime = config.inactiveTime;
Env.archiveRetentionTime = config.archiveRetentionTime;
Env.accountRetentionTime = config.accountRetentionTime;
Env.sendMessage = data => {
process.send(data);
};
Object.keys(plugins || {}).forEach(name => {
let plugin = plugins[name];
if (!plugin.initialize) { return; }
try { plugin.initialize(Env, "db-worker"); }
catch (e) {}
});
nThen(function (w) {
Store.create(config, w(function (err, _store) {
if (err) {
w.abort();
return void cb(err);
}
Env.store = store = _store;
}));
Store.create({
filePath: config.pinPath,
archivePath: config.archivePath,
// important to initialize the pinstore with its own volume id
// otherwise archived pin logs will get mixed in with channels
volumeId: 'pins',
}, w(function (err, _pinStore) {
if (err) {
w.abort();
return void cb(err);
}
Env.pinStore = pinStore = _pinStore;
}));
BlobStore.create({
blobPath: config.blobPath,
blobStagingPath: config.blobStagingPath,
archivePath: config.archivePath,
getSession: function () {},
}, w(function (err, blob) {
if (err) {
w.abort();
return void cb(err);
}
Env.blobStore = blobStore = blob;
}));
}).nThen(function (w) {
Tasks.create({
log: Env.Log,
taskPath: config.taskPath,
store: store,
}, w(function (err, tasks) {
if (err) {
w.abort();
return void cb(err);
}
Env.tasks = tasks;
}));
}).nThen(function (w) {
CPCrypto.init(w(function (err, crypto) {
Env.crypto = crypto;
}));
}).nThen(function () {
cb();
});
};
/* computeIndex
can call back with an error or a computed index which includes:
* cpIndex:
* array including any checkpoints pushed within the last 100 messages
* processed by 'sliceCpIndex(cpIndex, line)'
* offsetByHash:
* a map containing message offsets by their hash
* this is for every message in history, so it could be very large...
* except we remove offsets from the map if they occur before the oldest relevant checkpoint
* size: in bytes
* metadata:
* validationKey
* expiration time
* owners
* ??? (anything else we might add in the future)
* line
* the number of messages in history
* including the initial metadata line, if it exists
*/
const OPEN_CURLY_BRACE = Buffer.from('{');
const CHECKPOINT_PREFIX = Buffer.from('cp|');
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 = [];
let i = 0;
const CB = Util.once(cb);
const offsetByHash = {};
let offsetCount = 0;
let size = offset || 0;
var start = offset || 0;
let unconventional = false;
nThen(function (w) {
// iterate over all messages in the channel log
// old channels can contain metadata as the first message of the log
// skip over metadata as that is handled elsewhere
// otherwise index important messages in the log
store.readMessagesBin(channelName, start, (msgObj, readMore, abort) => {
let msg;
// keep an eye out for the metadata line if you haven't already seen it
// but only check for metadata on the first line
if (i) {
// fall through intentionally because the following blocks are invalid
// for all but the first message
} else if (msgObj.buff.includes(OPEN_CURLY_BRACE)) {
msg = HK.tryParse(Env, msgObj.buff.toString('utf8'));
if (typeof msg === "undefined") {
i++; // always increment the message counter
return readMore();
}
// validate that the current line really is metadata before storing it as such
// skip this, as you already have metadata...
if (HK.isMetadataMessage(msg)) {
i++; // always increment the message counter
return readMore();
}
} else if (!(msg = HK.tryParse(Env, msgObj.buff.toString('utf8')))) {
w.abort();
abort();
return CB("OFFSET_ERROR");
}
i++;
if (msgObj.buff.includes(CHECKPOINT_PREFIX)) {
msg = msg || HK.tryParse(Env, msgObj.buff.toString('utf8'));
if (typeof msg === "undefined") { return readMore(); }
// cache the offsets of checkpoints if they can be parsed
if (msg[2] === 'MSG' && msg[4].indexOf('cp|') === 0) {
cpIndex.push({
offset: msgObj.offset,
line: i
});
// we only want to store messages since the latest checkpoint
// so clear the buffer every time you see a new one
messageBuf = [];
}
} else if (messageBuf.length > 100 && cpIndex.length === 0) {
// take the last 50 messages
unconventional = true;
messageBuf = messageBuf.slice(-50);
}
// if it's not metadata or a checkpoint then it should be a regular message
// store it in the buffer
messageBuf.push(msgObj);
return readMore();
}, w((err) => {
if (err && err.code !== 'ENOENT') {
w.abort();
return void CB(err);
}
// once indexing is complete you should have a buffer of messages since the latest checkpoint
// or the 50-100 latest messages if the channel is of a type without checkpoints.
// map the 'hash' of each message to its byte offset in the log, to be used for reconnecting clients
messageBuf.forEach((msgObj) => {
const msg = HK.tryParse(Env, msgObj.buff.toString('utf8'));
if (typeof msg === "undefined") { return; }
if (msg[0] === 0 && msg[2] === 'MSG' && typeof(msg[4]) === 'string') {
// msgObj.offset is API guaranteed by our storage module
// it should always be a valid positive integer
offsetByHash[HK.getHash(msg[4])] = msgObj.offset;
offsetCount++;
}
// There is a trailing \n at the end of the file
size = msgObj.offset + msgObj.buff.length + 1;
});
}));
}).nThen(function (w) {
cpIndex = HK.sliceCpIndex(cpIndex, i);
var new_start;
if (cpIndex.length) {
new_start = cpIndex[0].offset;
} else if (unconventional && messageBuf.length && isValidOffsetNumber(messageBuf[0].offset)) {
new_start = messageBuf[0].offset;
}
if (new_start === start) { return; }
if (!isValidOffsetNumber(new_start)) { return; }
// store the offset of the earliest relevant line so that you can start from there next time...
store.writeOffset(channelName, {
start: new_start,
created: +new Date(),
}, w(function () {
var diff = new_start - start;
Env.Log.info('WORKER_OFFSET_UPDATE', {
channel: channelName,
start: start,
startMB: round(start / 1024 / 1024),
update: new_start,
updateMB: round(new_start / 1024 / 1024),
diff: diff,
diffMB: round(diff / 1024 / 1024),
});
}));
}).nThen(function () {
// return the computed index
CB(null, {
// Only keep the checkpoints included in the last 100 messages
cpIndex: cpIndex,
offsetByHash: offsetByHash,
offsets: offsetCount,
size: size,
//metadata: metadata,
line: i
});
});
};
const computeIndex = function (data, cb) {
if (!data || !data.channel) {
return void cb('E_NO_CHANNEL');
}
const channelName = data.channel;
const CB = Util.once(cb);
monitoringIncrement('computeIndex');
var start = 0;
nThen(function (w) {
store.getOffset(channelName, w(function (err, obj) {
if (err) { return; }
if (obj && typeof(obj.start) === 'number' && obj.start > 0) {
start = obj.start;
Env.Log.verbose('WORKER_OFFSET_RECOVERY', {
channel: channelName,
start: start,
startMB: round(start / 1024 / 1024),
});
}
}));
}).nThen(function (w) {
computeIndexFromOffset(channelName, start, w(function (err, index) {
if (err === 'OFFSET_ERROR') {
return Env.Log.error("WORKER_OFFSET_ERROR", {
channel: channelName,
});
}
w.abort();
monitoringIncrement('computeIndexFromOffset');
CB(err, index);
}));
}).nThen(function (w) {
// if you're here there was an OFFSET_ERROR..
// first remove the offset that caused the problem to begin with
store.clearOffset(channelName, w());
}).nThen(function () {
// now get the history as though it were the first time
monitoringIncrement('computeIndexFromStart');
computeIndexFromOffset(channelName, 0, CB);
});
};
const computeMetadata = function (data, cb) {
const ref = {};
const lineHandler = Meta.createLineHandler(ref, Env.Log.error);
monitoringIncrement('computeMetadata');
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);
}
cb(void 0, ref.meta);
});
};
const _getFileSize = function (channel, _cb) {
var 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 void store.getChannelSize(channel, function (e, size) {
if (e) {
if (e.code === 'ENOENT') { return void cb(void 0, 0); }
return void cb(e.code);
}
cb(void 0, size);
});
}
// 'channel' refers to a file, so you need another API
blobStore.size(channel, function (e, size) {
if (typeof(size) === 'undefined') { return void cb(e); }
cb(void 0, size);
});
};
const getFileSize = function (data, cb) {
_getFileSize(data.channel, cb);
};
/* getOlderHistory
* allows clients to query for all messages until a known hash is read
* stores all messages in history as they are read
* can therefore be very expensive for memory
* should probably be converted to a streaming interface
Used by:
* GET_HISTORY_RANGE
*/
const getOlderHistory = function (data, cb) {
const oldestKnownHash = data.hash;
const channelName = data.channel;
const desiredMessages = data.desiredMessages;
const desiredCheckpoint = data.desiredCheckpoint;
let messages = [];
store.readMessagesBin(channelName, 0, (msgObj, readMore, abort) => {
const parsed = HK.tryParse(Env, msgObj.buff.toString('utf8'));
if (!parsed) { return void readMore(); }
if (HK.isMetadataMessage(parsed)) { return void readMore(); }
const content = parsed[4];
if (typeof(content) !== 'string') { return void readMore(); }
const hash = HK.getHash(content);
messages.push(parsed);
// "X" messages before oldestKnownHash
if (typeof (desiredMessages) === "number") {
messages = messages.slice(-desiredMessages);
if (hash === oldestKnownHash) { return void abort(); }
return void readMore();
}
// "X" checkpoints before oldestKnownHash
if (hash === oldestKnownHash) { return void abort(); }
if (/^cp\|/.test(content)) { // clean whenever we push a cp
let foundCp = 0;
const idx = messages.findLastIndex(parsed => {
let isCp = /^cp\|/.test(parsed[4]);
if (!isCp) { return; }
foundCp++;
return foundCp >= desiredCheckpoint;
});
if (idx > 0) {
messages = messages.slice(idx);
}
}
readMore();
}, function (err, reason) {
if (err) { return void cb(err, reason); }
cb(void 0, messages);
});
/*
const untilHash = data.toHash;
var next = () => {
var messages = [];
var found = false;
store.getMessages(channelName, function (msgStr) {
if (found) { return; }
let parsed = HK.tryParse(Env, msgStr);
if (typeof parsed === "undefined") { return; }
// identify classic metadata messages by their inclusion of a channel.
// and don't send metadata, since:
// 1. the user won't be interested in it
// 2. this metadata is potentially incomplete/incorrect
if (HK.isMetadataMessage(parsed)) { return; }
var content = parsed[4];
if (typeof(content) !== 'string') { return; }
var hash = HK.getHash(content);
if (hash === oldestKnownHash) {
found = true;
}
messages.push(parsed);
}, function (err) {
var toSend = [];
if (typeof (desiredMessages) === "number") {
toSend = messages.slice(-desiredMessages);
} else if (untilHash) {
for (var j = messages.length - 1; j >= 0; j--) {
toSend.unshift(messages[j]);
if (Array.isArray(messages[j]) && HK.getHash(messages[j][4]) === untilHash) {
break;
}
}
} else {
let cpCount = 0;
for (var i = messages.length - 1; i >= 0; i--) {
if (/^cp\|/.test(messages[i][4]) && i !== (messages.length - 1)) {
cpCount++;
}
toSend.unshift(messages[i]);
if (cpCount >= desiredCheckpoint) { break; }
}
}
cb(err, toSend);
});
};
_getFileSize(channelName, function (err, size) {
if (err) { return void cb(err); }
if (size > HISTORY_SIZE_LIMIT) {
return void cb('HISTORY_TOO_LARGE');
}
next();
});
*/
};
const getPinState = function (data, cb) {
if (typeof(data.key) !== 'string') { return void cb('INVALID_KEY'); }
const safeKey = Util.escapeKeyCharacters(data.key);
var ref = {};
var lineHandler = Pins.createLineHandler(ref, Env.Log.error);
// if channels aren't in memory. load them from disk
monitoringIncrement('getPin');
pinStore.readMessagesBin(safeKey, 0, (msgObj, readMore) => {
lineHandler(msgObj.buff.toString('utf8'));
readMore();
}, function () {
cb(void 0, ref.pins); // FIXME no error handling?
});
};
const _iterateFiles = function (channels, handler, cb) {
if (!Array.isArray(channels)) { return cb('INVALID_LIST'); }
var L = channels.length;
var sem = Saferphore.create(10);
// (channel, next) => { ??? }
var job = function (channel, wait) {
return function (give) {
handler(channel, wait(give()));
};
};
nThen(function (w) {
for (var i = 0; i < L; i++) {
sem.take(job(channels[i], w));
}
}).nThen(function () {
cb();
});
};
const getTotalSize = function (data, cb) {
var bytes = 0;
monitoringIncrement('getTotalSize');
_iterateFiles(data.channels, function (channel, next) {
_getFileSize(channel, function (err, size) {
if (!err) { bytes += size; }
next();
});
}, function (err) {
if (err) { return cb(err); }
cb(void 0, bytes);
});
};
const getDeletedPads = function (data, cb) {
var absentees = [];
_iterateFiles(data.channels, function (channel, next) {
_getFileSize(channel, function (err, size) {
if (err) { return next(); }
if (size === 0) { absentees.push(channel); }
next();
});
}, function (err) {
if (err) { return void cb(err); }
cb(void 0, absentees);
});
};
const getMultipleFileSize = function (data, cb) {
const counts = {};
_iterateFiles(data.channels, function (channel, next) {
_getFileSize(channel, function (err, size) {
counts[channel] = err? 0: size;
next();
});
}, function (err) {
if (err) {
return void cb(err);
}
cb(void 0, counts);
});
};
const getHashOffset = function (data, cb) {
const channelName = data.channel;
const lastKnownHash = data.hash;
if (typeof(lastKnownHash) !== 'string') { return void cb("INVALID_HASH"); }
monitoringIncrement('getHashOffset');
var offset = -1;
store.readMessagesBin(channelName, 0, (msgObj, readMore, abort) => {
// tryParse return a parsed message or undefined
const msg = HK.tryParse(Env, msgObj.buff.toString('utf8'));
// if it was undefined then go onto the next message
if (typeof msg === "undefined") { return readMore(); }
if (typeof(msg[4]) !== 'string' || lastKnownHash !== HK.getHash(msg[4])) {
return void readMore();
}
offset = msgObj.offset;
abort();
}, function (err, reason) {
if (err) {
return void cb({
error: err,
reason: reason
});
}
cb(void 0, offset);
});
};
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
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
blobStore.archive.blob(blobId, reason, w(function (err) {
Env.Log.info('ARCHIVAL_OWNED_FILE_BY_OWNER_RPC', {
safeKey: safeKey,
blobId: blobId,
status: err? String(err): 'SUCCESS',
});
if (err) {
w.abort();
return void cb(err);
}
cb(void 0, 'OK');
}));
});
};
const runTasks = function (data, cb) {
Env.tasks.runAll(cb);
};
const writeTask = function (data, cb) {
Env.tasks.write(data.time, data.task_command, data.args, cb);
};
const evictInactive = function (data, cb) {
Eviction(Env, cb);
};
var reportStatus = function (Env, label, safeKey, err, id, size) {
var data = {
safeKey: safeKey,
err: err && err.message || err,
id: id,
size: size,
sizeMB: round((size || 0) / 1024 / 1024),
};
var method = err? 'error': 'info';
Env.Log[method](label, data);
};
const completeUpload = function (data, cb) {
if (!data) { return void cb('INVALID_ARGS'); }
if (typeof(data.safeKey) !== 'string') { return void cb("INVALID_KEY"); }
var owned = data.owned;
var safeKey = Util.escapeKeyCharacters(data.safeKey);
var arg = data.arg;
var size = data.size;
monitoringIncrement('uploadedBlob');
var method;
var label;
if (owned) {
method = 'completeOwned';
label = 'UPLOAD_COMPLETE_OWNED';
} else {
method = 'complete';
label = 'UPLOAD_COMPLETE';
}
Env.blobStore[method](safeKey, arg, function (err, id) {
reportStatus(Env, label, safeKey, err, id, size);
cb(err, id);
}, data.linked);
};
const getPinActivity = function (data, cb) {
if (!data) { return void cb("INVALID_ARGS"); }
if (typeof(data.key) !== 'string') { return void cb("INVALID_KEY"); }
var safeKey = Util.escapeKeyCharacters(data.key);
var first;
var latest;
pinStore.readMessagesBin(safeKey, 0, (msgObj, readMore) => {
var line = msgObj.buff.toString('utf8');
if (!line || !line.trim()) { return readMore(); }
try {
var parsed = JSON.parse(line);
var temp = parsed[parsed.length - 1];
if (!temp || typeof(temp) !== 'number') { return readMore(); }
latest = temp;
if (first) { return readMore(); }
first = latest;
readMore();
} catch (err) { readMore(); }
}, function (err) {
if (err) { return void cb(err); }
cb(void 0, {
first: first,
latest: latest,
});
});
};
const getLastChannelTime = function (data, cb) {
if (!data) { return void cb("INVALID_ARGS"); }
var latest;
store.getMessages(data.channel, function (line) {
try {
var parsed = JSON.parse(line);
var temp = parsed[parsed.length - 1];
if (!temp || typeof(temp) !== 'number') { return; }
latest = temp;
} catch (err) { }
}, function (err) {
if (err) { return void cb(err); }
cb(void 0, latest);
});
};
const COMMANDS = {
ENV_UPDATE: updateEnv,
COMPUTE_INDEX: computeIndex,
COMPUTE_METADATA: computeMetadata,
GET_OLDER_HISTORY: getOlderHistory,
GET_PIN_STATE: getPinState,
GET_FILE_SIZE: getFileSize,
GET_TOTAL_SIZE: getTotalSize,
GET_DELETED_PADS: getDeletedPads,
GET_MULTIPLE_FILE_SIZE: getMultipleFileSize,
GET_HASH_OFFSET: getHashOffset,
REMOVE_OWNED_BLOB: removeOwnedBlob,
RUN_TASKS: runTasks,
WRITE_TASK: writeTask,
EVICT_INACTIVE: evictInactive,
COMPLETE_UPLOAD: completeUpload,
GET_PIN_ACTIVITY: getPinActivity,
GET_LAST_CHANNEL_TIME: getLastChannelTime,
};
COMMANDS.INLINE = function (data, cb) {
monitoringIncrement('inlineValidation');
var signedMsg;
try {
signedMsg = Util.decodeBase64(data.msg);
} catch (e) {
return void cb('E_BAD_MESSAGE');
}
var validateKey;
try {
validateKey = Util.decodeBase64(data.key);
} catch (e) {
return void cb("E_BADKEY");
}
// validate the message
//const validated = Nacl.sign.open(signedMsg, validateKey);
const validated = Env.crypto.open(signedMsg, validateKey);
if (!validated) {
return void cb("FAILED");
}
cb();
};
const checkDetachedSignature = function (signedMsg, signature, publicKey) {
if (!(signedMsg && publicKey)) { return false; }
monitoringIncrement('detachedValidation');
var signedBuffer;
var pubBuffer;
var signatureBuffer;
try {
signedBuffer = Util.decodeUTF8(signedMsg);
} catch (e) {
throw new Error("INVALID_SIGNED_BUFFER");
}
try {
pubBuffer = Util.decodeBase64(publicKey);
} catch (e) {
throw new Error("INVALID_PUBLIC_KEY");
}
try {
signatureBuffer = Util.decodeBase64(signature);
} catch (e) {
throw new Error("INVALID_SIGNATURE");
}
if (pubBuffer.length !== 32) {
throw new Error("INVALID_PUBLIC_KEY_LENGTH");
}
if (signatureBuffer.length !== 64) {
throw new Error("INVALID_SIGNATURE_LENGTH");
}
//if (Nacl.sign.detached.verify(signedBuffer, signatureBuffer, pubBuffer) !== true) {
if (Env.crypto.detachedVerify(signedBuffer, signatureBuffer, pubBuffer) !== true) {
throw new Error("FAILED");
}
};
COMMANDS.DETACHED = function (data, cb) {
try {
checkDetachedSignature(data.msg, data.sig, data.key);
} catch (err) {
return void cb(err && err.message);
}
cb();
};
COMMANDS.HASH_CHANNEL_LIST = function (data, cb) {
var channels = data.channels;
if (!Array.isArray(channels)) { return void cb('INVALID_CHANNEL_LIST'); }
var uniques = [];
channels.forEach(function (a) {
if (uniques.indexOf(a) === -1) { uniques.push(a); }
});
uniques.sort();
var hash = Util.encodeBase64(Nacl.hash(Util.decodeUTF8(JSON.stringify(uniques))));
cb(void 0, hash);
};
COMMANDS.VALIDATE_ANCESTOR_PROOF = function (data, cb) {
monitoringIncrement('validateAncestorProof');
Block.validateAncestorProof(Env, data && data.proof, cb);
};
COMMANDS.VALIDATE_LOGIN_BLOCK = function (data, cb) {
monitoringIncrement('validateLoginBlock');
Block.validateLoginBlock(Env, data.publicKey, data.signature, data.block, cb);
};
Object.keys(plugins || {}).forEach(name => {
let plugin = plugins[name];
if (!plugin.addWorkerCommands) { return; }
try {
let events = plugin.addWorkerCommands(Env);
Object.keys(events || {}).forEach(cmd => {
if (typeof(events[cmd]) !== "function") { return; }
if (COMMANDS[cmd]) { return; }
COMMANDS[cmd] = events[cmd];
});
} catch (e) {}
});
process.on('message', function (data) {
if (!data || !data.txid || !data.pid) {
return void process.send({
error:'E_INVAL',
data: data,
});
}
const command = COMMANDS[data.command];
// Command broadcasted to all workers: no callback expected
if (data.type === 'broadcast') {
return void command(data);
}
const cb = function (err, value) {
process.send({
error: Util.serializeError(err),
txid: data.txid,
pid: data.pid,
value: value,
});
};
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;
cb();
});
}
if (typeof(command) !== 'function') {
return void cb("E_BAD_COMMAND");
}
command(data, cb);
});
process.on('uncaughtException', function (err) {
console.error('[%s] UNCAUGHT EXCEPTION IN DB WORKER', new Date());
console.error(err);
console.error("TERMINATING");
process.exit(1);
});

View File

@ -1,628 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Util = require("../common-util");
const nThen = require('nthen');
const OS = require("os");
const { fork } = require('child_process');
const Workers = module.exports;
const PID = process.pid;
const Block = require("../storage/block");
const Environment = require('../env');
const Linked = require("../commands/linked");
const DB_PATH = 'lib/workers/db-worker';
const MAX_JOBS = 16;
const DEFAULT_QUERY_TIMEOUT = 60000 * 15; // increased from three to fifteen minutes because queries for very large files were taking as long as seven minutes
Workers.initialize = function (Env, config, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
var incrementTime = function (command, start) {
if (!command) { return; }
var end = +new Date();
var T = Env.commandTimers;
var diff = (end - start);
T[command] = (T[command] || 0) + (diff / 1000);
};
const workers = [];
const response = Util.response(function (errLabel, info) {
Env.Log.error('HK_DB_WORKER__' + errLabel, info);
});
const Log = Env.Log;
const handleLog = function (level, label, info) {
if (typeof(Log[level]) !== 'function') { return; }
Log[level](label, info);
};
var isWorker = function (value) {
return value && value.worker && typeof(value.worker.send) === 'function';
};
// pick ids that aren't already in use...
const guid = function () {
var id = Util.uid();
return response.expected(id)? guid(): id;
};
const countWorkerTasks = function (/* index */) {
return 0; // FIXME this disables all queueing until it can be proven correct
//return Object.keys(workers[index].tasks || {}).length;
};
const WORKER_TASK_LIMIT = 250000; // XXX
var workerOffset = -1;
var queue = [];
var getAvailableWorkerIndex = function (isQueue) {
// If there is already a backlog of tasks you can avoid some work
// by going to the end of the line (unless we're trying to
// empty the queue)
if (queue.length && !isQueue) { return -1; }
var L = workers.length;
if (L === 0) {
Log.warn('NO_WORKERS_AVAILABLE', {
queue: queue.length,
});
return -1;
}
// cycle through the workers once
// start from a different offset each time
// return -1 if none are available
workerOffset = (workerOffset + 1) % L;
var temp;
for (let i = 0; i < L; i++) {
temp = (workerOffset + i) % L;
/* I'd like for this condition to be more efficient
(`Object.keys` is sub-optimal) but I found some bugs in my initial
implementation stemming from a task counter variable going out-of-sync
with reality when a worker crashed and its tasks were re-assigned to
its substitute. I'm sure it can be done correctly and efficiently,
but this is a relatively easy way to make sure it's always up to date.
We'll see how it performs in practice before optimizing.
*/
if (workers[temp] && countWorkerTasks(temp) <= MAX_JOBS) {
return temp;
}
}
return -1;
};
var drained = true;
var sendCommand = function (msg, _cb, opt, isQueue) {
if (!_cb) {
return void Log.error('WORKER_COMMAND_MISSING_CB', {
msg: msg,
opt: opt,
});
}
opt = opt || {};
var index = getAvailableWorkerIndex(isQueue);
var state = workers[index];
// if there is no worker available:
if (!isWorker(state)) {
// queue the message for when one becomes available
queue.push({
msg: msg,
cb: _cb,
});
if (drained) {
drained = false;
Log.warn('WORKER_QUEUE_BACKLOG', {
workers: workers.length,
});
}
return;
}
const txid = guid();
var start = +new Date();
var cb = Util.once(Util.mkAsync(Util.both(_cb, function (err /*, value */) {
incrementTime(msg && msg.command, start);
if (err !== 'TIMEOUT') { return; }
Log.warn("WORKER_TIMEOUT_CAUSE", msg);
// in the event of a timeout the user will receive an error
// but the state used to resend a query in the event of a worker crash
// won't be cleared. This also leaks a slot that could be used to keep
// an upper bound on the amount of parallelism for any given worker.
// if you run out of slots then the worker locks up.
delete state.tasks[txid];
state.checkTasks();
})));
if (!msg) {
return void cb('ESERVERERR');
}
msg.txid = txid;
msg.pid = PID;
// include the relevant worker process id in messages so that it will be logged
// in the event that the message times out or fails in other ways.
msg.worker = state.pid;
// track which worker is doing which jobs
state.tasks[txid] = msg;
// default to timing out affter 180s if no explicit timeout is passed
var timeout = typeof(opt.timeout) !== 'undefined'? opt.timeout: DEFAULT_QUERY_TIMEOUT;
response.expect(txid, cb, timeout);
delete msg._cb;
delete msg._opt;
state.worker.send(msg);
// Add original callback to message data in case we need
// to resend the command. setTimeout to avoid interfering
// with worker.send
setTimeout(function () {
msg._cb = _cb;
msg._opt = opt;
});
state.count++;
if (state.count > WORKER_TASK_LIMIT) {
// Remove from list and spawn new one
if (state.replaceWorker) { state.replaceWorker(); }
}
};
const pluginsResponses = {};
Object.keys(Env.plugins || {}).forEach(name => {
let plugin = Env.plugins[name];
if (!plugin.addWorkerResponses) { return; }
try {
let res = plugin.addWorkerResponses(Env);
Object.keys(res || {}).forEach(key => {
if (typeof(res[key]) !== "function") { return; }
if (pluginsResponses[key]) { return; }
pluginsResponses[key] = res[key];
});
} catch (e) {}
});
var handleResponse = function (state, res) {
if (!res) { return; }
// handle log messages before checking if it was addressed to your PID
// it might still be useful to know what happened inside an orphaned worker
if (res.log) {
return void handleLog(res.log, res.label, res.info);
}
// handle plugins
if (res.plugin) {
Object.keys(pluginsResponses).some(key => {
if (res.type !== key) { return; }
pluginsResponses[key](res.data);
return true;
});
return;
}
// but don't bother handling things addressed to other processes
// since it's basically guaranteed not to work
if (res.pid !== PID) {
return void Log.error("WRONG_PID", res);
}
if (!res.txid) { return; }
response.handle(res.txid, [res.error, res.value]);
delete state.tasks[res.txid];
state.checkTasks();
if (!queue.length) {
if (!drained) {
drained = true;
Log.debug('WORKER_QUEUE_DRAINED', {
workers: workers.length,
});
}
return;
}
var nextMsg = queue.shift();
if (!nextMsg || !nextMsg.msg) {
return void Log.error('WORKER_QUEUE_EMPTY_MESSAGE', {
item: nextMsg,
});
}
/* `nextMsg` was at the top of the queue.
We know that a job just finished and all of this code
is synchronous, so calling `sendCommand` should take the worker
which was just freed up. This is somewhat fragile though, so
be careful if you want to modify this block. The risk is that
we take something that was at the top of the queue and push it
to the back because the following msg took its place. OR, in an
even worse scenario, we cycle through the queue but don't run anything.
*/
sendCommand(nextMsg.msg, nextMsg.cb, {}, true);
};
const initWorker = function (worker, cb) {
const txid = guid();
const state = {
worker: worker,
tasks: {},
count: Math.floor(Math.random()*(WORKER_TASK_LIMIT/10)),
pid: worker.pid, // store the child process's id in an easily accessible location
};
let pid = worker.pid;
const onWorkerClosed = () => {
Object.keys(Env.plugins || {}).forEach(name => {
let plugin = Env.plugins[name];
if (!plugin.onWorkerClosed) { return; }
try { plugin.onWorkerClosed("db-worker", pid); }
catch (e) {}
});
};
state.replaceWorker = () => {
let index = workers.indexOf(state);
if (index === -1) { return; }
// Remove old
workers.splice(index, 1);
// Create new
state.complete = true;
const w = fork(DB_PATH);
Log.info('WORKER_REPLACE_START', {
from: state.worker.pid,
to: w.pid
});
initWorker(w, function (err) {
if (err) {
throw new Error(err);
}
});
};
// If we've reached the limit, kill the worker once
// all the tasks are complete or timed out
state.checkTasks = () => {
// Check limit
if (!state.complete || !state.worker) { return; }
// Check remaining tasks
if (Object.keys(state.tasks).length) { return; }
// Kill
Log.info('WORKER_KILL', {
worker: state.worker.pid,
count: state.count
});
onWorkerClosed();
delete state.worker;
worker.kill();
};
response.expect(txid, function (err) {
if (err) { return void cb(err); }
workers.push(state);
cb(void 0, state);
// We just pushed a new worker, available to receive
// a task, so we can empty the queue if necessary
if (queue.length) {
const nextMsg = queue.shift();
if (!nextMsg || !nextMsg.msg) {
return Log.error('WORKER_QUEUE_EMPTY_MESSAGE', {
item: nextMsg,
});
}
sendCommand(nextMsg.msg, nextMsg.cb, {}, true);
}
}, 15000);
worker.send({
pid: PID,
txid: txid,
config: config,
env: Environment.serialize(Env)
});
worker.on('message', function (res) {
handleResponse(state, res);
});
var substituteWorker = Util.once(function () {
onWorkerClosed();
Env.Log.info("SUBSTITUTE_DB_WORKER", '');
var idx = workers.indexOf(state);
if (idx !== -1) {
workers.splice(idx, 1);
}
Object.keys(state.tasks).forEach(function (txid) {
const cb = response.expectation(txid);
if (typeof(cb) !== 'function') { return; }
const task = state.tasks[txid];
if (!task) { return; }
response.clear(txid);
Log.info('DB_WORKER_RESEND', task);
sendCommand(task, task._cb || cb, task._opt);
});
var w = fork(DB_PATH);
initWorker(w, function (err) {
if (err) {
throw new Error(err);
}
});
});
worker.on('exit', function () {
if (!state.worker) { return; } // Manually killed
substituteWorker();
Env.Log.error("DB_WORKER_EXIT", {
pid: state.pid,
});
});
worker.on('close', function () {
if (!state.worker) { return; } // Manually killed
substituteWorker();
Env.Log.error("DB_WORKER_CLOSE", {
pid: state.pid,
});
});
worker.on('error', function (err) {
if (!state.worker) { return; } // Manually killed
substituteWorker();
Env.Log.error("DB_WORKER_ERROR", {
pid: state.pid,
error: err,
});
});
};
nThen(function (w) {
var limit = Env.maxWorkers;
var logged;
OS.cpus().forEach(function (cpu, index) {
if (limit && index >= limit) {
if (!logged) {
logged = true;
Log.info('WORKER_LIMIT', "(Opting not to use available CPUs beyond " + index + ')');
}
return;
}
initWorker(fork(DB_PATH), w(function (err) {
if (!err) { return; }
w.abort();
return void cb(err);
}));
});
}).nThen(function () {
Env.broadcastWorkerCommand = (data) => {
workers.forEach(state => {
state.worker.send({
type: 'broadcast',
pid: PID,
command: data.command,
txid: data.txid,
value: data.value
});
});
return workers;
};
Env.computeIndex = function (Env, channel, cb) {
Env.store.getWeakLock(channel, function (next) {
sendCommand({
channel: channel,
command: 'COMPUTE_INDEX',
}, function (err, index) {
next();
cb(err, index);
});
});
};
Env.computeMetadata = function (channel, cb) {
Env.store.getWeakLock(channel, function (next) {
sendCommand({
channel: channel,
command: 'COMPUTE_METADATA',
}, function (err, metadata) {
next();
cb(err, metadata);
});
});
};
Env.getOlderHistory = function (channel, oldestKnownHash, toHash, desiredMessages, desiredCheckpoint, cb) {
Env.store.getWeakLock(channel, function (next) {
sendCommand({
channel: channel,
command: "GET_OLDER_HISTORY",
hash: oldestKnownHash,
toHash: toHash,
desiredMessages: desiredMessages,
desiredCheckpoint: desiredCheckpoint,
}, Util.both(next, cb));
});
};
Env.getPinState = function (safeKey, cb) {
Env.pinStore.getWeakLock(safeKey, function (next) {
sendCommand({
key: safeKey,
command: 'GET_PIN_STATE',
}, Util.both(next, cb));
});
};
Env.getPinActivity = function (safeKey, cb) {
Env.pinStore.getWeakLock(safeKey, function (next) {
sendCommand({
key: safeKey,
command: 'GET_PIN_ACTIVITY',
}, Util.both(next, cb));
});
};
Env.getLastChannelTime = function (channel, cb) {
sendCommand({
command: 'GET_LAST_CHANNEL_TIME',
channel: channel,
}, cb);
};
Env.getFileSize = function (channel, cb, singleFile) {
if (!singleFile) {
return Linked.getFileSize(Env, { channel }, cb);
}
sendCommand({
command: 'GET_FILE_SIZE',
singleFile: singleFile, // ignore linked documents
channel: channel,
}, cb);
};
Env.getDeletedPads = function (channels, cb) {
sendCommand({
command: "GET_DELETED_PADS",
channels: channels,
}, cb);
};
Env.getTotalSize = function (channels, cb) {
// we could take out locks for all of these channels,
// but it's OK if the size is slightly off
sendCommand({
command: 'GET_TOTAL_SIZE',
channels: channels,
}, cb);
};
Env.getMultipleFileSize = function (channels, cb) {
sendCommand({
command: "GET_MULTIPLE_FILE_SIZE",
channels: channels,
}, cb);
};
Env.getHashOffset = function (channel, hash, cb) {
Env.store.getWeakLock(channel, function (next) {
sendCommand({
command: 'GET_HASH_OFFSET',
channel: channel,
hash: hash,
}, Util.both(next, cb));
});
};
Env.removeOwnedBlob = function (blobId, safeKey, reason, cb) {
sendCommand({
command: 'REMOVE_OWNED_BLOB',
blobId: blobId,
safeKey: safeKey,
reason: reason
}, cb);
};
Env.evictInactive = function (cb) {
sendCommand({
command: 'EVICT_INACTIVE',
}, cb, {
timeout: 1000 * 60 * 300, // time out after 300 minutes (5 hours)
});
};
Env.runTasks = function (cb) {
sendCommand({
command: 'RUN_TASKS',
}, cb, {
timeout: 1000 * 60 * 10, // time out after 10 minutes
});
};
Env.writeTask = function (time, command, args, cb) {
sendCommand({
command: 'WRITE_TASK',
time: time,
task_command: command,
args: args,
}, cb);
};
// Synchronous crypto functions
Env.validateMessage = function (signedMsg, key, cb) {
sendCommand({
msg: signedMsg,
key: key,
command: 'INLINE',
}, cb);
};
Env.checkSignature = function (signedMsg, signature, publicKey, cb) {
sendCommand({
command: 'DETACHED',
sig: signature,
msg: signedMsg,
key: publicKey,
}, cb);
};
Env.hashChannelList = function (channels, cb) {
sendCommand({
command: 'HASH_CHANNEL_LIST',
channels: channels,
}, cb);
};
Env.completeUpload = function (safeKey, arg, owned, size, linked, cb) {
sendCommand({
command: "COMPLETE_UPLOAD",
linked,
owned: owned, // Boolean
safeKey: safeKey, // String (public key)
arg: arg, // String (file id)
size: size, // Number || undefined
}, cb);
};
Env.validateAncestorProof = function (proof, cb) {
sendCommand({
command: 'VALIDATE_ANCESTOR_PROOF',
proof: proof,
}, cb);
};
Env.validateLoginBlock = function (publicKey, signature, block, cb) {
if (!block || !block.length || block.length > Block.MAX_SIZE) {
return void setTimeout(function () {
Env.Log.error('E_INVALID_BLOCK_SIZE', {
size: block.length,
});
cb('E_INVALID_BLOCK_SIZE');
});
}
sendCommand({
command: 'VALIDATE_LOGIN_BLOCK',
publicKey: publicKey,
signature: signature,
block: block,
}, cb);
};
cb(void 0);
});
};

View File

@ -1,46 +0,0 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
/*
var q = Queue();
q(id, function (next) {
// whatever you need to do....
// when you're done
next(); // guaranteed to be asynchronous :D
});
*/
var fix1 = function (f, x) {
return function () { f(x); };
};
module.exports = function () {
var map = {};
var next = function (id) {
setTimeout(function () {
if (!map[id] || map[id].length === 0) { return void delete map[id]; }
var task = map[id].shift();
task(fix1(next, id));
});
};
return function (id, task) {
// support initialization with just a function
if (typeof(id) === 'function' && typeof(task) === 'undefined') {
task = id;
id = '';
}
// ...but you really need to pass a function
if (typeof(task) !== 'function') { throw new Error("Expected function"); }
// if the intended queue already has tasks in progress, add this one to the end of the queue
if (map[id]) { return void map[id].push(task); }
// otherwise create a queue containing the given task
map[id] = [task];
next(id);
};
};