'use strict'; /** * The pad object, defined with joose */ const Changeset = require('../../static/js/Changeset'); const AttributePool = require('../../static/js/AttributePool'); const db = require('./DB'); const settings = require('../utils/Settings'); const authorManager = require('./AuthorManager'); const padManager = require('./PadManager'); const padMessageHandler = require('../handler/PadMessageHandler'); const groupManager = require('./GroupManager'); const CustomError = require('../utils/customError'); const readOnlyManager = require('./ReadOnlyManager'); const randomString = require('../utils/randomstring'); const hooks = require('../../static/js/pluginfw/hooks'); const promises = require('../utils/promises'); // serialization/deserialization attributes const attributeBlackList = ['id']; const jsonableList = ['pool']; /** * Copied from the Etherpad source code. It converts Windows line breaks to Unix * line breaks and convert Tabs to spaces * @param txt */ exports.cleanText = (txt) => txt.replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .replace(/\t/g, ' ') .replace(/\xa0/g, ' '); const Pad = function Pad(id) { this.atext = Changeset.makeAText('\n'); this.pool = new AttributePool(); this.head = -1; this.chatHead = -1; this.publicStatus = false; this.id = id; this.savedRevisions = []; }; exports.Pad = Pad; Pad.prototype.apool = function apool() { return this.pool; }; Pad.prototype.getHeadRevisionNumber = function getHeadRevisionNumber() { return this.head; }; Pad.prototype.getSavedRevisionsNumber = function getSavedRevisionsNumber() { return this.savedRevisions.length; }; Pad.prototype.getSavedRevisionsList = function getSavedRevisionsList() { const savedRev = []; for (const rev in this.savedRevisions) { savedRev.push(this.savedRevisions[rev].revNum); } savedRev.sort((a, b) => a - b); return savedRev; }; Pad.prototype.getPublicStatus = function getPublicStatus() { return this.publicStatus; }; Pad.prototype.appendRevision = async function appendRevision(aChangeset, author) { if (!author) { author = ''; } const newAText = Changeset.applyToAText(aChangeset, this.atext, this.pool); Changeset.copyAText(newAText, this.atext); const newRev = ++this.head; const newRevData = {}; newRevData.changeset = aChangeset; newRevData.meta = {}; newRevData.meta.author = author; newRevData.meta.timestamp = Date.now(); // ex. getNumForAuthor if (author !== '') { this.pool.putAttrib(['author', author || '']); } if (newRev % 100 === 0) { newRevData.meta.pool = this.pool; newRevData.meta.atext = this.atext; } const p = [ db.set(`pad:${this.id}:revs:${newRev}`, newRevData), this.saveToDatabase(), ]; // set the author to pad if (author) { p.push(authorManager.addPad(author, this.id)); } if (this.head === 0) { hooks.callAll('padCreate', {pad: this, author}); } else { hooks.callAll('padUpdate', {pad: this, author, revs: newRev, changeset: aChangeset}); } await Promise.all(p); }; // save all attributes to the database Pad.prototype.saveToDatabase = async function saveToDatabase() { const dbObject = {}; for (const attr in this) { if (typeof this[attr] === 'function') continue; if (attributeBlackList.indexOf(attr) !== -1) continue; dbObject[attr] = this[attr]; if (jsonableList.indexOf(attr) !== -1) { dbObject[attr] = dbObject[attr].toJsonable(); } } await db.set(`pad:${this.id}`, dbObject); }; // get time of last edit (changeset application) Pad.prototype.getLastEdit = function getLastEdit() { const revNum = this.getHeadRevisionNumber(); return db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'timestamp']); }; Pad.prototype.getRevisionChangeset = function getRevisionChangeset(revNum) { return db.getSub(`pad:${this.id}:revs:${revNum}`, ['changeset']); }; Pad.prototype.getRevisionAuthor = function getRevisionAuthor(revNum) { return db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'author']); }; Pad.prototype.getRevisionDate = function getRevisionDate(revNum) { return db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'timestamp']); }; Pad.prototype.getAllAuthors = function getAllAuthors() { const authors = []; for (const key in this.pool.numToAttrib) { if (this.pool.numToAttrib[key][0] === 'author' && this.pool.numToAttrib[key][1] !== '') { authors.push(this.pool.numToAttrib[key][1]); } } return authors; }; Pad.prototype.getInternalRevisionAText = async function getInternalRevisionAText(targetRev) { const keyRev = this.getKeyRevisionNumber(targetRev); // find out which changesets are needed const neededChangesets = []; for (let curRev = keyRev; curRev < targetRev;) { neededChangesets.push(++curRev); } // get all needed data out of the database // start to get the atext of the key revision const p_atext = db.getSub(`pad:${this.id}:revs:${keyRev}`, ['meta', 'atext']); // get all needed changesets const changesets = []; await Promise.all( neededChangesets.map((item) => this.getRevisionChangeset(item).then((changeset) => { changesets[item] = changeset; }))); // we should have the atext by now let atext = await p_atext; atext = Changeset.cloneAText(atext); // apply all changesets to the key changeset const apool = this.apool(); for (let curRev = keyRev; curRev < targetRev;) { const cs = changesets[++curRev]; atext = Changeset.applyToAText(cs, atext, apool); } return atext; }; Pad.prototype.getRevision = function getRevisionChangeset(revNum) { return db.get(`pad:${this.id}:revs:${revNum}`); }; Pad.prototype.getAllAuthorColors = async function getAllAuthorColors() { const authors = this.getAllAuthors(); const returnTable = {}; const colorPalette = authorManager.getColorPalette(); await Promise.all( authors.map((author) => authorManager.getAuthorColorId(author).then((colorId) => { // colorId might be a hex color or an number out of the palette returnTable[author] = colorPalette[colorId] || colorId; }))); return returnTable; }; Pad.prototype.getValidRevisionRange = function getValidRevisionRange(startRev, endRev) { startRev = parseInt(startRev, 10); const head = this.getHeadRevisionNumber(); endRev = endRev ? parseInt(endRev, 10) : head; if (isNaN(startRev) || startRev < 0 || startRev > head) { startRev = null; } if (isNaN(endRev) || endRev < startRev) { endRev = null; } else if (endRev > head) { endRev = head; } if (startRev != null && endRev != null) { return {startRev, endRev}; } return null; }; Pad.prototype.getKeyRevisionNumber = function getKeyRevisionNumber(revNum) { return Math.floor(revNum / 100) * 100; }; Pad.prototype.text = function text() { return this.atext.text; }; Pad.prototype.setText = async function setText(newText) { // clean the new text newText = exports.cleanText(newText); const oldText = this.text(); // create the changeset // We want to ensure the pad still ends with a \n, but otherwise keep // getText() and setText() consistent. let changeset; if (newText[newText.length - 1] === '\n') { changeset = Changeset.makeSplice(oldText, 0, oldText.length, newText); } else { changeset = Changeset.makeSplice(oldText, 0, oldText.length - 1, newText); } // append the changeset await this.appendRevision(changeset); }; Pad.prototype.appendText = async function appendText(newText) { // clean the new text newText = exports.cleanText(newText); const oldText = this.text(); // create the changeset const changeset = Changeset.makeSplice(oldText, oldText.length, 0, newText); // append the changeset await this.appendRevision(changeset); }; Pad.prototype.appendChatMessage = async function appendChatMessage(text, userId, time) { this.chatHead++; // save the chat entry in the database await Promise.all([ db.set(`pad:${this.id}:chat:${this.chatHead}`, {text, userId, time}), this.saveToDatabase(), ]); }; Pad.prototype.getChatMessage = async function getChatMessage(entryNum) { // get the chat entry const entry = await db.get(`pad:${this.id}:chat:${entryNum}`); // get the authorName if the entry exists if (entry != null) { entry.userName = await authorManager.getAuthorName(entry.userId); } return entry; }; Pad.prototype.getChatMessages = async function getChatMessages(start, end) { // collect the numbers of chat entries and in which order we need them const neededEntries = []; for (let order = 0, entryNum = start; entryNum <= end; ++order, ++entryNum) { neededEntries.push({entryNum, order}); } // get all entries out of the database const entries = []; await Promise.all( neededEntries.map((entryObject) => this.getChatMessage(entryObject.entryNum).then((entry) => { entries[entryObject.order] = entry; }))); // sort out broken chat entries // it looks like in happened in the past that the chat head was // incremented, but the chat message wasn't added const cleanedEntries = entries.filter((entry) => { const pass = (entry != null); if (!pass) { console.warn(`WARNING: Found broken chat entry in pad ${this.id}`); } return pass; }); return cleanedEntries; }; Pad.prototype.init = async function init(text) { // replace text with default text if text isn't set if (text == null) { text = settings.defaultPadText; } // try to load the pad const value = await db.get(`pad:${this.id}`); // if this pad exists, load it if (value != null) { // copy all attr. To a transfrom via fromJsonable if necassary for (const attr in value) { if (jsonableList.indexOf(attr) !== -1) { this[attr] = this[attr].fromJsonable(value[attr]); } else { this[attr] = value[attr]; } } } else { // this pad doesn't exist, so create it const firstChangeset = Changeset.makeSplice('\n', 0, 0, exports.cleanText(text)); await this.appendRevision(firstChangeset, ''); } hooks.callAll('padLoad', {pad: this}); }; Pad.prototype.copy = async function copy(destinationID, force) { let destGroupID; const sourceID = this.id; // Kick everyone from this pad. // This was commented due to https://github.com/ether/etherpad-lite/issues/3183. // Do we really need to kick everyone out? // padMessageHandler.kickSessionsFromPad(sourceID); // flush the source pad: await this.saveToDatabase(); try { // if it's a group pad, let's make sure the group exists. destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID); // if force is true and already exists a Pad with the same id, remove that Pad await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force); } catch (err) { throw err; } // copy the 'pad' entry const pad = await db.get(`pad:${sourceID}`); db.set(`pad:${destinationID}`, pad); // copy all relations in parallel const promises = []; // copy all chat messages const chatHead = this.chatHead; for (let i = 0; i <= chatHead; ++i) { const p = db.get(`pad:${sourceID}:chat:${i}`) .then((chat) => db.set(`pad:${destinationID}:chat:${i}`, chat)); promises.push(p); } // copy all revisions const revHead = this.head; for (let i = 0; i <= revHead; ++i) { const p = db.get(`pad:${sourceID}:revs:${i}`) .then((rev) => db.set(`pad:${destinationID}:revs:${i}`, rev)); promises.push(p); } this.copyAuthorInfoToDestinationPad(destinationID); // wait for the above to complete await Promise.all(promises); // Group pad? Add it to the group's list if (destGroupID) { await db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1); } // delay still necessary? await new Promise((resolve) => setTimeout(resolve, 10)); // Initialize the new pad (will update the listAllPads cache) await padManager.getPad(destinationID, null); // this runs too early. // let the plugins know the pad was copied await hooks.aCallAll('padCopy', {originalPad: this, destinationID}); return {padID: destinationID}; }; Pad.prototype.checkIfGroupExistAndReturnIt = async function checkIfGroupExistAndReturnIt(destinationID) { let destGroupID = false; if (destinationID.indexOf('$') >= 0) { destGroupID = destinationID.split('$')[0]; const groupExists = await groupManager.doesGroupExist(destGroupID); // group does not exist if (!groupExists) { throw new CustomError('groupID does not exist for destinationID', 'apierror'); } } return destGroupID; }; Pad.prototype.removePadIfForceIsTrueAndAlreadyExist = async function removePadIfForceIsTrueAndAlreadyExist(destinationID, force) { // if the pad exists, we should abort, unless forced. const exists = await padManager.doesPadExist(destinationID); // allow force to be a string if (typeof force === 'string') { force = (force.toLowerCase() === 'true'); } else { force = !!force; } if (exists) { if (!force) { console.error('erroring out without force'); throw new CustomError('destinationID already exists', 'apierror'); } // exists and forcing const pad = await padManager.getPad(destinationID); await pad.remove(); } }; Pad.prototype.copyAuthorInfoToDestinationPad = function copyAuthorInfoToDestinationPad(destinationID) { // add the new sourcePad to all authors who contributed to the old one this.getAllAuthors().forEach((authorID) => { authorManager.addPad(authorID, destinationID); }); }; Pad.prototype.copyPadWithoutHistory = async function copyPadWithoutHistory(destinationID, force) { let destGroupID; const sourceID = this.id; // flush the source pad this.saveToDatabase(); try { // if it's a group pad, let's make sure the group exists. destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID); // if force is true and already exists a Pad with the same id, remove that Pad await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force); } catch (err) { throw err; } const sourcePad = await padManager.getPad(sourceID); // add the new sourcePad to all authors who contributed to the old one this.copyAuthorInfoToDestinationPad(destinationID); // Group pad? Add it to the group's list if (destGroupID) { await db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1); } // initialize the pad with a new line to avoid getting the defaultText const newPad = await padManager.getPad(destinationID, '\n'); const oldAText = this.atext; const newPool = newPad.pool; newPool.fromJsonable(sourcePad.pool.toJsonable()); // copy that sourceId pool to the new pad // based on Changeset.makeSplice const assem = Changeset.smartOpAssembler(); assem.appendOpWithText('=', ''); Changeset.appendATextToAssembler(oldAText, assem); assem.endDocument(); // although we have instantiated the newPad with '\n', an additional '\n' is // added internally, so the pad text on the revision 0 is "\n\n" const oldLength = 2; const newLength = assem.getLengthChange(); const newText = oldAText.text; // create a changeset that removes the previous text and add the newText with // all atributes present on the source pad const changeset = Changeset.pack(oldLength, newLength, assem.toString(), newText); newPad.appendRevision(changeset); await hooks.aCallAll('padCopy', {originalPad: this, destinationID}); return {padID: destinationID}; }; Pad.prototype.remove = async function remove() { const padID = this.id; const p = []; // kick everyone from this pad padMessageHandler.kickSessionsFromPad(padID); // delete all relations - the original code used async.parallel but // none of the operations except getting the group depended on callbacks // so the database operations here are just started and then left to // run to completion // is it a group pad? -> delete the entry of this pad in the group if (padID.indexOf('$') >= 0) { // it is a group pad const groupID = padID.substring(0, padID.indexOf('$')); const group = await db.get(`group:${groupID}`); // remove the pad entry delete group.pads[padID]; // set the new value p.push(db.set(`group:${groupID}`, group)); } // remove the readonly entries p.push(readOnlyManager.getReadOnlyId(padID).then(async (readonlyID) => { await db.remove(`readonly2pad:${readonlyID}`); })); p.push(db.remove(`pad2readonly:${padID}`)); // delete all chat messages p.push(promises.timesLimit(this.chatHead + 1, 500, async (i) => { await db.remove(`pad:${padID}:chat:${i}`, null); })); // delete all revisions p.push(promises.timesLimit(this.head + 1, 500, async (i) => { await db.remove(`pad:${padID}:revs:${i}`, null); })); // remove pad from all authors who contributed this.getAllAuthors().forEach((authorID) => { p.push(authorManager.removePad(authorID, padID)); }); // delete the pad entry and delete pad from padManager p.push(padManager.removePad(padID)); p.push(hooks.aCallAll('padRemove', {padID})); await Promise.all(p); }; // set in db Pad.prototype.setPublicStatus = async function setPublicStatus(publicStatus) { this.publicStatus = publicStatus; await this.saveToDatabase(); }; Pad.prototype.addSavedRevision = async function addSavedRevision(revNum, savedById, label) { // if this revision is already saved, return silently for (const i in this.savedRevisions) { if (this.savedRevisions[i] && this.savedRevisions[i].revNum === revNum) { return; } } // build the saved revision object const savedRevision = {}; savedRevision.revNum = revNum; savedRevision.savedById = savedById; savedRevision.label = label || `Revision ${revNum}`; savedRevision.timestamp = Date.now(); savedRevision.id = randomString(10); // save this new saved revision this.savedRevisions.push(savedRevision); await this.saveToDatabase(); }; Pad.prototype.getSavedRevisions = function getSavedRevisions() { return this.savedRevisions; };