Merge pull request #2358 from cryptpad/linked-doc

Improve stability and performances on office apps
This commit is contained in:
yflory 2026-08-06 15:40:09 +02:00 committed by GitHub
commit 1be87cb7cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 2304 additions and 1423 deletions

View File

@ -93,6 +93,10 @@
left: 250px;
}
&.cp-small {
min-width: 50px;
}
&:hover {
display: block;
}

View File

@ -52,10 +52,6 @@
min-width: 0;
text-align: center;
}
.cp-history-timeline-version {
font-size: 12px;
margin-right: 10px;
}
.cp-toolbar-history-previous, .cp-toolbar-history-next {
display: inline-flex;
@ -207,6 +203,37 @@
height: 18px;
display: flex;
}
.cp-history-timeline-patch {
display: flex;
.cp-history-bar-el {
cursor: pointer;
flex: 1;
background-color: @history_userBg1;
border: 2px solid @history_userBg1;
display: flex;
align-items: center;
justify-content: center;
&:nth-child(2n) {
background-color: @history_userBg2;
border: 2px solid @history_userBg2;
}
&.cp-selected {
border: 2px solid @cryptpad_text_col;
position: relative;
svg:not([data-snapshot]) {
position: absolute;
margin: 0;
left: 50%;
bottom: 65%;
transform: translateX(-50%)
}
}
svg[data-snapshot] {
height: 100%;
margin: 0;
}
}
}
.cp-history-timeline-users {
margin-bottom: 1px;
.cp-history-bar-el {
@ -290,6 +317,38 @@
}
}
.cp-history-version {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.8em;
& *:not(button > span) {
font-size: inherit;
}
.cp-dropdown-container {
svg {
margin-top: 3px;
}
button {
text-transform: unset;
min-width: 50px;
width: auto;
padding: 3px;
font-size: 0.9em;
}
}
.cp-history-version-time {
&.cp-hidden {
visibility: hidden;
}
}
.cp-history-version-select {
display: flex;
align-items: center;
gap: 5px;
}
}
.cp-history-oo-timeline-pos {
border: 2px solid @cryptpad_text_col;
height: 37px;

View File

@ -13,6 +13,7 @@ const Channel = require("./channel");
const Invitation = require("./invitation");
const Users = require("./users");
const Moderators = require("./moderators");
const Linked = require("./linked");
const BlockStore = require("../storage/block");
const MFA = require("../storage/mfa");
const ArchiveAccount = require('../archive-account');
@ -195,16 +196,21 @@ var archiveDocument = function (Env, Server, _cb, data) {
switch (id.length) {
case 32:
return void Env.msgStore.archiveChannel(id, archiveReason, Util.both(cb, function (err) {
Env.Log.info("ARCHIVAL_CHANNEL_BY_ADMIN_RPC", {
channelId: id,
reason: reason,
status: err? String(err): "SUCCESS",
});
Channel.disconnectChannelMembers(Env, Server, id, 'EDELETED', reasonStr, err => {
if (err) { } // TODO
});
}));
return void Linked.listLinkedDocuments(Env, id, (err, channels) => {
Env.msgStore.archiveChannel(id, archiveReason, Util.both(cb, function (err) {
if (!err && channels) {
Linked.archiveLinkedData(Env, id, archiveReason, channels, () => {});
}
Env.Log.info("ARCHIVAL_CHANNEL_BY_ADMIN_RPC", {
channelId: id,
reason: reason,
status: err? String(err): "SUCCESS",
});
Channel.disconnectChannelMembers(Env, Server, id, 'EDELETED', reasonStr, err => {
if (err) { } // TODO
});
}));
});
case 48:
return void Env.blobStore.archive.blob(id, archiveReason, Util.both(cb, function (err) {
Env.Log.info("ARCHIVAL_BLOB_BY_ADMIN_RPC", {
@ -260,16 +266,21 @@ var removeDocument = function (Env, Server, cb, data) {
switch (id.length) {
case 32:
return void Env.msgStore.removeChannel(id, Util.both(cb, function (err) {
Env.Log.info("REMOVAL_CHANNEL_BY_ADMIN_RPC", {
channelId: id,
reason: reason,
status: err? String(err): "SUCCESS",
});
Channel.disconnectChannelMembers(Env, Server, id, 'EDELETED', reason, err => {
if (err) { } // TODO
});
}));
return void Linked.listLinkedDocuments(Env, id, (err, channels) => {
Env.msgStore.removeChannel(id, Util.both(cb, function (err) {
if (!err && channels) {
Linked.archiveLinkedData(Env, id, reason, channels, () => {});
}
Env.Log.info("REMOVAL_CHANNEL_BY_ADMIN_RPC", {
channelId: id,
reason: reason,
status: err? String(err): "SUCCESS",
});
Channel.disconnectChannelMembers(Env, Server, id, 'EDELETED', reason, err => {
if (err) { } // TODO
});
}));
});
case 48:
return void Env.blobStore.remove.blob(id, Util.both(cb, function (err) {
Env.Log.info("REMOVAL_BLOB_BY_ADMIN_RPC", {

View File

@ -8,6 +8,7 @@ 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");
@ -171,7 +172,15 @@ Channel.removeOwnedChannel = function (Env, safeKey, obj, __cb, Server) {
if (Env.blobStore.isFileId(channelId)) {
return void Env.removeOwnedBlob(channelId, safeKey, reason, cb);
}
archiveOwnedChannel(Env, safeKey, channelId, reason, cb, Server);
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);
});
});
};
@ -200,7 +209,11 @@ Channel.trimHistory = function (Env, safeKey, data, cb) {
}
// 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,

445
lib/commands/linked.js Normal file
View File

@ -0,0 +1,445 @@
// 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

@ -9,7 +9,7 @@ const Core = require("./core");
const Util = require("../common-util");
const HK = require("../hk-util");
Data.getMetadataRaw = function (Env, channel /* channelName */, _cb) {
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 &&
@ -39,6 +39,17 @@ Data.getMetadataRaw = function (Env, channel /* channelName */, _cb) {
// 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);
});
});
@ -142,7 +153,11 @@ Data.setMetadata = function (Env, safeKey, data, cb, Server) {
cb(void 0, metadata);
return void next();
}
Env.msgStore.writeMetadata(channel, JSON.stringify(line), function (e) {
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();
@ -208,6 +223,6 @@ Data.setMetadata = function (Env, safeKey, data, cb, Server) {
Server.removeFromChannel(channel, toRemove);
});
});
}, true);
});
};

View File

@ -7,6 +7,7 @@ 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;
@ -74,6 +75,25 @@ var getChannelList = Pinning.getChannelList = function (Env, safeKey, _cb) {
});
};
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];
@ -108,6 +128,8 @@ Pinning.getTotalSize = function (Env, safeKey, cb) {
}));
});
}
}).nThen(function (waitFor) {
addLinkedDocuments(Env, channels, waitFor());
}).nThen(function () {
Env.getTotalSize(Array.from(channels), done);
});

View File

@ -69,6 +69,7 @@ Upload.status = function (Env, safeKey, data, _cb) { // FIXME FILES
var user = Core.getSession(Env.Sessions, safeKey);
user.pendingUploadSize = filesize;
user.currentUploadSize = 0;
user.linked = data.linked;
cb(void 0, false);
});
@ -88,7 +89,8 @@ var completeUpload = function (owned) {
Env.blobStore.closeBlobstage(safeKey);
var user = Core.getSession(Env.Sessions, safeKey);
var size = user.pendingUploadSize;
Env.completeUpload(safeKey, arg, Boolean(owned), size, cb);
var linked = user.linked;
Env.completeUpload(safeKey, arg, Boolean(owned), size, linked, cb);
};
};

View File

