mirror of
https://github.com/ether/etherpad-lite.git
synced 2025-04-22 08:26:16 -04:00

This will be a breaking change for some people. We removed all internal password control logic. If this affects you, you have two options: 1. Use a plugin for authentication and use session based pad access (recommended). 1. Use a plugin for password setting. The reasoning for removing this feature is to reduce the overall security footprint of Etherpad. It is unnecessary and cumbersome to keep this feature and with the thousands of available authentication methods available in the world our focus should be on supporting those and allowing more granual access based on their implementations (instead of half assed baking our own).
621 lines
18 KiB
JavaScript
621 lines
18 KiB
JavaScript
/**
|
|
* The pad object, defined with joose
|
|
*/
|
|
|
|
|
|
var Changeset = require("ep_etherpad-lite/static/js/Changeset");
|
|
var AttributePool = require("ep_etherpad-lite/static/js/AttributePool");
|
|
var db = require("./DB");
|
|
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');
|
|
var promises = require('../utils/promises')
|
|
|
|
// 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, ' ');
|
|
};
|
|
|
|
|
|
let 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() {
|
|
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 = async 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.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': author});
|
|
} else {
|
|
hooks.callAll("padUpdate", {'pad':this, 'author': author});
|
|
}
|
|
|
|
await Promise.all(p);
|
|
};
|
|
|
|
// save all attributes to the database
|
|
Pad.prototype.saveToDatabase = async 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();
|
|
}
|
|
}
|
|
|
|
await db.set('pad:' + this.id, dbObject);
|
|
}
|
|
|
|
// get time of last edit (changeset application)
|
|
Pad.prototype.getLastEdit = function getLastEdit() {
|
|
var 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() {
|
|
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 = async function getInternalRevisionAText(targetRev) {
|
|
let keyRev = this.getKeyRevisionNumber(targetRev);
|
|
|
|
// find out which changesets are needed
|
|
let 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
|
|
let p_atext = db.getSub("pad:" + this.id + ":revs:" + keyRev, ["meta", "atext"]);
|
|
|
|
// get all needed changesets
|
|
let changesets = [];
|
|
await Promise.all(neededChangesets.map(item => {
|
|
return 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
|
|
let apool = this.apool();
|
|
for (let curRev = keyRev; curRev < targetRev; ) {
|
|
let 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() {
|
|
let authors = this.getAllAuthors();
|
|
let returnTable = {};
|
|
let colorPalette = authorManager.getColorPalette();
|
|
|
|
await Promise.all(authors.map(author => {
|
|
return 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);
|
|
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 = async 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
|
|
await this.appendRevision(changeset);
|
|
};
|
|
|
|
Pad.prototype.appendText = async 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
|
|
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
|
|
let 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
|
|
let neededEntries = [];
|
|
for (let order = 0, entryNum = start; entryNum <= end; ++order, ++entryNum) {
|
|
neededEntries.push({ entryNum, order });
|
|
}
|
|
|
|
// get all entries out of the database
|
|
let entries = [];
|
|
await Promise.all(neededEntries.map(entryObject => {
|
|
return 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
|
|
let cleanedEntries = entries.filter(entry => {
|
|
let 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
|
|
let 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 (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
|
|
let 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;
|
|
let 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
|
|
let pad = await db.get("pad:" + sourceID);
|
|
db.set("pad:" + destinationID, pad);
|
|
|
|
// copy all relations in parallel
|
|
let promises = [];
|
|
|
|
// copy all chat messages
|
|
let chatHead = this.chatHead;
|
|
for (let i = 0; i <= chatHead; ++i) {
|
|
let p = db.get("pad:" + sourceID + ":chat:" + i).then(chat => {
|
|
return db.set("pad:" + destinationID + ":chat:" + i, chat);
|
|
});
|
|
promises.push(p);
|
|
}
|
|
|
|
// copy all revisions
|
|
let revHead = this.head;
|
|
for (let i = 0; i <= revHead; ++i) {
|
|
let p = db.get("pad:" + sourceID + ":revs:" + i).then(rev => {
|
|
return 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
|
|
hooks.callAll('padCopy', { 'originalPad': this, 'destinationID': destinationID });
|
|
|
|
return { padID: destinationID };
|
|
}
|
|
|
|
Pad.prototype.checkIfGroupExistAndReturnIt = async function checkIfGroupExistAndReturnIt(destinationID) {
|
|
let destGroupID = false;
|
|
|
|
if (destinationID.indexOf("$") >= 0) {
|
|
|
|
destGroupID = destinationID.split("$")[0]
|
|
let 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.
|
|
let 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
|
|
let 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;
|
|
let 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;
|
|
}
|
|
|
|
let 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
|
|
let newPad = await padManager.getPad(destinationID, '\n');
|
|
|
|
let oldAText = this.atext;
|
|
let newPool = newPad.pool;
|
|
newPool.fromJsonable(sourcePad.pool.toJsonable()); // copy that sourceId pool to the new pad
|
|
|
|
// based on Changeset.makeSplice
|
|
let 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"
|
|
let oldLength = 2;
|
|
|
|
let newLength = assem.getLengthChange();
|
|
let newText = oldAText.text;
|
|
|
|
// create a changeset that removes the previous text and add the newText with
|
|
// all atributes present on the source pad
|
|
let changeset = Changeset.pack(oldLength, newLength, assem.toString(), newText);
|
|
newPad.appendRevision(changeset);
|
|
|
|
hooks.callAll('padCopy', { 'originalPad': this, 'destinationID': destinationID });
|
|
|
|
return { padID: destinationID };
|
|
}
|
|
|
|
|
|
Pad.prototype.remove = async function remove() {
|
|
var 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
|
|
let groupID = padID.substring(0, padID.indexOf("$"));
|
|
let 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));
|
|
hooks.callAll("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 (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);
|
|
await this.saveToDatabase();
|
|
};
|
|
|
|
Pad.prototype.getSavedRevisions = function getSavedRevisions() {
|
|
return this.savedRevisions;
|
|
};
|
|
|