/** * The pad object, defined with joose */ var ERR = require("async-stacktrace"); var Changeset = require("ep_etherpad-lite/static/js/Changeset"); var AttributePool = require("ep_etherpad-lite/static/js/AttributePool"); var db = require("./DB").db; var async = require("async"); var settings = require('../utils/Settings'); var authorManager = require("./AuthorManager"); var padManager = require("./PadManager"); var padMessageHandler = require("../handler/PadMessageHandler"); var groupManager = require("./GroupManager"); var customError = require("../utils/customError"); var readOnlyManager = require("./ReadOnlyManager"); var crypto = require("crypto"); var randomString = require("../utils/randomstring"); var hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks'); const thenify = require("thenify").withCallback; // serialization/deserialization attributes var attributeBlackList = ["id"]; var 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 = function (txt) { return txt.replace(/\r\n/g,'\n').replace(/\r/g,'\n').replace(/\t/g, ' ').replace(/\xa0/g, ' '); }; var Pad = function Pad(id) { this.atext = Changeset.makeAText("\n"); this.pool = new AttributePool(); this.head = -1; this.chatHead = -1; this.publicStatus = false; this.passwordHash = null; 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() { var savedRev = new Array(); for (var rev in this.savedRevisions) { savedRev.push(this.savedRevisions[rev].revNum); } savedRev.sort(function(a, b) { return a - b; }); return savedRev; }; Pad.prototype.getPublicStatus = function getPublicStatus() { return this.publicStatus; }; Pad.prototype.appendRevision = function appendRevision(aChangeset, author) { if (!author) { author = ''; } var newAText = Changeset.applyToAText(aChangeset, this.atext, this.pool); Changeset.copyAText(newAText, this.atext); var newRev = ++this.head; var 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.atext = this.atext; } db.set("pad:" + this.id + ":revs:" + newRev, newRevData); this.saveToDatabase(); // set the author to pad if (author) { authorManager.addPad(author, this.id); } if (this.head == 0) { hooks.callAll("padCreate", {'pad':this, 'author': author}); } else { hooks.callAll("padUpdate", {'pad':this, 'author': author}); } }; // save all attributes to the database Pad.prototype.saveToDatabase = function saveToDatabase() { var dbObject = {}; for (var 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(); } } db.set("pad:" + this.id, dbObject); } // get time of last edit (changeset application) Pad.prototype.getLastEdit = thenify(function getLastEdit(callback) { var revNum = this.getHeadRevisionNumber(); db.getSub("pad:" + this.id + ":revs:" + revNum, ["meta", "timestamp"], callback); }); Pad.prototype.getRevisionChangeset = thenify(function getRevisionChangeset(revNum, callback) { db.getSub("pad:" + this.id + ":revs:" + revNum, ["changeset"], callback); }); Pad.prototype.getRevisionAuthor = thenify(function getRevisionAuthor(revNum, callback) { db.getSub("pad:" + this.id + ":revs:" + revNum, ["meta", "author"], callback); }); Pad.prototype.getRevisionDate = thenify(function getRevisionDate(revNum, callback) { db.getSub("pad:" + this.id + ":revs:" + revNum, ["meta", "timestamp"], callback); }); Pad.prototype.getAllAuthors = function getAllAuthors() { var authors = []; for(var 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 = thenify(function getInternalRevisionAText(targetRev, callback) { var _this = this; var keyRev = this.getKeyRevisionNumber(targetRev); var atext; var changesets = []; // find out which changesets are needed var neededChangesets = []; var curRev = keyRev; while (curRev < targetRev) { curRev++; neededChangesets.push(curRev); } async.series([ // get all needed data out of the database function(callback) { async.parallel([ // get the atext of the key revision function (callback) { db.getSub("pad:" + _this.id + ":revs:" + keyRev, ["meta", "atext"], function(err, _atext) { if (ERR(err, callback)) return; try { atext = Changeset.cloneAText(_atext); } catch (e) { return callback(e); } callback(); }); }, // get all needed changesets function (callback) { async.forEach(neededChangesets, function(item, callback) { _this.getRevisionChangeset(item, function(err, changeset) { if (ERR(err, callback)) return; changesets[item] = changeset; callback(); }); }, callback); } ], callback); }, // apply all changesets to the key changeset function(callback) { var apool = _this.apool(); var curRev = keyRev; while (curRev < targetRev) { curRev++; var cs = changesets[curRev]; try { atext = Changeset.applyToAText(cs, atext, apool); } catch(e) { return callback(e) } } callback(null); } ], function(err) { if (ERR(err, callback)) return; callback(null, atext); }); }); Pad.prototype.getRevision = thenify(function getRevisionChangeset(revNum, callback) { db.get("pad:" + this.id + ":revs:" + revNum, callback); }); Pad.prototype.getAllAuthorColors = thenify(function getAllAuthorColors(callback) { var authors = this.getAllAuthors(); var returnTable = {}; var colorPalette = authorManager.getColorPalette(); async.forEach(authors, function(author, callback) { authorManager.getAuthorColorId(author, function(err, colorId) { if (err) { return callback(err); } // colorId might be a hex color or an number out of the palette returnTable[author] = colorPalette[colorId] || colorId; callback(); }); }, function(err) { callback(err, returnTable); }); }); Pad.prototype.getValidRevisionRange = function getValidRevisionRange(startRev, endRev) { startRev = parseInt(startRev, 10); var 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: startRev , endRev: 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 = function setText(newText) { // clean the new text newText = exports.cleanText(newText); var oldText = this.text(); // create the changeset // We want to ensure the pad still ends with a \n, but otherwise keep // getText() and setText() consistent. var 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 this.appendRevision(changeset); }; Pad.prototype.appendText = function appendText(newText) { // clean the new text newText = exports.cleanText(newText); var oldText = this.text(); // create the changeset var changeset = Changeset.makeSplice(oldText, oldText.length, 0, newText); // append the changeset this.appendRevision(changeset); }; Pad.prototype.appendChatMessage = function appendChatMessage(text, userId, time) { this.chatHead++; // save the chat entry in the database db.set("pad:" + this.id + ":chat:" + this.chatHead, { "text": text, "userId": userId, "time": time }); this.saveToDatabase(); }; Pad.prototype.getChatMessage = thenify(function getChatMessage(entryNum, callback) { var _this = this; var entry; async.series([ // get the chat entry function(callback) { db.get("pad:" + _this.id + ":chat:" + entryNum, function(err, _entry) { if (ERR(err, callback)) return; entry = _entry; callback(); }); }, // add the authorName function(callback) { // this chat message doesn't exist, return null if (entry == null) { callback(); return; } // get the authorName authorManager.getAuthorName(entry.userId, function(err, authorName) { if (ERR(err, callback)) return; entry.userName = authorName; callback(); }); } ], function(err) { if (ERR(err, callback)) return; callback(null, entry); }); }); Pad.prototype.getChatMessages = thenify(function getChatMessages(start, end, callback) { // collect the numbers of chat entries and in which order we need them var neededEntries = []; var order = 0; for (var i = start; i <= end; i++) { neededEntries.push({ entryNum: i, order: order }); order++; } var _this = this; // get all entries out of the database var entries = []; async.forEach(neededEntries, function(entryObject, callback) { _this.getChatMessage(entryObject.entryNum, function(err, entry) { if (ERR(err, callback)) return; entries[entryObject.order] = entry; callback(); }); }, function(err) { if (ERR(err, callback)) return; // 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 var cleanedEntries = []; for (var i=0; i < entries.length; i++) { if (entries[i] != null) { cleanedEntries.push(entries[i]); } else { console.warn("WARNING: Found broken chat entry in pad " + _this.id); } } callback(null, cleanedEntries); }); }); Pad.prototype.init = thenify(function init(text, callback) { var _this = this; // replace text with default text if text isn't set if (text == null) { text = settings.defaultPadText; } // try to load the pad db.get("pad:" + this.id, function(err, value) { if (ERR(err, callback)) return; // if this pad exists, load it if (value != null) { // copy all attr. To a transfrom via fromJsonable if necessary for (var 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 var firstChangeset = Changeset.makeSplice("\n", 0, 0, exports.cleanText(text)); _this.appendRevision(firstChangeset, ''); } hooks.callAll("padLoad", { 'pad': _this }); callback(null); }); }); Pad.prototype.copy = thenify(function copy(destinationID, force, callback) { var sourceID = this.id; var _this = this; var destGroupID; // allow force to be a string if (typeof force === "string") { force = (force.toLowerCase() === "true"); } else { force = !!force; } // 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: _this.saveToDatabase(); async.series([ // if it's a group pad, let's make sure the group exists. function(callback) { if (destinationID.indexOf("$") === -1) { callback(); return; } destGroupID = destinationID.split("$")[0]; groupManager.doesGroupExist(destGroupID, function (err, exists) { if (ERR(err, callback)) return; // group does not exist if (exists == false) { callback(new customError("groupID does not exist for destinationID", "apierror")); return; } // everything is fine, continue callback(); }); }, // if the pad exists, we should abort, unless forced. function(callback) { padManager.doesPadExists(destinationID, function (err, exists) { if (ERR(err, callback)) return; /* * this is the negation of a truthy comparison. Has been left in this * wonky state to keep the old (possibly buggy) behaviour */ if (!(exists == true)) { callback(); return; } if (!force) { console.error("erroring out without force"); callback(new customError("destinationID already exists", "apierror")); console.error("erroring out without force - after"); return; } // exists and forcing padManager.getPad(destinationID, function(err, pad) { if (ERR(err, callback)) return; pad.remove(callback); }); }); }, // copy the 'pad' entry function(callback) { db.get("pad:" + sourceID, function(err, pad) { db.set("pad:" + destinationID, pad); }); callback(); }, // copy all relations function(callback) { async.parallel([ // copy all chat messages function(callback) { var chatHead = _this.chatHead; for (var i=0; i <= chatHead; i++) { db.get("pad:" + sourceID + ":chat:" + i, function (err, chat) { if (ERR(err, callback)) return; db.set("pad:" + destinationID + ":chat:" + i, chat); }); } callback(); }, // copy all revisions function(callback) { var revHead = _this.head; for (var i=0; i <= revHead; i++) { db.get("pad:" + sourceID + ":revs:" + i, function (err, rev) { if (ERR(err, callback)) return; db.set("pad:" + destinationID + ":revs:" + i, rev); }); } callback(); }, // add the new pad to all authors who contributed to the old one function(callback) { var authorIDs = _this.getAllAuthors(); authorIDs.forEach(function (authorID) { authorManager.addPad(authorID, destinationID); }); callback(); }, // parallel ], callback); }, function(callback) { if (destGroupID) { // Group pad? Add it to the group's list db.setSub("group:" + destGroupID, ["pads", destinationID], 1); } // Initialize the new pad (will update the listAllPads cache) setTimeout(function() { padManager.getPad(destinationID, null, callback) // this runs too early. }, 10); }, // let the plugins know the pad was copied function(callback) { hooks.callAll('padCopy', { 'originalPad': _this, 'destinationID': destinationID }); callback(); } // series ], function(err) { if (ERR(err, callback)) return; callback(null, { padID: destinationID }); }); }); Pad.prototype.remove = thenify(function remove(callback) { var padID = this.id; var _this = this; // kick everyone from this pad padMessageHandler.kickSessionsFromPad(padID); async.series([ // delete all relations function(callback) { async.parallel([ // is it a group pad? -> delete the entry of this pad in the group function(callback) { if (padID.indexOf("$") === -1) { // it isn't a group pad, nothing to do here callback(); return; } // it is a group pad var groupID = padID.substring(0, padID.indexOf("$")); db.get("group:" + groupID, function (err, group) { if (ERR(err, callback)) return; // remove the pad entry delete group.pads[padID]; // set the new value db.set("group:" + groupID, group); callback(); }); }, // remove the readonly entries function(callback) { readOnlyManager.getReadOnlyId(padID, function(err, readonlyID) { if (ERR(err, callback)) return; db.remove("pad2readonly:" + padID); db.remove("readonly2pad:" + readonlyID); callback(); }); }, // delete all chat messages function(callback) { var chatHead = _this.chatHead; for (var i = 0; i <= chatHead; i++) { db.remove("pad:" + padID + ":chat:" + i); } callback(); }, // delete all revisions function(callback) { var revHead = _this.head; for (var i = 0; i <= revHead; i++) { db.remove("pad:" + padID + ":revs:" + i); } callback(); }, // remove pad from all authors who contributed function(callback) { var authorIDs = _this.getAllAuthors(); authorIDs.forEach(function (authorID) { authorManager.removePad(authorID, padID); }); callback(); } ], callback); }, // delete the pad entry and delete pad from padManager function(callback) { padManager.removePad(padID); hooks.callAll("padRemove", { 'padID': padID }); callback(); } ], function(err) { if (ERR(err, callback)) return; callback(); }); }); // set in db Pad.prototype.setPublicStatus = function setPublicStatus(publicStatus) { this.publicStatus = publicStatus; this.saveToDatabase(); }; Pad.prototype.setPassword = function setPassword(password) { this.passwordHash = password == null ? null : hash(password, generateSalt()); this.saveToDatabase(); }; Pad.prototype.isCorrectPassword = function isCorrectPassword(password) { return compare(this.passwordHash, password); }; Pad.prototype.isPasswordProtected = function isPasswordProtected() { return this.passwordHash != null; }; Pad.prototype.addSavedRevision = function addSavedRevision(revNum, savedById, label) { // if this revision is already saved, return silently for (var i in this.savedRevisions) { if (this.savedRevisions[i] && this.savedRevisions[i].revNum === revNum) { return; } } // build the saved revision object var 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); this.saveToDatabase(); }; Pad.prototype.getSavedRevisions = function getSavedRevisions() { return this.savedRevisions; }; /* Crypto helper methods */ function hash(password, salt) { var shasum = crypto.createHash('sha512'); shasum.update(password + salt); return shasum.digest("hex") + "$" + salt; } function generateSalt() { return randomString(86); } function compare(hashStr, password) { return hash(password, hashStr.split("$")[1]) === hashStr; }