@ -5,6 +5,7 @@
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');
@ -379,10 +380,21 @@ module.exports = function (Env, cb) {
accountRetentionTime = -1;
}
var pinAll = function (pinList) {
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) {
@ -422,8 +434,7 @@ module.exports = function (Env, cb) {
if (accountIsActive(mtime, pinList)) {
// add active accounts' pinned documents to a second bloom filter
pinAll(pinList);
return void next();
return pinAll(pinList, next);
}
// Otherwise they are inactive.
@ -431,19 +442,21 @@ module.exports = function (Env, cb) {
// we plan to delete them, because it may be interesting information
inactive++;
if (PRESERVE_INACTIVE_ACCOUNTS) {
pinAll(pinList);
return Log.info('EVICT_INACTIVE_ACCOUNT_PRESERVED', {
id: id,
mtime: mtime,
}, next);
return pinAll(pinList, () => {
Log.info('EVICT_INACTIVE_ACCOUNT_PRESERVED', {
id: id,
mtime: mtime,
}, next);
});
}
if (isPremiumAccount(id)) {
pinAll(pinList);
return Log.info("EVICT_INACTIVE_PREMIUM_ACCOUNT", {
id: id,
mtime: mtime,
}, next);
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

View File

@ -448,7 +448,7 @@ const storeMessage = function (Env, channel, msg, isCp, optionalMessageHash, tim
* -1 if you didn't find it
*/
const getHistoryOffset = (Env, channelName, lastKnownHash, _cb) => {
const getHistoryOffset = HK.getHistoryOffset = (Env, channelName, lastKnownHash, _cb) => {
const cb = Util.once(Util.mkAsync(_cb));
// lastKnownhash === -1 means we want the complete history
@ -866,7 +866,7 @@ const handleGetFullHistory = function (Env, Server, seq, userId, parsed) {
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(['FULL_HISTORY', msg])], readMore);
}, (err) => {
let parsedMsg = ['FULL_HISTORY_END', parsed[1]];
if (err) {
if (err && err?.code !== 'ENOENT') {
Log.error('HK_GET_FULL_HISTORY', err.stack);
parsedMsg = ['ERROR', parsed[1], err.message];
}

View File

@ -335,6 +335,21 @@ commands.RESET_OWNERS = function (meta, args) {
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

View File

@ -11,6 +11,7 @@ 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;
@ -25,7 +26,11 @@ const UNAUTHENTICATED_CALLS = {
DELETE_MAILBOX_MESSAGE: Channel.deleteMailboxMessage,
GET_METADATA: Metadata.getMetadata,
IS_PREMIUM: Pinning.isPremium,
ADD_FIRST_ADMIN: Admin.addFirstAdmin
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) {

View File

@ -378,7 +378,7 @@ var upload_cancel = function (Env, safeKey, fileSize, cb) {
};
// upload_complete
var upload_complete = function (Env, safeKey, id, cb) {
var upload_complete = function (Env, safeKey, id, cb, linked) {
closeBlobstage(Env, safeKey);
var oldPath = makeStagePath(Env, safeKey);
@ -405,6 +405,12 @@ var upload_complete = function (Env, safeKey, id, cb) {
}
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
@ -438,7 +444,7 @@ var tryId = function (path, cb) {
let unescapeKeyCharacters = function (key) {
return key.replace(/\-/g, '/');
};
var owned_upload_complete = function (Env, safeKey, id, cb) {
var owned_upload_complete = function (Env, safeKey, id, cb, linked) {
closeBlobstage(Env, safeKey);
if (!isValidId(id)) {
return void cb('EINVAL_ID');
@ -472,9 +478,9 @@ var owned_upload_complete = function (Env, safeKey, id, cb) {
}));
}).nThen(function (w) {
// Write the metadata
let md = JSON.stringify({
owners: [unsafeKey]
});
let meta = { owners: [unsafeKey] };
if (linked) { meta.linked = linked; }
let md = JSON.stringify(meta);
writeMetadata(Env, id, md, w((e) => {
if (e) {
w.abort();
@ -883,17 +889,17 @@ BlobStore.create = function (config, _cb) {
closeBlobstage: function (safeKey) {
closeBlobstage(Env, safeKey);
},
complete: function (safeKey, id, _cb) {
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);
upload_complete(Env, safeKey, id, cb, linked);
},
completeOwned: function (safeKey, id, _cb) {
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);
owned_upload_complete(Env, safeKey, id, cb, linked);
},
size: function (id, _cb) {
var cb = Util.once(Util.mkAsync(_cb));

View File

@ -38,6 +38,8 @@ const STREAM_CLOSE_TIMEOUT = 120000;
leaks are bad. */
const STREAM_DESTROY_TIMEOUT = 30000;
const LINKED_CP = 10;
const isValidChannelId = function (id) {
return typeof(id) === 'string' &&
id.length >= 32 && id.length < 50 &&
@ -76,6 +78,21 @@ var mkPlaceholderPath = function (env, channelId) {
return mkPath(env, channelId) + '.placeholder';
};
// Linked documents (e.g. office patches, form responses)
/*
{
"media": [], // images
"checkpoints": [], // office checkpoints
"channels": [] // form responses
}
*/
var mkLinkedPath = function (env, channelId) {
return mkPath(env, channelId) + '.linked';
};
var mkArchiveLinkedPath = function (env, channelId) {
return mkArchivePath(env, channelId) + '.linked';
};
// pass in the path so we can reuse the same function for archived files
var channelExists = function (filepath, cb) {
Fs.stat(filepath, function (err, stat) {
@ -165,6 +182,91 @@ var readPlaceholder = function (env, channelId, cb) {
});
};
const readLinkedFile = (env, path, cb) => {
Fs.readFile(path, function (err, content) {
if (err) { return void cb(); }
try {
cb(void 0, JSON.parse(content.toString('utf8')));
} catch (e) {
cb();
}
});
};
const writeLinkedFile = (env, path, content, cb) => {
let s_data;
try {
s_data = JSON.stringify(content);
} catch (e) {
return cb(e);
}
Fs.writeFile(path, s_data, err => {
if (err) { return void cb(err); }
cb(void 0, content);
});
};
const addLinkedDocument = (env, channelId, type, data, cb) => {
const path = mkLinkedPath(env, channelId);
readLinkedFile(env, path, (_err, content = {}) => {
if (type === 'checkpoints') {
if (!(data?.rtChannel?.length === 32 &&
(!data.blob || data?.blob?.length === 48) &&
data?.user && data?.time)) {
return void cb('EINVAL');
}
content.checkpoints ||= [];
content.checkpoints.push(data);
content.checkpoints = content.checkpoints.slice(-LINKED_CP);
} else if (type === 'media') {
if (data.length !== 48) { return void cb('EINVAL'); }
content.media ||= [];
if (content.media.includes(data)) {
return void cb(void 0, content);
}
content.media.push(data);
} else if (type === 'channels') {
if (data.length !== 32) { return void cb('EINVAL'); }
content.channels ||= [];
if (content.channels.includes(data)) {
return void cb(void 0, content);
}
content.channels.push(data);
}
writeLinkedFile(env, path, content, cb);
});
};
const resetLinkedDocuments = (env, channelId, content, cb) => {
const path = mkLinkedPath(env, channelId);
readLinkedFile(env, path, (_err, oldContent = {}) => {
writeLinkedFile(env, path, content, err => {
if (err) { return void cb(err); }
cb(void 0, { oldContent, content });
});
});
};
const removeLinkedDocument = (env, channelId, type, data, cb) => {
const path = mkLinkedPath(env, channelId);
readLinkedFile(env, path, (_err, content = {}) => {
if (type === 'checkpoint' && content.checkpoints) {
content.checkpoints = content.checkpoints.filter(obj => {
return obj.blob !== data;
});
} else if (type === "media" && content.media) {
content.media.filter(str => {
return str !== data;
});
} else if (type === "channel" && content.channels) {
content.channels.filter(str => {
return str !== data;
});
}
writeLinkedFile(env, path, content, cb);
});
};
const getLinkedDocuments = (env, channelId, cb) => {
const path = mkLinkedPath(env, channelId);
readLinkedFile(env, path, cb);
};
const destroyStream = function (stream) {
if (!stream) { return; }
@ -567,6 +669,7 @@ var removeChannel = function (env, channelName, cb) {
var removeArchivedChannel = function (env, channelName, cb) {
var channelPath = mkArchivePath(env, channelName);
var metadataPath = mkArchiveMetadataPath(env, channelName);
var linkedPath = mkArchiveLinkedPath(env, channelName);
var CB = Util.once(cb);
@ -585,6 +688,13 @@ var removeArchivedChannel = function (env, channelName, cb) {
CB(labelError("E_ARCHIVED_METADATA_REMOVAL", err));
}
}));
Fs.unlink(linkedPath, w(function (err) {
if (err) {
if (err.code === "ENOENT") { return; }
w.abort();
CB(labelError("E_ARCHIVED_LINKED_REMOVAL", err));
}
}));
}).nThen(function () {
CB();
});
@ -772,6 +882,12 @@ var archiveChannel = function (env, channelName, reason, cb) {
}).nThen(function (w) {
if (!reason) { return; }
addPlaceholder(env, channelName, reason, w());
}).nThen(function (w) {
// archive the dedicated .linked channel
var linkedPath = mkLinkedPath(env, channelName);
var archivePath = mkArchiveLinkedPath(env, channelName);
// Ignore errors, proceed to archive metadata
Fse.move(linkedPath, archivePath, { overwrite: true }, w());
}).nThen(function (w) {
// archive the dedicated metadata channel
var metadataPath = mkMetadataPath(env, channelName);
@ -849,6 +965,13 @@ var unarchiveChannel = function (env, channelName, cb) {
}));
}).nThen(function (w) {
clearPlaceholder(env, channelName, w());
}).nThen(function (w) {
// construct archive linked path
var archiveLinkedPath = mkArchiveLinkedPath(env, channelName);
var linkedPath = mkLinkedPath(env, channelName);
// restore the archived .linked file
// ignore errors, always try to restore metadata
Fse.move(archiveLinkedPath, linkedPath, w());
}).nThen(function (w) {
var archiveMetadataPath = mkArchiveMetadataPath(env, channelName);
// TODO validate that it's ok to move metadata non-atomically
@ -1439,6 +1562,39 @@ module.exports.create = function (conf, _cb) {
});
},
// LINKED DOCUMENTS
// get linked documents
getLinkedDocuments: (channelId, cb) => {
if (!isValidChannelId(channelId)) { return cb(new Error('EINVAL')); }
// read can be unordered
schedule.unordered(channelId, function (next) {
getLinkedDocuments(env, channelId, Util.both(cb, next));
});
},
// add linked document
addLinkedDocument: (channelId, type, data, cb) => {
if (!isValidChannelId(channelId)) { return cb(new Error('EINVAL')); }
// write to the file: apply in order
schedule.ordered(channelId, function (next) {
addLinkedDocument(env, channelId, type, data, Util.both(cb, next));
});
},
resetLinkedDocuments: (channelId, json, cb) => {
if (!isValidChannelId(channelId)) { return cb(new Error('EINVAL')); }
// write to the file: apply in order
schedule.ordered(channelId, function (next) {
resetLinkedDocuments(env, channelId, json, Util.both(cb, next));
});
},
// remove linked document
removeLinkedDocument: (channelId, type, data, cb) => {
if (!isValidChannelId(channelId)) { return cb(new Error('EINVAL')); }
// write to the file: apply in order
schedule.ordered(channelId, function (next) {
removeLinkedDocument(env, channelId, type, data, Util.both(cb, next));
});
},
// CHANNEL ITERATION
listChannels: function (handler, cb, fastMode) {
listChannels(env.root, handler, cb, fastMode);

View File

@ -702,7 +702,7 @@ const completeUpload = function (data, cb) {
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) {

View File

@ -10,6 +10,7 @@ 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;
@ -480,9 +481,13 @@ Workers.initialize = function (Env, config, _cb) {
}, cb);
};
Env.getFileSize = function (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);
};
@ -579,9 +584,10 @@ Workers.initialize = function (Env, config, _cb) {
}, cb);
};
Env.completeUpload = function (safeKey, arg, owned, size, 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)

58
package-lock.json generated
View File

@ -1311,9 +1311,9 @@
}
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@ -1395,15 +1395,15 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/braces": {
@ -2391,9 +2391,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true,
"funding": [
{
@ -2876,7 +2876,9 @@
}
},
"node_modules/http-proxy-middleware": {
"version": "3.0.5",
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.7.tgz",
"integrity": "sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==",
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.15",
@ -2887,7 +2889,7 @@
"micromatch": "^4.0.8"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
"node": "^14.18.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/http-proxy-middleware/node_modules/http-proxy": {
@ -3066,8 +3068,20 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@ -3536,7 +3550,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [
{
@ -3816,9 +3832,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"version": "8.5.24",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz",
"integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==",
"dev": true,
"funding": [
{
@ -3836,7 +3852,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@ -5064,9 +5080,9 @@
}
},
"node_modules/ws": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@ -1339,7 +1339,7 @@ const factory = (UserObject, Util, Hash,
var data = userObject.getFileData(fileId);
if (!data) { return; }
// Pin onlyoffice checkpoints
if (data.lastVersion) {
if (data.lastVersion ) {
var otherChan = Hash.hrefToHexChannelId(data.lastVersion);
result.add(otherChan);
}
@ -1438,6 +1438,7 @@ const factory = (UserObject, Util, Hash,
});
return all;
};
/*
const findMissingRtChannel = (Env) => {
const userObjects = _getUserObjects(Env);
const all = [];
@ -1449,6 +1450,7 @@ const factory = (UserObject, Util, Hash,
});
return all;
};
*/
var create = function (proxy, data, uoConfig) {
var Env = {
@ -1489,19 +1491,6 @@ const factory = (UserObject, Util, Hash,
delete Env.unpinPads;
};
let rtChannelTo;
const setRtChannelTo = () => {
clearTimeout(rtChannelTo);
rtChannelTo = setTimeout(() => {
if (Env.store.offline) { return void setRtChannelTo(); }
const list = findMissingRtChannel(Env);
Env.Store.fixMissingRtChannelInterval(list, () => {
setRtChannelTo();
});
}, 120000);
};
setRtChannelTo();
return {
// Manager
addProxy: callWithEnv(addProxy),

View File

@ -3,7 +3,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
(() => {
const factory = (Util, Hash, Realtime, Feedback) => {
const factory = (Util, Hash, Realtime) => {
let window = globalThis;
var module = {};
@ -68,6 +68,8 @@ const factory = (Util, Hash, Realtime, Feedback) => {
var data = exp.getFileData(id, true);
if (attr === "href") {
exp.setHref(null, id, value);
} else if (!value) {
delete data[attr];
} else {
data[attr] = clone(value);
}
@ -86,14 +88,9 @@ const factory = (Util, Hash, Realtime, Feedback) => {
if (readOnly) { return void cb('EFORBIDDEN'); }
var id = Util.createRandomInteger();
var data = clone(_data);
let parsed = Hash.parsePadUrl(data.roHref || data.href);
// If we were given an edit link, encrypt its value if needed
if (data.href && data.href.indexOf('#') !== -1) { data.href = exp.cryptor.encrypt(data.href); }
if (['sheet', 'doc', 'presentation'].includes(parsed?.type) && !data.rtChannel) {
Feedback.send('PUSH_DATA_MISSING_RT_CHANNEL', true);
}
files[FILES_DATA][id] = data;
cb(null, id);
};
@ -891,7 +888,7 @@ const factory = (Util, Hash, Realtime, Feedback) => {
// toClean.push(id);
}
if (['sheet', 'doc', 'presentation'].includes(parsed.type) && !el.rtChannel) {
if (!el.linked && ['sheet', 'doc', 'presentation'].includes(parsed.type) && !el.rtChannel) {
missingRtChannel[el.channel] = el;
}
@ -1027,15 +1024,13 @@ if (typeof(module) !== 'undefined' && module.exports) {
module.exports = factory(
require('./common-util'),
require('./common-hash'),
require('./common-realtime'),
require('./common-feedback'),
require('./common-realtime')
);
} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) {
define([
'/common/common-util.js',
'/common/common-hash.js',
'/common/common-realtime.js',
'/common/common-feedback.js',
'/common/common-realtime.js'
], factory);
} else {
// unsupported initialization

View File

@ -8,7 +8,7 @@ const factory = (Sortify, UserObject, ProxyManager,
SF, AccountTS, DriveTS, PadTS, Form, Cursor,
Support, Integration, OnlyOffice,
Mailbox, Profile, Team, Messenger, History,
Calendar, BadgeTS, Block, NetConfig,
Calendar, BadgeTS, LinkedTS, Block, NetConfig,
Crypto, ChainPad, CpNetflux, Listmap,
Netflux, nThen) => {
@ -16,6 +16,7 @@ const factory = (Sortify, UserObject, ProxyManager,
const Drive = DriveTS.Drive;
const Pad = PadTS.Pad;
const Badge = BadgeTS.Badge;
const LinkedDoc = LinkedTS.LinkedDoc;
const window = globalThis;
globalThis.nacl = globalThis.nacl || Crypto.Nacl;
@ -392,7 +393,8 @@ const factory = (Sortify, UserObject, ProxyManager,
if (!s.rpc) { return void cb({error: 'RPC_NOT_READY'}); }
s.rpc.uploadStatus({
id: data.id,
size: data.size
size: data.size,
linked: data.linked
}, function (err, res) {
if (err) { return void cb({error:err}); }
cb(res);
@ -736,7 +738,7 @@ const factory = (Sortify, UserObject, ProxyManager,
});
if (['doc', 'sheet', 'presentation'].includes(parsed.type)) {
if (!pad.rtChannel) {
if (!pad.rtChannel && !pad.linked) {
return getRtChannelFromPad(pad, (err, rtChannel) => {
const key = 'ADDPAD_NO_RT_CHANNEL' ;
if (!err) {
@ -1785,6 +1787,7 @@ const factory = (Sortify, UserObject, ProxyManager,
var secret = Hash.getSecrets(parsed.type, parsed.hash, data.password);
if (obj && obj.error) { return; }
if (!obj.mailbox) { return; }
if (!store.loggedIn) { return; }
// Decrypt the mailbox
var crypto = Crypto.createEncryptor(secret.keys);
@ -1891,7 +1894,7 @@ const factory = (Sortify, UserObject, ProxyManager,
if (msg) {
msg = msg.replace(/cp\|(([A-Za-z0-9+\/=]+)\|)?/, '');
//var decryptedMsg = crypto.decrypt(msg, true);
if (data.debug) {
if (data.debug || data.full) {
msgs.push({
serverHash: msg.slice(0,64),
msg: msg,
@ -2428,6 +2431,7 @@ const factory = (Sortify, UserObject, ProxyManager,
loadUniversal(Messenger, 'messenger', waitFor);
loadUniversal(History, 'history', waitFor);
loadUniversal(Badge, 'badge', waitFor);
loadUniversal(LinkedDoc, 'linked-doc', waitFor);
loadOnlyOffice();
if (store) {
store.messenger = store.modules['messenger'];
@ -3126,6 +3130,7 @@ module.exports = factory(
require('./modules/history'),
require('./modules/calendar'),
require('./modules/badge'), // .ts
require('./modules/linked'), // .ts
require('../common/outer/login-block'),
require('../common/network-config'),
require('chainpad-crypto'),

View File

@ -85,13 +85,33 @@ const factory = (Util, Hash, UserObject, nThen) => {
return team.rpc;
};
var getHistoryData = function (ctx, channel, lastKnownHash, teamId, _cb) {
/*
Before (old cp still active or checking from drive)
- for each channel, call getHistoryData
- get file total size
- get required history size (data to preserve)
- get metadata size (to preserver)
- compute removable history size
After
- only one channel
- call getHistorySize from lib/commands/linked.js
- call getTotalSize from lib/commands/linked.js
*/
var getHistoryData = function (ctx, channel, lastKnownHash, teamId, linked, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
var edPublic = getEdPublic(ctx, teamId);
var Store = ctx.Store;
var total = 0;
var history = 0;
var dataSize = 0;
var metadata = 0;
var hash;
nThen(function (waitFor) {
@ -110,27 +130,42 @@ const factory = (Util, Hash, UserObject, nThen) => {
total = obj.size;
}));
// Pad
Store.getHistory(null, {
channel: channel,
lastKnownHash: lastKnownHash
}, waitFor(function (obj) {
if (obj && obj.error) {
waitFor.abort();
return void cb(obj);
}
if (!Array.isArray(obj)) {
waitFor.abort();
return void cb({error: 'EINVAL'});
}
if (linked) {
Store.anonRpcMsg('', {
msg: 'GET_HISTORY_SIZE',
data: { channel }
}, waitFor(obj => {
if (obj?.error) {
waitFor.abort();
return void cb(obj);
}
let value = obj[0];
dataSize = value?.size || 0;
hash = value?.hash;
}));
} else {
Store.getHistory(null, {
channel: channel,
lastKnownHash: lastKnownHash
}, waitFor(function (obj) {
if (obj && obj.error) {
waitFor.abort();
return void cb(obj);
}
if (!Array.isArray(obj)) {
waitFor.abort();
return void cb({error: 'EINVAL'});
}
if (!obj.length) { return; }
if (!obj.length) { return; }
hash = obj[0].hash;
var messages = obj.map(function(data) {
return data.msg;
});
history = messages.join('\n').length;
}), true);
hash = obj[0].hash;
var messages = obj.map(function(data) {
return data.msg;
});
dataSize = messages.join('\n').length;
}), true);
}
// Metadata
Store.pad.getMetadata(null, {
channel: channel
@ -146,7 +181,8 @@ const factory = (Util, Hash, UserObject, nThen) => {
}));
}).nThen(function () {
cb({
size: (total - metadata - history),
total,
size: (total - metadata - dataSize),
hash: hash
});
});
@ -154,7 +190,10 @@ const factory = (Util, Hash, UserObject, nThen) => {
};
commands.GET_HISTORY_SIZE = function (ctx, data, cId, cb) {
if (!ctx.store.loggedIn || !ctx.store.rpc) { return void cb({ error: 'INSUFFICIENT_PERMISSIONS' }); }
if (!ctx.store.loggedIn || !ctx.store.rpc || !ctx.store.anon_rpc) {
return void cb({ error: 'INSUFFICIENT_PERMISSIONS' });
}
var channels = data.channels;
if (!Array.isArray(channels)) { return void cb({ error: 'EINVAL' }); }
@ -168,6 +207,7 @@ const factory = (Util, Hash, UserObject, nThen) => {
}
var size = 0;
var total = 0;
var res = [];
nThen(function (waitFor) {
channels.forEach(function (chan) {
@ -177,12 +217,13 @@ const factory = (Util, Hash, UserObject, nThen) => {
channel = chan.channel;
lastKnownHash = chan.lastKnownHash;
}
getHistoryData(ctx, channel, lastKnownHash, data.teamId, waitFor(function (obj) {
getHistoryData(ctx, channel, lastKnownHash, data.teamId, data.linked, waitFor(function (obj) {
if (obj && obj.error) {
warning.push(obj.error);
return;
}
size += obj.size;
total += obj.total;
if (!obj.hash) { return; }
res.push({
channel: channel,
@ -194,11 +235,13 @@ const factory = (Util, Hash, UserObject, nThen) => {
cb({
warning: warning.length ? warning : undefined,
channels: res,
size: size
size: size, // history size
total // total size
});
});
};
// XXX
commands.TRIM_HISTORY = function (ctx, data, cId, cb) {
if (!ctx.store.loggedIn || !ctx.store.rpc) { return void cb({ error: 'INSUFFICIENT_PERMISSIONS' }); }
var channels = data.channels;

View File

@ -0,0 +1,161 @@
// SPDX-FileCopyrightText: 2025 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import nacl from 'tweetnacl/nacl-fast';
import nThen from 'nthen';
import { Module, ModuleObject, Command } from '../types'
import * as Util from '../../common/common-util.js';
import Sortify from 'json.sortify';
export interface LinkedDocModule<T> extends Module<T> {
setCustomize: (data: any) => void
}
let ApiConfig:any = {};
const getLinkedDocuments = (ctx, channel, cb) => {
ctx.Store.anonRpcMsg('', {
msg: 'GET_LINKED_DOCUMENTS',
data: { channel }
}, cb);
};
const resetLinkedDocuments = (ctx, data, cb) => {
ctx.Store.anonRpcMsg('', {
msg: 'RESET_LINKED_DOCUMENTS',
data // { channel, user, content, netfluxId, proof }
}, cb);
};
const addLinkedDocument = (ctx, data, cb) => {
ctx.Store.anonRpcMsg('', {
msg: 'ADD_LINKED_DOCUMENT',
data // { channel, user, content, netfluxId, proof }
}, cb);
};
const signData = (data, signKey) => {
try {
const edPrivate = Util.decodeBase64(signKey); // pad signing key
const msg = Util.decodeUTF8(JSON.stringify(data));
data.proof = Util.encodeBase64(nacl.sign.detached(msg, edPrivate));
return data;
} catch (e) {
return false;
}
};
// TODO
/*
- one RPC to add or remove multiple elements?
- add a single element (checkpoint or image?)
- remove single?
*/
const getLinkedData:Command = (ctx, data, clientId, cb) => {
const { channel } = data;
getLinkedDocuments(ctx, channel, obj => {
if (obj?.error) { return void cb(obj); }
const json = obj?.[0] || {};
cb(json);
});
};
const checkCurrentDoc:Command = (ctx, data, clientId, cb) => {
const { channel, expectedJSON, signKey64 } = data;
const missing = {};
getLinkedDocuments(ctx, channel, (obj) => {
const json = obj?.[0] || {};
json?.checkpoints?.forEach(obj => {
delete obj.time;
delete obj.user;
});
expectedJSON.media ||= [];
expectedJSON.checkpoints ||= [];
expectedJSON.channels ||= [];
if (Sortify(json) === Sortify(expectedJSON)) { return void cb(); }
// "user" won't be encrypted so we can't add the username
const user = ctx.store.proxy.edPublic || 'GUEST';
// Add netfluxId to guard against replay attacks
const netfluxId = ctx.store.network?.webChannels?.[0]?.myID;
const toSend = signData({
user, channel, netfluxId,
content: expectedJSON
}, signKey64);
if (!toSend) {
return void cb({error: 'SIGN_ERROR'});
}
resetLinkedDocuments(ctx, toSend, cb);
});
};
const addLinkedData:Command = (ctx, data, clientId, cb) => {
const { channel, content, signKey64 } = data;
// content.type, content.data
// "user" won't be encrypted so we can't add the username
const user = ctx.store.proxy.edPublic || 'GUEST';
// Add netfluxId to guard against replay attacks
const netfluxId = ctx.store.network?.webChannels?.[0]?.myID;
const toSend = signData({
user, channel, netfluxId, content
}, signKey64);
if (!toSend) {
return void cb({error: 'SIGN_ERROR'});
}
addLinkedDocument(ctx, toSend, cb);
};
const LinkedDoc: LinkedDocModule<ModuleObject> = {
init: (config, cb, emit) => {
const ctx:any = {
store: config.store,
Store: config.Store
//updateMetadata: config.updateMetadata
};
return {
removeClient: () => {},
execCommand: (clientId, obj, cb) => {
if (!ctx.store?.network?.webChannels.length ||
!ctx.store?.ready) {
return void cb({error: 'OFFLINE'});
}
const cmd = obj.cmd;
const data = obj.data;
if (cmd === 'CHECK_CURRENT_DOC') {
return void checkCurrentDoc(ctx, data, clientId, cb);
}
if (cmd === 'GET_LINKED_DATA') {
return void getLinkedData(ctx, data, clientId, cb);
}
if (cmd === 'ADD_LINKED_DATA') {
return void addLinkedData(ctx, data, clientId, cb);
}
cb();
},
}
},
setCustomize: data => {
ApiConfig = data?.ApiConfig;
}
};
export { LinkedDoc }

View File

@ -27,15 +27,26 @@ const factory = (Feedback) => {
var first = true;
var c = ctx.clients[client];
if (!c) {
var chan = ctx.channels[channel];
if (!c) { // new tab
c = ctx.clients[client] = {
channel: channel,
};
} else {
} else if (c?.channel !== channel) { // new channel on existing tab
// Remove client from existing chan
// and disconnect from chan if needed
ctx.removeClient(client, true);
c = ctx.clients[client] = {
channel: channel,
};
} else { // same channel existing tab
setTimeout(() => {
ctx.emit('READY', chan.clients, [client]);
});
return void cb();
}
var chan = ctx.channels[channel];
if (chan) {
// This channel is already open in another tab
@ -114,8 +125,7 @@ const factory = (Feedback) => {
metadata: {
//forcePlaceholder: true,
validateKey: obj.validateKey,
owners: obj.owners,
expire: obj.expire
linked: obj.padChan
}
};
var msg = ['GET_HISTORY', wc.id, cfg];
@ -209,25 +219,6 @@ const factory = (Feedback) => {
});
};
var updateHash = function (ctx, data, clientId, cb) {
var c = ctx.clients[clientId];
if (!c) { return void cb({ error: 'NOT_IN_CHANNEL' }); }
var chan = ctx.channels[c.channel];
if (!chan) { return void cb({ error: 'INVALID_CHANNEL' }); }
var hash = data;
var index = -1;
chan.history.some(function (msg, idx) {
if (msg.slice(0,64) === hash) {
index = idx + 1;
return true;
}
});
if (index !== -1) {
chan.history = chan.history.slice(index);
}
cb();
};
var sendMessage = function (ctx, data, clientId, cb) {
var c = ctx.clients[clientId];
if (!c) { return void cb({ error: 'NOT_IN_CHANNEL' }); }
@ -255,18 +246,40 @@ const factory = (Feedback) => {
var channel = data.channel;
var network = ctx.store.network;
var hk = network.historyKeeper;
const txid = Math.floor(Math.random() * 1000000);
var onOpen = function (wc) {
var hk = network.historyKeeper;
const sendEncrypted = () => {
data.msgs.forEach(function (msg) {
wc.bcast(msg);
});
wc.leave();
cb();
};
const onDirectMessage = (msg, sender) => {
// Ignore messages for others
if (sender !== hk) { return; }
try {
const parsed = JSON.parse(msg);
if (parsed?.txid !== txid) { return; }
if (!parsed.channel) { return; }
// Remove listener and send re-encrypted messages
network.off('message', onDirectMessage);
sendEncrypted();
} catch (e) { console.error(e); }
};
network.on('message', onDirectMessage);
var cfg = {
txid: txid,
metadata: data.metadata
};
var msg = ['GET_HISTORY', wc.id, cfg];
network.sendto(hk, JSON.stringify(msg));
data.msgs.forEach(function (msg) {
wc.bcast(msg);
});
wc.leave();
cb();
};
ctx.store.anon_rpc.send("IS_NEW_CHANNEL", channel, function (e, response) {
@ -298,7 +311,7 @@ const factory = (Feedback) => {
};
// Remove the client from all its channels when a tab is closed
var removeClient = function (ctx, clientId) {
var removeClient = function (ctx, clientId, newChan) {
var filter = function (c) {
return c !== clientId;
};
@ -314,7 +327,7 @@ const factory = (Feedback) => {
}
}
if (ctx.clients[clientId]) {
if (ctx.clients[clientId] && !newChan) {
var oldChannel = ctx.clients[clientId].channel;
var oldChan = ctx.channels[oldChannel];
if (oldChan) {
@ -335,8 +348,8 @@ const factory = (Feedback) => {
clients: {}
};
oo.removeClient = function (clientId) {
removeClient(ctx, clientId);
oo.removeClient = ctx.removeClient = function (clientId, newChan) {
removeClient(ctx, clientId, newChan);
};
oo.leavePad = function (padChan) {
leaveChannel(ctx, padChan);
@ -347,9 +360,6 @@ const factory = (Feedback) => {
if (cmd === 'SEND_MESSAGE') {
return void sendMessage(ctx, data, clientId, cb);
}
if (cmd === 'UPDATE_HASH') {
return void updateHash(ctx, data, clientId, cb);
}
if (cmd === 'OPEN_CHANNEL') {
return void openChannel(ctx, data, clientId, cb);
}

View File

@ -29,6 +29,7 @@ export interface Module<T> {
init: (config: ModuleConfig, cb: Callback, emit: Callback) => T
}
export type Command = (ctx: any, data: any, clientId: string, cb: Callback) => void;
export type AccountConfig = {
anonHash: string,

View File

@ -1717,6 +1717,9 @@ define([
'role': 'menu',
'tabindex': '-1'
});
if (config.smallContent) {
$innerblock.addClass('cp-small');
}
var $outerblock = $(h('div.cp-dropdown-menu-container', $innerblock[0]));
let $parentMenu = config.isSubmenuOf;
$container.$menu = $innerblock;

View File

@ -635,8 +635,8 @@ define([
});
};
common.uploadStatus = function (teamId, id, size, cb) {
postMessage("UPLOAD_STATUS", {teamId, id, size}, function (obj) {
common.uploadStatus = function (data, cb) {
postMessage("UPLOAD_STATUS", data, function (obj) {
if (obj && obj.error) { return void cb(obj.error); }
cb(null, obj);
});
@ -1386,6 +1386,16 @@ define([
cb();
};
const sendUniversalCmd = (type, cmd, data, cb) => {
universal.execCommand({
type,
data: {
cmd,
data
}
}, cb);
};
common.changePadPassword = function (Crypt, Crypto, data, cb) {
var href = data.href;
var oldPassword = data.oldPassword;
@ -1729,6 +1739,7 @@ define([
var oldMetadata;
var oldRtChannel;
var privateData;
var lastCp;
var newSecret;
if (parsed.hashData.version >= 2) {
@ -1844,14 +1855,36 @@ define([
}), optsGet);
}).nThen(function (waitFor) {
// Re-encrypt rtchannel
oldRtChannel = Util.find(cryptgetVal, ['content', 'channel']);
var newCrypto = Crypto.createEncryptor(newSecret.keys);
var oldCrypto = Crypto.createEncryptor(oldSecret.keys);
var cps = Util.find(cryptgetVal, ['content', 'hashes']);
var cpLength = Object.keys(cps).length;
var lastCp = cpLength ? cps[cpLength] : {};
var cps = cryptgetVal?.content?.hashes;
var sortedCpIdx = Object.keys(cps).map(Number).sort();
var lastCpIdx = sortedCpIdx.pop();
lastCp = cps[lastCpIdx] || {};
oldRtChannel = lastCp?.rtChannel ||
cryptgetVal?.content?.channel;
cryptgetVal.content.hashes = {};
common.getHistory({
// Update rtChannel in content
if (lastCpIdx) {
cryptgetVal.content.hashes[lastCpIdx] = {
file: lastCp.file,
rtChannel: newRtChannel,
version: lastCp.version
};
delete cryptgetVal.content.channel;
} else {
cryptgetVal.content.channel = newRtChannel;
}
// Support old and new cp format
let f = common.getHistory;
if (!lastCp?.hash) { f = common.getFullHistory; }
f({
channel: oldRtChannel,
lastKnownHash: lastCp.hash
}, waitFor(function (obj) {
@ -1860,6 +1893,7 @@ define([
console.error(obj);
return void cb(obj.error);
}
// Re-encrypt messages
var msgs = obj;
var newHistory = msgs.map(function (str) {
try {
@ -1871,18 +1905,16 @@ define([
return void cb({error: e});
}
});
// Update last knwon hash in cryptgetVal
if (cpLength && newHistory.length) {
lastCp.hash = newHistory[0].slice(0, 64);
lastCp.index = 50;
cryptgetVal.content.hashes[1] = lastCp;
}
common.onlyoffice.execCommand({
cmd: 'REENCRYPT',
data: {
channel: newRtChannel,
msgs: newHistory,
metadata: optsPut.metadata
metadata: {
linked: newSecret.channel,
validateKey: optsPut.metadata.validateKey
}
}
}, waitFor(function (obj) {
if (obj && obj.error) {
@ -1894,9 +1926,8 @@ define([
}));
Cache.clearChannel(newSecret.channel, waitFor());
}).nThen(function (waitFor) {
// The new rt channel is ready
// The blob uses its own encryption and doesn't need to be reencrypted
cryptgetVal.content.channel = newRtChannel;
// The new rt channel is ready, we can create the new chainpad
if (!newPassword) { optsPut.metadata.forcePlaceholder = true; }
Crypt.put(newHash, JSON.stringify(cryptgetVal), waitFor(function (err) {
if (err) {
@ -1904,6 +1935,60 @@ define([
return void cb({ error: err });
}
}), optsPut);
}).nThen(function (waitFor) {
// The blob uses its own encryption and doesn't need to be
// reencrypted but we should make sure it's linked to the
// newSecret.channel
if (!lastCp.file) { return; }
const parsed = Hash.parsePadUrl(lastCp.file);
const fileSecret = Hash.getSecrets(parsed.type, parsed.hash);
common.setPadMetadata({
channel: fileSecret.channel,
command: 'SET_LINKED',
value: newSecret.channel
}, waitFor());
// If we had a checkpoint, make sure it won't be destroyed
// alongside the old document. We must remove it from the
// "linked" data
// Add the new "linked" data to the new document
const cp = {
blob: fileSecret.channel,
rtChannel: newRtChannel
};
const newLinkedData = { checkpoints: [cp] };
const newSigningKey = newSecret.keys?.signKey;
sendUniversalCmd('linked-doc', 'CHECK_CURRENT_DOC', {
channel: newSecret.channel,
expectedJSON: newLinkedData,
signKey64: newSigningKey
}, waitFor());
// Update old doc "linked" data
const oldSigningKey = oldSecret.keys?.signKey;
sendUniversalCmd('linked-doc', 'GET_LINKED_DATA', {
channel: oldSecret.channel
}, waitFor((json) => {
if (!json || json.error) { return; }
// Remove last cp "blob" from old "linked" data
// because we're re-using the same blob
const cp = json.checkpoints || [];
const lastCp = cp.pop();
// But preserve the associated rtChannel as "linked" so
// that this rtChannel can be destroyed with the main doc
if (lastCp?.rtChannel) {
json.channels ||= [];
json.channels.push(lastCp.rtChannel);
}
sendUniversalCmd('linked-doc', 'CHECK_CURRENT_DOC', {
channel: oldSecret.channel,
expectedJSON: json,
signKey64: oldSigningKey
}, waitFor());
}));
}).nThen(function (waitFor) {
pad.leavePad({
channel: oldSecret.channel
@ -1917,15 +2002,18 @@ define([
common.setPadAttribute('channel', newSecret.channel, waitFor(function (err) {
if (err) { warning = true; }
}), href);
common.setPadAttribute('rtChannel', newRtChannel, waitFor(function (err) {
if (err) { warning = true; }
}), href);
var viewHash = Hash.getViewHashFromKeys(newSecret);
newRoHref = '/' + parsed.type + '/#' + viewHash;
common.setPadAttribute('roHref', newRoHref, waitFor(function (err) {
if (err) { warning = true; }
}), href);
// Remove rtChannel and last cp data and mark as "linked"
common.setPadAttribute('linked', true, waitFor(() => {}), href);
common.setPadAttribute('rtChannel', void 0, waitFor(() => {}), href);
common.setPadAttribute('lastVersion', void 0, waitFor(() => {}), href);
common.setPadAttribute('lastCpHash', void 0, waitFor(() => {}), href);
if (parsed.hashData.password && newPassword) { return; } // same hash
common.setPadAttribute('href', newHref, waitFor(function (err) {
if (err) { warning = true; }

View File

@ -19,6 +19,7 @@ define([
var getPadProperties = function (Env, data, opts, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
var common = Env.common;
var sframeChan = common.getSframeChannel();
opts = opts || {};
var $d = $('<div>');
if (!data) { return void cb(void 0, $d); }
@ -32,6 +33,7 @@ define([
data.rtChannel = data.rtChannel || p.rtChannel;
data.lastVersion = data.lastVersion || p.lastVersion;
data.lastCpHash = data.lastCpHash || p.lastCpHash;
data.linked ||= p.linked;
}
if (data.channel) {
@ -78,12 +80,14 @@ define([
var bytes = 0;
var historyBytes;
var chan = [data.channel];
if (data.answersChannel) { chan.push(data.answersChannel); }
if (data.rtChannel) { chan.push(data.rtChannel); }
if (data.lastVersion) { chan.push(Hash.hrefToHexChannelId(data.lastVersion)); }
if (!data?.linked) {
if (data.answersChannel) { chan.push(data.answersChannel); }
if (data.rtChannel) { chan.push(data.rtChannel); }
if (data.lastVersion) { chan.push(Hash.hrefToHexChannelId(data.lastVersion)); }
}
// Get the channels with history (no blobs)
var channels = chan.filter(function (c) { return c.length === 32; }).map(function (id) {
var channels = chan.filter(c => c.length === 32).map(id => {
if (id === data.rtChannel && data.lastVersion && data.lastCpHash) {
return {
channel: id,
@ -98,25 +102,29 @@ define([
var history = common.makeUniversal('history');
var trimChannels = [];
nThen(function (waitFor) {
// Get total size
chan.forEach(function (c) {
common.getFileSize(c, waitFor(function (e, _bytes) {
if (e) {
// there was a problem with the RPC
console.error(e);
}
bytes += _bytes;
}), true);
});
if (!owned) {
// Get total size
chan.forEach(function (c) {
common.getFileSize(c, waitFor(function (e, _bytes) {
if (e) {
// there was a problem with the RPC
console.error(e);
}
bytes += _bytes;
}), true);
});
return;
}
if (!owned) { return; }
// Get history size
// Get data and history size
history.execCommand('GET_HISTORY_SIZE', {
pad: true,
linked: data?.linked,
channels: channels,
teamId: typeof(owned) === "number" && owned
}, waitFor(function (obj) {
if (obj && obj.error) { return; }
bytes = obj.total;
historyBytes = obj.size;
trimChannels = obj.channels;
}));
@ -168,23 +176,29 @@ define([
spinner.spin();
history.execCommand('TRIM_HISTORY', {
pad: true,
linked: data?.linked,
channels: trimChannels,
teamId: typeof(owned) === "number" && owned
}, function (obj) {
spinner.hide();
if (obj && obj.error || obj.warning) {
if (obj && obj.error || obj.warning) {
console.error(obj.warning);
$(size).append(h('div.alert.alert-danger', Messages.trimHistory_error));
spinner.hide();
return;
}
$(size).remove();
var formatted = UIElements.prettySize(bytes - historyBytes);
$d.append(h('div.cp-app-prop', [
Messages.upload_size,
h('br'),
h('span.cp-app-prop-content', formatted)
]));
$d.append(h('div.alert.alert-success', Messages.trimHistory_success));
sframeChan.query('Q_TRIM_HISTORY', {
href: data.href
}, function () {
spinner.hide();
$(size).remove();
var formatted = UIElements.prettySize(bytes - historyBytes);
$d.append(h('div.cp-app-prop', [
Messages.upload_size,
h('br'),
h('span.cp-app-prop-content', formatted)
]));
$d.append(h('div.alert.alert-success', Messages.trimHistory_success));
});
});
});

View File

@ -5,14 +5,93 @@
define([
'jquery',
'/common/common-interface.js',
'/common/common-ui-elements.js',
'/common/hyperscript.js',
'/common/common-icons.js',
'/common/common-util.js',
], function ($, UI, h, Icons, Util) {
], function ($, UI, UIElements, h, Icons, Util) {
//var ChainPad = window.ChainPad;
var History = {};
History.sortCpIndex = function (hashes) {
return Object.keys(hashes).map(Number).sort((a, b) => {
return a-b;
});
};
History.loadHistoryData = (cfg) => {
const {
sframeChan, mainRtChannel, downloadId,
currentCp, nextCp, href, password
} = cfg;
return new Promise((resolve, reject) => {
// New CP: use this cp's rtChannel
if (currentCp?.rtChannel) {
// Load all messages from currentCp.rtChannel
sframeChan.query('Q_GET_FULL_HISTORY', {
href, password, // get secret from other pad (template)
channel: currentCp.rtChannel,
isDownload: downloadId,
full: true
}, function (err, data) {
if (err) { return void reject(err); }
resolve(data);
});
return;
}
// Old CP or no CP: use mainRtChannel
if (!mainRtChannel) {
return void reject('EINVAL');
}
// No CP and no nextCp hash
if (!currentCp?.file && !nextCp?.hash) {
// Load all messages from mainRtChannel
sframeChan.query('Q_GET_FULL_HISTORY', {
href, password, // get secret from other pad (template)
channel: mainRtChannel,
isDownload: downloadId,
full: true
}, function (err, data) {
if (err) { return void reject(err); }
resolve(data);
});
return;
}
// If we have a startHash or an endHash, use
// the GET_HISTORY_RANGE command
let startHash = currentCp?.hash || 'NONE';
let endHash = nextCp?.hash;
if (currentCp?.hash || nextCp?.hash) {
sframeChan.query('Q_GET_HISTORY_RANGE', {
href, password, // get secret from other pad (template)
channel: mainRtChannel,
lastKnownHash: endHash,
toHash: startHash,
isDownload: downloadId,
}, function (err, data) {
if (err || !Array.isArray(data.messages)) {
return void reject(err || 'EINVAL');
}
let msgs = data.messages;
if (data.messages[0].serverHash === startHash) {
msgs.shift();
}
resolve(msgs);
});
return;
}
reject('INVALID_CP');
});
};
History.create = function (common, config) {
if (!config.$toolbar) { return void console.error("config.$toolbar is undefined");}
if (History.loading) { return void console.error("History is already being loaded..."); }
@ -25,537 +104,268 @@ define([
throw new Error("Missing config element");
}
var cpIndex = -1;
var msgIndex = -1;
var ooMessages = {};
var msgs;
var loading = false;
var currentTime;
//Defining position here means it can be passed to the showVersion and share functions
var position;
//Defining patch here means it can be passed to the snapshot function
var patch;
var currentVersion;
var forward;
var revertCheckpoint;
var previousRevertCheckpoint;
const metadataMgr = common.getMetadataMgr();
// Get an array of the checkpoint IDs sorted their patch index
var hashes = config.onlyoffice.hashes;
var id;
var sortedCp = Object.keys(hashes).map(Number);
const ooMessages = {};
var getId = function () {
var cps = sortedCp.length;
id = sortedCp[cps -1] || -1;
return id;
const hashes = config.onlyoffice.hashes;
const mainRtChannel = config.onlyoffice.channel;
const ctime = config.onlyoffice.ctime;
const sortedCp = History.sortCpIndex(hashes);
let cpIdx = sortedCp.length - 1;
let msgIdx = 0;
let loading = true;
const getCpId = () => {
return sortedCp[cpIdx] || 0;
};
const getCpMsgs = () => {
return ooMessages[getCpId()] || [];
};
const getCurrentMsg = () => {
const msgs = getCpMsgs();
return msgs[msgIdx];
};
const getCurrentVersion = () => {
return `${getCpId()}.${msgIdx}`;
};
var endWithCp = sortedCp.length &&
config.onlyoffice.lastHash === hashes[sortedCp[sortedCp.length - 1]].hash;
const loadMessages = () => {
return new Promise((resolve, reject) => {
let cpId = getCpId();
var fillOO = function (messages) {
ooMessages = {};
ooMessages[id] = messages;
if (Array.isArray(ooMessages[cpId])) {
return void resolve(ooMessages[cpId]);
}
let currentCp = hashes[cpId];
let nextCp = hashes[sortedCp[cpIdx + 1]];
History.loadHistoryData({
sframeChan,
mainRtChannel, hashes, sortedCp, currentCp, nextCp
}).then(data => {
data.unshift(void 0);
ooMessages[cpId] = data;
resolve(data);
}).catch(reject);
});
};
if (endWithCp) { cpIndex = 0; }
var $version, $share;
var $hist = $toolbar.find('.cp-toolbar-history');
let $version, $share, $timeline;
let $next, $prev;
const $hist = $toolbar.find('.cp-toolbar-history');
$hist.addClass('cp-smallpatch');
$hist.addClass('cp-history-oo');
var $bottom = $toolbar.find('.cp-toolbar-bottom');
var Messages = common.Messages;
const $bottom = $toolbar.find('.cp-toolbar-bottom');
const Messages = common.Messages;
var getVersion = function (position, initial, revert) {
let version = (id === -1 || id === 0) ? 0 : id;
if (!Object.keys(ooMessages).length) {
return '0.0';
}
if (typeof(position) === "undefined" || position === -1) {
position = ooMessages[id]?.length || 0;
} else if (msgs?.length === position &&
id !== parseInt(Object.keys(hashes)[Object.keys(hashes).length-1]) &&
!initial && !revert && $(`[data^="${id+1},"][data*=","]`).length > 1) {
version = id+1;
position = 0;
}
return version + '.' + position;
};
var getMessages = function(fromHash, toHash, callback) {
sframeChan.query('Q_GET_HISTORY_RANGE', {
channel: config.onlyoffice.channel,
lastKnownHash: fromHash,
toHash: toHash,
}, function (err, data) {
if (err) { return void console.error(err); }
if (!Array.isArray(data.messages)) { return void console.error('Not an array!'); }
var isEmptyPatch = function(msg) {
return msg?.changes?.length === 2 &&
msg.changes.some(c => c.change.includes('64;AgAAA')) &&
msg.changes.some(c => c.change.includes('18;BgAAA') || c.change.includes('23;BgAAAD'));
};
var messages;
if (data.messages[0] && hashes[id]?.index > JSON.parse(data.messages?.[0]?.msg).changesIndex+1 ) {
messages = [];
} else if (config.docType() === 'spreadsheet' && toHash === 'NONE') {
messages = (data.messages || []);
} else {
messages = (data.messages || []).slice(1);
}
if (revertCheckpoint && !forward) {
previousRevertCheckpoint = revertCheckpoint;
}
if (messages[0] && isEmptyPatch(JSON.parse(messages[0].msg))) {
revertCheckpoint = false;
messages.splice(0, 1);
} else if (messages[1] && isEmptyPatch(JSON.parse(messages[1].msg))) {
revertCheckpoint = false;
messages.splice(1, 1);
} else if (id === 0) {
revertCheckpoint = false;
} else {
revertCheckpoint = true;
}
if (config.debug) { console.log(data.messages); }
id = typeof(id) !== "undefined" ? id : getId();
fillOO(messages);
loading = false;
callback(null, messages);
});
};
// We want to load a checkpoint (or initial state)
var loadMoreOOHistory = function () {
return new Promise((resolve, reject) => {
if (!Array.isArray(sortedCp)) {
console.error("Wrong type");
return reject();
}
// Get the checkpoint ID
id = typeof(id) !== "undefined" ? id : getId();
var cp = hashes[id];
// Get the history between "toHash" and "fromHash". This function is using
// "getOlderHistory", that's why we start from the more recent hash
// and we go back in time to an older hash
// We need to get all the patches between the current cp hash and the next cp hash
var nextId = hashes[id+1] ? hashes[id+1] : undefined;
// Current cp or initial hash (invalid hash ==> initial hash)
var fromHash = cp?.hash || 'NONE';
// Next cp or last hash
var toHash = nextId ? nextId.hash : config.onlyoffice.lastHash;
getMessages(toHash, fromHash, function (err) {
if (err) {
console.error(err);
reject(err);
return;
}
resolve();
});
});
};
var onClose = function () { config.setHistory(false); };
var onRevert = function () {
const onClose = function () { config.setHistory(false); };
const onRevert = function () {
config.onRevert();
};
config.setHistory(true);
config.setHistory(true);
$hist.html('').css('display', 'flex');
$bottom.hide();
// UI.spinner($hist).get().show();
const updateNavButtons = () => {
const max = getCpMsgs().length - 1;
var $fastPrev, $fastNext, $next, $prev;
var updateButtons = function () {
$fastPrev.show();
$next.show();
$prev.show();
$fastNext.show();
$hist.find('.cp-toolbar-history-next, .cp-toolbar-history-previous')
.prop('disabled', '');
if ((id === -1 || id === 0) && (ooMessages[id]?.length+1 === Math.abs(msgIndex) || !ooMessages[id]?.length && id === 0)){
if (msgIdx <= 0) {
$prev.prop('disabled', 'disabled');
$fastPrev.prop('disabled', 'disabled');
} else {
$prev.prop('disabled', '');
}
var version = currentVersion.split('.');
var hashesLength = Object.keys(hashes).length;
if (currentVersion === Messages.oo_version_latest || hashesLength === parseInt(version[0]) && ooMessages[id]?.length === parseInt(version[1]) ||
hashesLength+1 === id && (msgIndex === -1) && forward ||
hashes[hashesLength-1] === id && !ooMessages[id].length && msgIndex === 0) {
if (msgIdx >= max) {
$next.prop('disabled', 'disabled');
$fastNext.prop('disabled', 'disabled');
}
};
var loadingFalse = function () {
setTimeout(function () {
$('iframe').blur();
loading = false;
}, 200);
};
var showVersion = function (initial, revert, empty) {
$('.cp-history-timeline-pos-oo').remove();
$('.cp-history-oo-timeline-pos').removeClass('cp-history-oo-timeline-pos');
var currentPatch;
if (initial) {
currentPatch = $('.cp-history-patch').last();
currentVersion = getVersion(position, initial);
} else if (empty) {
currentPatch = $(`[data="${id},0"]`);
currentVersion = getVersion(position, initial);
} else if ($(`[data="${id},${position}"]`).length) {
currentPatch = $(`[data="${id},${position}"]`);
currentVersion = getVersion(position, initial, true);
} else if (msgs?.length === position &&
id !== parseInt(Object.keys(hashes)[Object.keys(hashes).length-1]) ) {
currentPatch = $(`[data="${id+1},0"]`);
currentVersion = getVersion(position, initial);
}
if (initial || position === msgs?.length && (id === -1 ||
id === Object.keys(hashes)[Object.keys(hashes).length-1])) {
currentVersion = Messages.oo_version_latest;
}
var patchTime = patch ? new Date(patch.time).toLocaleString() : '';
$version.text(Messages.oo_version + currentVersion + ' ' + patchTime);
var pos = Icons.get('chevron-down', {'class': 'cp-history-timeline-pos-oo'});
$(currentPatch).addClass('cp-history-oo-timeline-pos').append(pos);
updateButtons();
loadingFalse();
};
var displayCheckpointTimeline = function(initial) {
var bar = $hist.find('.cp-history-timeline-container');
$(bar).addClass('cp-history-timeline-bar').addClass('cp-oohistory-bar-el');
msgs = ooMessages[id];
if (initial) {
var snapshotsEl = [];
var msgsRev = msgs;
} else {
snapshotsEl = Array.from($hist.find('.cp-history-snapshots')[0].childNodes);
msgsRev = msgs.slice().reverse();
$next.prop('disabled', '');
}
};
var cpNfInner = common.startRealtime(config);
var md = Util.clone(cpNfInner.metadataMgr.getMetadata());
var snapshots = md.snapshots;
const updateTimeline = () => {
$timeline.empty();
const msgs = getCpMsgs();
const cp = hashes[getCpId()];
var patchWidth;
var patchDiv;
for (var i = 0; i < msgsRev.length; i++) {
var msg = msgs[i];
if (initial || id === -1) {
patchWidth = (1/msgs?.length)*100;
} else {
patchWidth = (1/(msgs?.length+Array.from($hist.find('.cp-history-snapshots')[0].childNodes).length))*100;
const md = Util.clone(metadataMgr.getMetadata());
const snapshots = md?.snapshots || {};
const els = msgs.map((msg, i) => {
const selected = i === msgIdx;
const selClass = selected ? '.cp-selected' : '';
const content = selected ? Icons.get('chevron-down', {})
: undefined;
let title = `${getCpId()}.${i}`;
const s = snapshots[title];
if (msg?.time) {
title += ' - ' + new Date(msg.time).toLocaleString();
} else if (i === 0 && cp?.time) {
title += ' - ' + new Date(cp.time).toLocaleString();
}
patchDiv = h('div.cp-history-patch', {
style: 'width:'+patchWidth+'%;',
title: new Date(msgsRev[i].time).toLocaleString(),
data: [id, msgsRev.indexOf(msg)]
});
if (initial) {
snapshotsEl.push(patchDiv);
} else {
snapshotsEl.unshift(patchDiv);
let snap;
if (s) {
if (s?.title) { title += `\n${Util.fixHTML(s.title)}`; }
snap = Icons.get('snapshot', {'data-snapshot': '1'});
}
if (snapshots) {
var match = Object.values(snapshots).find(item => item.time === msg.time);
if (match) { $(patchDiv).addClass('cp-history-snapshot').append(Icons.get('snapshot', {title: match.title})); }
}
}
var finalpatchDiv = h('div.cp-history-patch', {
style: 'width:'+patchWidth+'%; height: 100%; position: relative',
title: new Date().toLocaleString(),
data: [id, msgs?.length]
return h('span.cp-history-bar-el'+selClass, {
title,
'data-msg': i
}, [content, snap]);
});
if (initial) {
snapshotsEl.push(finalpatchDiv);
} else if (previousRevertCheckpoint) {
snapshotsEl.splice(msgs.length, 0, finalpatchDiv);
$timeline.append(els);
updateNavButtons();
};
const hideVersion = () => {
$version[0].classList.add('cp-hidden');
};
const showVersion = () => {
const patch = getCurrentMsg();
if (!patch) { return $version.hide(); }
const time = new Date(patch?.time).toLocaleString();
$version.text(`${getCurrentVersion()} - ${time}`).show();
$version[0].classList.remove('cp-hidden');
};
const prev = (i = 1) => {
if ((msgIdx - i) < 0) { loading = false; return; }
msgIdx -= i;
const msgs = getCpMsgs().slice(1, (msgIdx + 1));
const cp = hashes[getCpId()] || {};
config.onPatchBack(cp, msgs);
loading = false;
showVersion();
updateTimeline();
};
const next = (i = 1) => {
const max = getCpMsgs().length - 1;
if (msgIdx > (max - i)) { return; }
for (let j = 0; j < i; j++) {
msgIdx++;
const msg = getCurrentMsg();
if (msg) { config.onPatch(msg); }
}
showVersion();
updateTimeline();
};
if (!msgsRev.length && !Object.keys(hashes).length || initial && !msgs?.length) {
$(finalpatchDiv).css('width', '100%');
} else {
$(finalpatchDiv).css('width', `${($(snapshotsEl[snapshotsEl.indexOf(finalpatchDiv)+1])?.width()/ $(snapshotsEl[snapshotsEl.indexOf(finalpatchDiv)+1])?.parent().width())*100}%`);
patchWidth = ($(snapshotsEl[snapshotsEl.indexOf(finalpatchDiv)+1])?.width()/ $(snapshotsEl[snapshotsEl.indexOf(finalpatchDiv)+1])?.parent().width())*100;
}
// Dropdown to select checkpoint (or "major version")
const makeDropdown = ($dropdown) => {
const all = sortedCp.slice();
if (mainRtChannel) { all.unshift(0); }
const options = all.map((id, idx) => {
const cp = hashes[id] || {};
let time = '';;
let t = cp?.time || (idx === 0 && id === 0 && ctime);
if (t) { time = ` - ${new Date(t).toLocaleString()}`; }
return {
tag: 'a',
attributes: {
'class': 'cp-history-major-version',
'data-value': idx,
},
content: `${id}.x` + time
};
});
const dropdownConfig = {
text: `${getCpId()}.x`, // Button initial text
options, // Entries displayed in the menu
isSelect: true,
caretDown: true,
smallContent: true,
buttonCls: 'btn btn-default small'
};
const dd = UIElements.createDropdown(dropdownConfig);
if (mainRtChannel) { dd.setValue(cpIdx + 1); }
else { dd.setValue(cpIdx); }
dd.onChange.reg((id, idx) => {
loading = true;
dd.find('> button').attr('disabled', 'disabled');
var pos = Icons.get('chevron-down', {'class': 'cp-history-timeline-pos-oo'});
if (!mainRtChannel) { cpIdx = idx; }
else {
cpIdx = idx - 1; // -1 because we've added "0" to the list
}
var patches = h('div.cp-history-snapshots.cp-history-snapshots-oo', [
snapshotsEl
]);
$(patches).css('height', '100%');
$(patches).css('display', 'flex');
hideVersion();
bar.html('').append([
patches
]);
loadMessages().then(() => {
loading = false;
dd.find('> button').removeAttr('disabled');
if (snapshotsEl.length === 1) {
$('.cp-history-patch').css('width', '100%');
}
if (!initial) {
var finalPatchWidth = patchWidth ? patchWidth : 100/$hist.find('.cp-history-snapshots')[0].childNodes.length;
Array.from($hist.find('.cp-history-snapshots')[0].childNodes).forEach(function(patch) {
$(patch).css('width', `${finalPatchWidth}%`);
});
}
if (initial) {
$('.cp-history-patch').last().addClass('cp-history-oo-timeline-pos').append(pos);
}
msgIdx = 0;
showVersion();
updateTimeline();
$('.cp-history-patch').on('click', function(e) {
var patchData = $(e.target).attr('data').split(',');
var cpNo = parseInt(patchData[0]);
var patchNo = parseInt(patchData[1]);
id = cpNo;
loadMoreOOHistory().then(() => {
msgs = ooMessages[id];
if (cpNo === -1) {
var q = msgs.slice(0, patchNo);
config.onPatchBack({}, q);
patch = msgs[patchNo];
position = (patchNo === msgs?.length) ? msgs?.length : msgs.indexOf(patch);
msgIndex = position === -1 ? -1 : position - msgs?.length-1;
showVersion(false, true);
updateButtons();
return;
} else if (cpNo === 0 && patchNo === 0) {
config.onPatchBack({});
patch = msgs[0];
} else if (!msgs?.length ) {
q = msgs.slice(0, patchNo);
config.onPatchBack(hashes[cpNo], q);
patch = msgs[msgs?.length-1];
position = patch ? msgs.indexOf(patch)+1 : 0;
msgIndex = position === -1 ? -1 : position - msgs?.length-1;
showVersion(false, false, true);
updateButtons();
return;
} else if (patchNo === msgs?.length && msgs?.length < $(`[data^="${id},"][data*=","]`).length) {
q = msgs.slice(0, patchNo);
config.onPatchBack(hashes[cpNo], q);
patch = msgs[msgs?.length-1];
position = patch ? msgs.indexOf(patch)+1 : 0;
msgIndex = position === -1 ? -1 : position - msgs?.length-1;
showVersion(false, true);
updateButtons();
return;
} else {
q = msgs.slice(0, patchNo);
config.onPatchBack(hashes[cpNo], q);
patch = msgs[patchNo];
}
position = patch ? msgs.indexOf(patch) : 0;
msgIndex = position === -1 ? -1 : position - msgs?.length-1;
showVersion(false);
updateButtons();
const cp = hashes[getCpId()];
config.onCheckpoint(cp);
});
});
$dropdown.empty().append([
h('span', Messages.oo_version),
dd
]);
};
loadMoreOOHistory().then(() => {
displayCheckpointTimeline(true);
showVersion(true);
});
var restore;
var next = async function () {
forward = true;
msgIndex++;
msgs = ooMessages[id];
var hasHashes = Object.keys(hashes).length;
if (hasHashes) {
// /Check if the end of the checkpoint has been reached and the next one should be loaded
if (msgIndex === 0) {
id++;
await loadMoreOOHistory();
msgs = ooMessages[id];
// Empty checkpoint (checkpoint created/history restored with no further changes)
if (!msgs?.length) {
config.loadHistoryCp(hashes[id]);
msgs = ooMessages[id];
msgIndex = -msgs?.length;
patch = msgs[0];
position = 0;
showVersion(false);
return;
}
//Is the checkpoint the result of restoring history? If yes, we need to load an extra patch
if (!revertCheckpoint) {
msgIndex = -msgs?.length;
config.onPatchBack(hashes[id], [msgs[0]]);
position = 1;
showVersion(false);
} else {
msgIndex = -msgs?.length - 1;
config.loadHistoryCp(hashes[id]);
position = 0;
showVersion(false, true);
}
return;
}
if (!msgs?.length) {
position = 0;
msgIndex = -1;
id++;
showVersion(false);
return config.loadHistoryCp(hashes[id]);
}
// Adjust msgIndex after fastPrev
if (Math.abs(msgIndex) > msgs?.length) { msgIndex = -msgs?.length; }
}
else if (msgs?.length + msgIndex === -1) { msgIndex++; }
patch = msgs[msgs?.length + msgIndex];
position = msgs.indexOf(patch) + 1;
config.onPatch?.(patch);
msgIndex === -1
? showVersion(false, true)
: showVersion(false);
};
var prev = function () {
forward = false;
msgs = ooMessages[id];
let hasHashes = Object.keys(hashes).length;
let cp = hasHashes ? hashes[id] : {};
let loadPrevCp = (!msgs?.length) ||
(msgs?.length + 1 === Math.abs(msgIndex) && id !== 0) ||
(msgs?.length - Math.abs(msgIndex) === -2);
var isRevert = revertCheckpoint;
//Check if the end of the checkpoint has been reached and the previous one should be loaded
if (hasHashes && loadPrevCp) {
id--;
msgIndex = -1;
return loadMoreOOHistory().then(() => {
msgs = ooMessages[id];
//Empty checkpoint - checkpoint saved with no further changes
if (!msgs?.length) {
msgs = ooMessages[id];
config.onPatchBack(hashes[id], msgs.slice(0, msgIndex));
msgIndex--;
patch = msgs[msgs?.length-1];
position = msgs.indexOf(patch);
if (!$(`[data="${id},0"]`).length) {
displayCheckpointTimeline();
}
showVersion(false, false, true);
return;
}
cp = hashes[id];
var q = msgs.slice(0, msgIndex);
patch = msgs[msgs?.length-1];
//Is the checkpoint the result of restoring history? If yes, we need to load an extra patch
if (!isRevert) {
config.onPatchBack(cp, q);
msgIndex--;
position = msgs?.length-1;
} else {
restore = true;
if (!$(`[data="${id},${position}"]`).length) {
displayCheckpointTimeline(false, true);
}
config.onPatchBack(cp, msgs);
patch = msgs[msgs?.length-1];
position = msgs?.length;
}
//Check if this checkpoint has already been added to the timeline
if (!$(`[data="${id},0"]`).length) {
displayCheckpointTimeline();
}
showVersion(false, restore);
restore = false;
});
}
var q = msgs.slice(0, msgIndex);
config.onPatchBack(cp, q);
patch = msgs[msgs?.length + msgIndex];
msgIndex--;
position = msgs.indexOf(patch);
showVersion(false);
};
// Create the history toolbar
var display = function () {
$hist.html('');
var fastPrev = h('button.cp-toolbar-history-previous', { title: Messages.history_prev }, [
Icons.get('history-fast-prev'),
]);
var fastNext = h('button.cp-toolbar-history-next', { title: Messages.history_next }, [
Icons.get('history-fast-next'),
]);
var _next = h('button.cp-toolbar-history-next', { title: Messages.history_next }, [
const _next = h('button.cp-toolbar-history-next', { title: Messages.history_next }, [
Icons.get('history-next'),
]);
var _prev = h('button.cp-toolbar-history-previous', { title: Messages.history_prev }, [
const _prev = h('button.cp-toolbar-history-previous', { title: Messages.history_prev }, [
Icons.get('history-prev')
]);
$fastPrev = $(fastPrev);
$prev = $(_prev);
$fastNext = $(fastNext).prop('disabled', 'disabled');
$next = $(_next).prop('disabled', 'disabled');
$next = $(_next);
var time = h('div.cp-history-timeline-time');
var version = h('div.cp-history-timeline-version');
var version = h('div.cp-history-version-time');
$version = $(version);
var dropdown = h('div.cp-history-version-select');
var $dropdown = $(dropdown);
var line = h('span.cp-history-timeline-patch');
$timeline = $(line);
var pos = h('span.cp-history-snapshots');
var timeline = h('div.cp-toolbar-history-timeline', [
h('div.cp-history-timeline-line', [
h('span.cp-history-timeline-container')
h('span.cp-history-timeline-container', [
h('span.cp-history-timeline-bar', [
line
]),
pos
])
]),
h('div.cp-history-timeline-actions', [
h('span.cp-history-timeline-prev', [
fastPrev,
_prev
]),
time,
version,
h('div.cp-history-version', [
dropdown,
version,
]),
h('span.cp-history-timeline-next', [
_next,
fastNext
_next
])
])
]);
var snapshot = h('button', {
title: Messages.snapshots_new,
class: 'cp-history-create-snapshot'
@ -595,6 +405,8 @@ define([
$share = $(share);
$hist.append([timeline, actions]);
makeDropdown($dropdown);
var onKeyDown, onKeyUp;
var closeUI = function () {
$hist.hide();
@ -607,7 +419,6 @@ define([
// Push one patch
$next.click(function () {
if (loading) { return; }
loading = true;
next();
});
$prev.click(function () {
@ -616,81 +427,38 @@ define([
prev();
});
// Go to next checkpoint
$fastNext.click(function () {
if (loading) { return; }
loading = true;
var msgs = ooMessages[id];
if (id < Object.keys(hashes).length && id !== -1) {
if (id === -1) {
id = 1;
} else {
id++;
}
loadMoreOOHistory().then(() => {
var cp = hashes[id];
config.loadHistoryCp(cp);
var msgs = ooMessages[id];
msgIndex = -msgs?.length-1;
position = 0;
showVersion(false);
loadingFalse();
return;
});
}
else {
var cp = hashes[id];
msgs = ooMessages[id];
msgIndex = -1;
config.onPatchBack(cp, msgs);
}
loadingFalse();
position = msgs?.length;
showVersion(false);
});
// Go to previous checkpoint
$fastPrev.click(function () {
if (loading) { return; }
loading = true;
if (!ooMessages[id].length || ooMessages[id].length+1 === Math.abs(msgIndex)) {
id--;
}
var cp = hashes[id];
config.loadHistoryCp(cp);
loadMoreOOHistory().then(() => {
var msgs = ooMessages[id];
msgIndex = -msgs?.length-1;
if (!$(`[data="${id},0"]`).length) {
displayCheckpointTimeline();
}
patch = msgs[msgs?.length-1];
position = 0;
showVersion(false);
updateButtons(true);
});
loadingFalse();
});
// XXX
onKeyDown = function (e) {
var p = function () { e.preventDefault(); };
if ([38, 39].indexOf(e.which) >= 0) { p(); return $next.click(); } // Right
if (e.which === 33) { p(); return $fastNext.click(); } // PageUp
if (e.which === 34) { p(); return $fastPrev.click(); } // PageUp
if (e.which === 27) { p(); return $(close).click(); }
};
onKeyUp = function (e) { e.stopPropagation(); };
$(window).on('keydown', onKeyDown).on('keyup', onKeyUp).focus();
$timeline.on('click', '.cp-history-bar-el', (ev) => {
let target = ev.target;
if (!target) { return; }
if (!target.classList.contains('cp-history-bar-el')) {
target = $(target).closest('.cp-history-bar-el')[0];
}
const attr = target?.attributes?.getNamedItem('data-msg');
const idx = Number(attr?.value || 0);
if (idx > msgIdx) {
return void next(idx - msgIdx);
}
if (idx < msgIdx) {
return void prev(msgIdx - idx);
}
});
// Versioned link
$share.click(function () {
common.getSframeChannel().event('EV_SHARE_OPEN', {
versionHash: getVersion(position)
versionHash: getCurrentVersion()
});
});
$(snapshot).click(function () {
if (cpIndex === -1 && msgIndex === -1) { return void UI.warn(Messages.snapshots_ooPickVersion); }
var input = h('input', {
placeholder: Messages.snapshots_placeholder
});
@ -712,14 +480,14 @@ define([
onClick: function () {
var val = $input.val();
if (!val) { return true; }
msgs = ooMessages[id];
const patch = getCurrentMsg();
config.makeSnapshot(val, function (err) {
if (err) { return; }
$input.val('');
UI.log(Messages.saved);
}, {
hash: getVersion(position),
time: currentTime || patch && patch.time || 0
hash: getCurrentVersion(),
time: patch?.time || +new Date()
});
},
keys: [13],
@ -748,11 +516,18 @@ define([
});
};
// Build UI
display();
// Load initial state
loadMessages().then((msgs) => {
loading = false;
msgIdx = msgs.length - 1;
showVersion();
updateTimeline();
}).catch(err => { console.error(err); });
};
return History;
});

File diff suppressed because it is too large Load Diff

View File

@ -38,74 +38,42 @@ define([
obj.ooVersionHash = version;
obj.ooForceVersion = localStorage.CryptPad_ooVersion || "";
};
var channels = {};
const channels = {};
var getPropChannels = function () {
return channels;
};
var addRpc = function (sframeChan, Cryptpad, Utils) {
Cryptpad.otherPadAttrs = channels;
sframeChan.on('Q_OO_SAVE', function (data, cb) {
var chanId = Utils.Hash.hrefToHexChannelId(data.url);
Cryptpad.getPadAttribute('lastVersion', function (err, data) {
if (!data) { return; }
var oldChanId = Utils.Hash.hrefToHexChannelId(data);
if (oldChanId !== chanId) { Cryptpad.unpinPads([oldChanId], function () {}); }
});
// If pad is stored, pin
Cryptpad.getPadAttribute('channel', function (err, data) {
if (err || !data) { return; }
Cryptpad.pinPads([chanId], function (e) {
if (e) { return void cb(e); }
});
});
Cryptpad.setPadAttribute('lastVersion', data.url, cb);
// Only called onReady when loading legacy checkpoints
channels.rtChannel = data.channel;
channels.lastVersion = data.url;
if (data.hash) { channels.lastCpHash = data.hash; }
Cryptpad.setPadAttribute('lastCpHash', data.hash, cb);
channels.lastCpHash = data.hash;
Cryptpad.setPadAttribute('lastVersion', data.url, cb);
});
sframeChan.on('Q_OO_OPENCHANNEL', function (data, cb) {
const other = Cryptpad.otherPadAttrs = {
rtChannel: data.channel
};
if (channels.lastVersion) {
other.lastVersion = channels.lastVersion;
}
other.lastCpHash = channels.lastCpHash;
Cryptpad.getPadAttribute('rtChannel', function (err, res) {
// If already stored, don't pin it again
channels.rtChannel = data.channel;
if (res && res === data.channel) { return; }
Cryptpad.pinPads([data.channel], function () {
Cryptpad.setPadAttribute('rtChannel', data.channel, function () {});
});
});
var owners, expire;
nThen(function (waitFor) {
if (Utils.rtConfig) {
owners = Utils.Util.find(Utils.rtConfig, ['metadata', 'owners']);
expire = Utils.Util.find(Utils.rtConfig, ['metadata', 'expire']);
return;
sframeChan.on('Q_OO_OPENCHANNEL', function (data, cb) {
// If we don't have "channels" values, it means we're not
// loading an "old" checkpoint so we can clean attributes
if (!channels?.lastVersion) {
channels.linked = true;
Cryptpad.setPadAttribute('linked', true, () => {});
Cryptpad.setPadAttribute('rtChannel', void 0, () => {});
Cryptpad.setPadAttribute('lastVersion', void 0, () => {});
Cryptpad.setPadAttribute('lastCpHash', void 0, () => {});
}
Cryptpad.onlyoffice.execCommand({
cmd: 'OPEN_CHANNEL',
data: {
channel: data.channel,
lastCpHash: data.lastCpHash,
padChan: Utils.secret.channel, // metadata inherited from this pad
validateKey: Utils.secret.keys.validateKey
}
Cryptpad.getPadAttribute('owners', waitFor(function (err, res) {
owners = res;
}));
Cryptpad.getPadAttribute('expire', waitFor(function (err, res) {
expire = res;
}));
}).nThen(function () {
Cryptpad.onlyoffice.execCommand({
cmd: 'OPEN_CHANNEL',
data: {
owners: owners,
expire: expire,
channel: data.channel,
lastCpHash: data.lastCpHash,
padChan: Utils.secret.channel,
validateKey: Utils.secret.keys.validateKey
}
}, cb);
});
}, cb);
});
sframeChan.on('EV_OO_PIN_IMAGES', function (list) {
Cryptpad.getPadAttribute('ooImages', function (err, res) {
@ -181,7 +149,7 @@ define([
type: 'oo',
addData: addData,
addRpc: addRpc,
getPropChannels: getPropChannels,
getPropChannels: getPropChannels, // XXX TODO
messaging: true,
useCreationScreen: !isIntegration,
noDrive: true,

View File

@ -0,0 +1,32 @@
const factory = () => {
const sortCpIndex = (hashes) => {
return Object.keys(hashes).map(Number).sort(function (a, b) {
return a-b;
});
};
const trim = (content) => {
let hashes = content?.content?.hashes || {};
if (!hashes) { return content; }
const sortedCp = sortCpIndex(hashes);
const lastIdx = sortedCp.pop();
if (!lastIdx) { return content; }
const lastCp = hashes[lastIdx];
if (!lastCp) { return content; }
content.content.hashes = hashes = {};
if (lastCp.rtChannel) {
delete content.content.channel;
}
hashes[lastIdx] = lastCp;
return content;
};
return { trim };
};
if (typeof(module) !== 'undefined' && module.exports) {
module.exports = factory();
} else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) {
define([], factory);
}

View File

@ -20,6 +20,7 @@ define([
var u8 = data.u8;
var metadata = data.metadata;
var key = data.key;
var linked = data.linked;
var onError = data.onError || function () {};
var onPending = data.onPending || function () {};
@ -141,7 +142,9 @@ define([
});
};
common.uploadStatus(teamId, id, estimate, function (e, pending) {
common.uploadStatus({
teamId, id, linked, size: estimate
}, function (e, pending) {
if (e) {
console.error(e);
onError(e);
@ -166,6 +169,7 @@ define([
module.upload = function (file, noStore, common, updateProgress, onComplete, onError, onPending) {
var u8 = file.blob; // This is not a blob but a uint8array
var metadata = file.metadata;
var linked = file.linked;
var owned = file.owned;
var teamId = file.teamId;
@ -209,6 +213,7 @@ define([
module.uploadU8(common, {
teamId: teamId,
u8: u8,
linked,
metadata: metadata,
key: key,
id: id,

View File

@ -485,6 +485,7 @@ define([
queue.push({
blob: file_arraybuffer,
metadata: metadata,
linked: file.linked,
password: password,
owned: owned,
forceSave: forceSave,

View File

@ -957,8 +957,12 @@ define([
Cryptpad.universal.onEvent.reg(function (data) {
sframeChan.event('EV_UNIVERSAL_EVENT', data);
});
sframeChan.on('Q_UNIVERSAL_COMMAND', function (data, cb) {
Cryptpad.universal.execCommand(data, cb);
sframeChan.on('Q_UNIVERSAL_COMMAND', function (content, cb) {
if (content?.type === 'linked-doc') {
content.data.data.signKey64 = secret.keys?.signKey;
content.data.data.channel = secret.channel;
}
Cryptpad.universal.execCommand(content, cb);
});
sframeChan.on('Q_ANON_RPC_MESSAGE', function (data, cb) {
@ -1439,6 +1443,53 @@ define([
}
}, cb);
});
// History
sframeChan.on('Q_GET_FULL_HISTORY', function (data, cb) {
let nSecret = secret;
if (data.isDownload && ooDownloadData[data.isDownload]) {
var ooData = ooDownloadData[data.isDownload];
delete ooDownloadData[data.isDownload];
nSecret = Utils.Hash.getSecrets('sheet', ooData.hash, ooData.password);
} else if (data.href) {
var _parsed = Utils.Hash.parsePadUrl(data.href);
nSecret = Utils.Hash.getSecrets(_parsed.type, _parsed.hash, data.password);
}
var crypto = Crypto.createEncryptor(nSecret.keys);
Cryptpad.getFullHistory({
debug: data?.debug,
full: data?.full,
channel: data.channel || nSecret.channel,
validateKey: nSecret.keys.validateKey
}, function (encryptedMsgs) {
var nt = nThen;
var decryptedMsgs = [];
var total = encryptedMsgs.length;
encryptedMsgs.forEach(function (_msg, i) {
nt = nt(function (waitFor) {
// The 3rd parameter "true" means we're going to skip signature validation.
// We don't need it since the message is already validated serverside by hk
if (typeof(_msg) === "object") {
decryptedMsgs.push({
author: _msg.author,
serverHash: _msg.serverHash,
time: _msg.time,
msg: crypto.decrypt(_msg.msg, true, true)
});
} else {
decryptedMsgs.push(crypto.decrypt(_msg, true, true));
}
setTimeout(waitFor(function () {
sframeChan.event('EV_FULL_HISTORY_STATUS', (i+1)/total);
}));
}).nThen;
});
nt(function () {
cb(decryptedMsgs);
});
});
});
sframeChan.on('Q_GET_HISTORY_RANGE', function (data, cb) {
var nSecret = secret;
if (cfg.isDrive) {
@ -1533,6 +1584,39 @@ define([
});
});
});
sframeChan.on('Q_TRIM_HISTORY', function (data, cb) {
const { href } = data;
const parsed = Utils.Hash.parsePadUrl(href);
let type = parsed.type;
if (['sheet', 'doc', 'presentation'].includes(type)) {
type = 'common/onlyoffice';
}
const path = `/${type}/trim-history.js`;
const cfg = {
password: data.password
};
require([path], (Trimming) => {
nThen(waitFor => {
Cryptpad.getAccessKeys(waitFor((keys) => {
cfg.accessKeys = keys;
}));
}).nThen(function () {
Cryptget.get(parsed.hash, (err, val) => {
if (err) { return void cb(); }
const json = Utils.Util.tryParse(val);
if (!json) { return void cb(); }
const newJson = Trimming.trim(json);
if (!newJson) { return void cb(); }
Cryptget.put(parsed.hash, JSON.stringify(newJson), () => {
cb();
}, cfg);
}, cfg);
});
}, () => {
cb();
});
});
};
addCommonRpc(sframeChan, isSafe);
@ -1710,42 +1794,6 @@ define([
Cryptpad.anonGetPreviewContent(data, cb);
});
// History
sframeChan.on('Q_GET_FULL_HISTORY', function (data, cb) {
var crypto = Crypto.createEncryptor(secret.keys);
Cryptpad.getFullHistory({
debug: data && data.debug,
channel: secret.channel,
validateKey: secret.keys.validateKey
}, function (encryptedMsgs) {
var nt = nThen;
var decryptedMsgs = [];
var total = encryptedMsgs.length;
encryptedMsgs.forEach(function (_msg, i) {
nt = nt(function (waitFor) {
// The 3rd parameter "true" means we're going to skip signature validation.
// We don't need it since the message is already validated serverside by hk
if (typeof(_msg) === "object") {
decryptedMsgs.push({
author: _msg.author,
serverHash: _msg.serverHash,
time: _msg.time,
msg: crypto.decrypt(_msg.msg, true, true)
});
} else {
decryptedMsgs.push(crypto.decrypt(_msg, true, true));
}
setTimeout(waitFor(function () {
sframeChan.event('EV_FULL_HISTORY_STATUS', (i+1)/total);
}));
}).nThen;
});
nt(function () {
cb(decryptedMsgs);
});
});
});
// Store
sframeChan.on('Q_DRIVE_GETDELETED', function (data, cb) {
Cryptpad.getDeletedPads(data, function (err, obj) {
@ -2049,7 +2097,7 @@ define([
nThen(function (waitFor) {
channels.forEach(function (chan) {
if (chan === "chainpad") { chan = secret.channel; }
console.error(chan);
if (!chan) { return; }
Utils.Cache.clearChannel(chan, waitFor());
});
}).nThen(cb);
@ -2368,7 +2416,10 @@ define([
};
if (burnAfterReading) {
Cryptpad.padRpc.onReadyEvent.reg(function () {
nThen(w => {
Cryptpad.padRpc.onReadyEvent.reg(w());
if (isOO) { sframeChan.on('EV_OO_DOC_READY', w()); }
}).nThen(() => {
Cryptpad.burnPad({
password: password,
href: currentPad.href,

File diff suppressed because one or more lines are too long