Merge branch 'develop'

This commit is contained in:
webzwo0i 2021-03-22 16:17:20 +01:00
commit 5db0c8d1cf
30 changed files with 496 additions and 524 deletions

View file

@ -1,3 +1,26 @@
# 1.8.13
### Notable fixes
* Fixed a bug in the safeRun.sh script (#4935)
* Don't create sessions on some static resources (#4921)
* Fixed issue with non-opening device keyboard on smartphones (#4929)
* Add version string to iframe_editor.css to prevent stale cache entry (#4964)
### Notable enhancements
* Refactor pad loading (no document.write anymore) (#4960)
* Improve import/export functionality, logging and tests (#4957)
* Refactor CSS manager creation (#4963)
* Better metrics
* Add test for client height (#4965)
### Dependencies
* ueberDB2 1.3.2 -> 1.4.4
* express-rate-limit 5.2.5 -> 5.2.6
* etherpad-require-kernel 1.0.9 -> 1.0.11
# 1.8.12 # 1.8.12
Special mention: Thanks to Sauce Labs for additional testing tunnels to help us grow! :) Special mention: Thanks to Sauce Labs for additional testing tunnels to help us grow! :)

View file

@ -15,6 +15,7 @@ Etherpad is extremely flexible providing you the means to modify it to solve wha
* [Video Chat](https://video.etherpad.com) - Plugins to enable Video and Audio chat in a pad. * [Video Chat](https://video.etherpad.com) - Plugins to enable Video and Audio chat in a pad.
* [Collaboration++](https://collab.etherpad.com) - Plugins to improve the really-real time collaboration experience, suitable for busy pads. * [Collaboration++](https://collab.etherpad.com) - Plugins to improve the really-real time collaboration experience, suitable for busy pads.
* [Document Analysis](https://analysis.etherpad.com) - Plugins to improve author and document analysis during and post creation. * [Document Analysis](https://analysis.etherpad.com) - Plugins to improve author and document analysis during and post creation.
* [Scale](https://shard.etherpad.com) - Etherpad running at scale with pad sharding which allows Etherpad to scale to ∞ number of Active Pads with up to ~20,000 edits per second, per pad.
# Project Status # Project Status

View file

@ -24,8 +24,8 @@ fatal() { error "$@"; exit 1; }
LAST_EMAIL_SEND=0 LAST_EMAIL_SEND=0
# Move to the Etherpad base directory. # Move to the Etherpad base directory.
MY_DIR=$(try cd "${0%/*}" && try pwd -P) || exit 1 MY_DIR=$(cd "${0%/*}" && pwd -P) || exit 1
try cd "${MY_DIR}/../.." cd "${MY_DIR}/../.." || exit 1
# Check if a logfile parameter is set # Check if a logfile parameter is set
LOG="$1" LOG="$1"

View file

@ -31,7 +31,7 @@
"pad.settings.stickychat": "Di ekranê de hertim çet bike", "pad.settings.stickychat": "Di ekranê de hertim çet bike",
"pad.settings.chatandusers": "Çeta û Bikarhênera Nîşan bide", "pad.settings.chatandusers": "Çeta û Bikarhênera Nîşan bide",
"pad.settings.colorcheck": "Rengên nivîskarîye", "pad.settings.colorcheck": "Rengên nivîskarîye",
"pad.settings.linenocheck": "Hejmarên rêze", "pad.settings.linenocheck": "Hejmarên rêzê",
"pad.settings.rtlcheck": "Bila naverok ji raste ber bi çepe be xwendin?", "pad.settings.rtlcheck": "Bila naverok ji raste ber bi çepe be xwendin?",
"pad.settings.fontType": "Tîpa nivîsê:", "pad.settings.fontType": "Tîpa nivîsê:",
"pad.settings.language": "Ziman:", "pad.settings.language": "Ziman:",

View file

@ -24,6 +24,7 @@
const ueberDB = require('ueberdb2'); const ueberDB = require('ueberdb2');
const settings = require('../utils/Settings'); const settings = require('../utils/Settings');
const log4js = require('log4js'); const log4js = require('log4js');
const stats = require('../stats');
const util = require('util'); const util = require('util');
// set database settings // set database settings
@ -48,6 +49,13 @@ exports.init = async () => await new Promise((resolve, reject) => {
process.exit(1); process.exit(1);
} }
if (db.metrics != null) {
for (const [metric, value] of Object.entries(db.metrics)) {
if (typeof value !== 'number') continue;
stats.gauge(`ueberdb_${metric}`, () => db.metrics[metric]);
}
}
// everything ok, set up Promise-based methods // everything ok, set up Promise-based methods
['get', 'set', 'findKeys', 'getSub', 'setSub', 'remove'].forEach((fn) => { ['get', 'set', 'findKeys', 'getSub', 'setSub', 'remove'].forEach((fn) => {
exports[fn] = util.promisify(db[fn].bind(db)); exports[fn] = util.promisify(db[fn].bind(db));

View file

@ -33,24 +33,12 @@ const util = require('util');
const fsp_writeFile = util.promisify(fs.writeFile); const fsp_writeFile = util.promisify(fs.writeFile);
const fsp_unlink = util.promisify(fs.unlink); const fsp_unlink = util.promisify(fs.unlink);
let convertor = null;
// load abiword only if it is enabled
if (settings.abiword != null) {
convertor = require('../utils/Abiword');
}
// Use LibreOffice if an executable has been defined in the settings
if (settings.soffice != null) {
convertor = require('../utils/LibreOffice');
}
const tempDirectory = os.tmpdir(); const tempDirectory = os.tmpdir();
/** /**
* do a requested export * do a requested export
*/ */
const doExport = async (req, res, padId, readOnlyId, type) => { exports.doExport = async (req, res, padId, readOnlyId, type) => {
// avoid naming the read-only file as the original pad's id // avoid naming the read-only file as the original pad's id
let fileName = readOnlyId ? readOnlyId : padId; let fileName = readOnlyId ? readOnlyId : padId;
@ -85,7 +73,7 @@ const doExport = async (req, res, padId, readOnlyId, type) => {
const newHTML = await hooks.aCallFirst('exportHTMLSend', html); const newHTML = await hooks.aCallFirst('exportHTMLSend', html);
if (newHTML.length) html = newHTML; if (newHTML.length) html = newHTML;
res.send(html); res.send(html);
throw 'stop'; return;
} }
// else write the html export to a file // else write the html export to a file
@ -98,7 +86,7 @@ const doExport = async (req, res, padId, readOnlyId, type) => {
html = null; html = null;
await TidyHtml.tidy(srcFile); await TidyHtml.tidy(srcFile);
// send the convert job to the convertor (abiword, libreoffice, ..) // send the convert job to the converter (abiword, libreoffice, ..)
const destFile = `${tempDirectory}/etherpad_export_${randNum}.${type}`; const destFile = `${tempDirectory}/etherpad_export_${randNum}.${type}`;
// Allow plugins to overwrite the convert in export process // Allow plugins to overwrite the convert in export process
@ -106,12 +94,11 @@ const doExport = async (req, res, padId, readOnlyId, type) => {
if (result.length > 0) { if (result.length > 0) {
// console.log("export handled by plugin", destFile); // console.log("export handled by plugin", destFile);
} else { } else {
// @TODO no Promise interface for convertors (yet) const converter =
await new Promise((resolve, reject) => { settings.soffice != null ? require('../utils/LibreOffice')
convertor.convertFile(srcFile, destFile, type, (err) => { : settings.abiword != null ? require('../utils/Abiword')
err ? reject('convertFailed') : resolve(); : null;
}); await converter.convertFile(srcFile, destFile, type);
});
} }
// send the file // send the file
@ -128,11 +115,3 @@ const doExport = async (req, res, padId, readOnlyId, type) => {
await fsp_unlink(destFile); await fsp_unlink(destFile);
} }
}; };
exports.doExport = (req, res, padId, readOnlyId, type) => {
doExport(req, res, padId, readOnlyId, type).catch((err) => {
if (err !== 'stop') {
throw err;
}
});
};

View file

@ -55,17 +55,17 @@ const rm = async (path) => {
} }
}; };
let convertor = null; let converter = null;
let exportExtension = 'htm'; let exportExtension = 'htm';
// load abiword only if it is enabled and if soffice is disabled // load abiword only if it is enabled and if soffice is disabled
if (settings.abiword != null && settings.soffice == null) { if (settings.abiword != null && settings.soffice == null) {
convertor = require('../utils/Abiword'); converter = require('../utils/Abiword');
} }
// load soffice only if it is enabled // load soffice only if it is enabled
if (settings.soffice != null) { if (settings.soffice != null) {
convertor = require('../utils/LibreOffice'); converter = require('../utils/LibreOffice');
exportExtension = 'html'; exportExtension = 'html';
} }
@ -80,8 +80,8 @@ const doImport = async (req, res, padId) => {
// set html in the pad // set html in the pad
const randNum = Math.floor(Math.random() * 0xFFFFFFFF); const randNum = Math.floor(Math.random() * 0xFFFFFFFF);
// setting flag for whether to use convertor or not // setting flag for whether to use converter or not
let useConvertor = (convertor != null); let useConverter = (converter != null);
const form = new formidable.IncomingForm(); const form = new formidable.IncomingForm();
form.keepExtensions = true; form.keepExtensions = true;
@ -170,30 +170,25 @@ const doImport = async (req, res, padId) => {
// convert file to html if necessary // convert file to html if necessary
if (!importHandledByPlugin && !directDatabaseAccess) { if (!importHandledByPlugin && !directDatabaseAccess) {
if (fileIsTXT) { if (fileIsTXT) {
// Don't use convertor for text files // Don't use converter for text files
useConvertor = false; useConverter = false;
} }
// See https://github.com/ether/etherpad-lite/issues/2572 // See https://github.com/ether/etherpad-lite/issues/2572
if (fileIsHTML || !useConvertor) { if (fileIsHTML || !useConverter) {
// if no convertor only rename // if no converter only rename
await fs.rename(srcFile, destFile); await fs.rename(srcFile, destFile);
} else { } else {
// @TODO - no Promise interface for convertors (yet) try {
await new Promise((resolve, reject) => { await converter.convertFile(srcFile, destFile, exportExtension);
convertor.convertFile(srcFile, destFile, exportExtension, (err) => { } catch (err) {
// catch convert errors
if (err) {
logger.warn(`Converting Error: ${err.stack || err}`); logger.warn(`Converting Error: ${err.stack || err}`);
return reject(new ImportError('convertFailed')); throw new ImportError('convertFailed');
} }
resolve();
});
});
} }
} }
if (!useConvertor && !directDatabaseAccess) { if (!useConverter && !directDatabaseAccess) {
// Read the file with no encoding for raw buffer access. // Read the file with no encoding for raw buffer access.
const buf = await fs.readFile(destFile); const buf = await fs.readFile(destFile);
@ -224,7 +219,7 @@ const doImport = async (req, res, padId) => {
// change text of the pad and broadcast the changeset // change text of the pad and broadcast the changeset
if (!directDatabaseAccess) { if (!directDatabaseAccess) {
if (importHandledByPlugin || useConvertor || fileIsHTML) { if (importHandledByPlugin || useConverter || fileIsHTML) {
try { try {
await importHtml.setPadHTML(pad, text); await importHtml.setPadHTML(pad, text);
} catch (err) { } catch (err) {

View file

@ -60,7 +60,7 @@ exports.expressCreateServer = (hookName, args, cb) => {
} }
console.log(`Exporting pad "${req.params.pad}" in ${req.params.type} format`); console.log(`Exporting pad "${req.params.pad}" in ${req.params.type} format`);
exportHandler.doExport(req, res, padId, readOnlyId, req.params.type); await exportHandler.doExport(req, res, padId, readOnlyId, req.params.type);
} }
})().catch((err) => next(err || new Error(err))); })().catch((err) => next(err || new Error(err)));
}); });

View file

@ -10,12 +10,20 @@ const readOnlyManager = require('../../db/ReadOnlyManager');
hooks.deprecationNotices.authFailure = 'use the authnFailure and authzFailure hooks instead'; hooks.deprecationNotices.authFailure = 'use the authnFailure and authzFailure hooks instead';
const staticPathsRE = new RegExp(`^/(?:${[ const staticPathsRE = new RegExp(`^/(?:${[
'api/.*', 'api(?:/.*)?',
'favicon\\.ico', 'favicon\\.ico',
'ep/pad/connection-diagnostic-info',
'javascript',
'javascripts/.*', 'javascripts/.*',
'jserror/?',
'locales\\.json', 'locales\\.json',
'locales/.*',
'rest/.*',
'pluginfw/.*', 'pluginfw/.*',
'robots.txt',
'static/.*', 'static/.*',
'stats/?',
'tests/frontend(?:/.*)?'
].join('|')})$`); ].join('|')})$`);
exports.normalizeAuthzLevel = (level) => { exports.normalizeAuthzLevel = (level) => {

View file

@ -46,6 +46,7 @@ const hooks = require('../static/js/pluginfw/hooks');
const pluginDefs = require('../static/js/pluginfw/plugin_defs'); const pluginDefs = require('../static/js/pluginfw/plugin_defs');
const plugins = require('../static/js/pluginfw/plugins'); const plugins = require('../static/js/pluginfw/plugins');
const settings = require('./utils/Settings'); const settings = require('./utils/Settings');
const stats = require('./stats');
const logger = log4js.getLogger('server'); const logger = log4js.getLogger('server');
@ -104,8 +105,6 @@ exports.start = async () => {
// Check if Etherpad version is up-to-date // Check if Etherpad version is up-to-date
UpdateCheck.check(); UpdateCheck.check();
// start up stats counting system
const stats = require('./stats');
stats.gauge('memoryUsage', () => process.memoryUsage().rss); stats.gauge('memoryUsage', () => process.memoryUsage().rss);
stats.gauge('memoryUsageHeap', () => process.memoryUsage().heapUsed); stats.gauge('memoryUsageHeap', () => process.memoryUsage().heapUsed);
@ -215,6 +214,7 @@ exports.exit = async (err = null) => {
logger.info('Received SIGTERM signal'); logger.info('Received SIGTERM signal');
err = null; err = null;
} else if (err != null) { } else if (err != null) {
logger.error(`Metrics at time of fatal error:\n${JSON.stringify(stats.toJSON(), null, 2)}`);
logger.error(err.stack || err.toString()); logger.error(err.stack || err.toString());
process.exitCode = 1; process.exitCode = 1;
if (exitCalled) { if (exitCalled) {

View file

@ -24,111 +24,67 @@ const async = require('async');
const settings = require('./Settings'); const settings = require('./Settings');
const os = require('os'); const os = require('os');
let doConvertTask;
// on windows we have to spawn a process for each convertion, // on windows we have to spawn a process for each convertion,
// cause the plugin abicommand doesn't exist on this platform // cause the plugin abicommand doesn't exist on this platform
if (os.type().indexOf('Windows') > -1) { if (os.type().indexOf('Windows') > -1) {
exports.convertFile = async (srcFile, destFile, type) => {
const abiword = spawn(settings.abiword, [`--to=${destFile}`, srcFile]);
let stdoutBuffer = ''; let stdoutBuffer = '';
abiword.stdout.on('data', (data) => { stdoutBuffer += data.toString(); });
doConvertTask = (task, callback) => { abiword.stderr.on('data', (data) => { stdoutBuffer += data.toString(); });
// span an abiword process to perform the conversion await new Promise((resolve, reject) => {
const abiword = spawn(settings.abiword, [`--to=${task.destFile}`, task.srcFile]);
// delegate the processing of stdout to another function
abiword.stdout.on('data', (data) => {
// add data to buffer
stdoutBuffer += data.toString();
});
// append error messages to the buffer
abiword.stderr.on('data', (data) => {
stdoutBuffer += data.toString();
});
// throw exceptions if abiword is dieing
abiword.on('exit', (code) => { abiword.on('exit', (code) => {
if (code !== 0) { if (code !== 0) return reject(new Error(`Abiword died with exit code ${code}`));
return callback(`Abiword died with exit code ${code}`);
}
if (stdoutBuffer !== '') { if (stdoutBuffer !== '') {
console.log(stdoutBuffer); console.log(stdoutBuffer);
} }
resolve();
callback(); });
}); });
};
exports.convertFile = (srcFile, destFile, type, callback) => {
doConvertTask({srcFile, destFile, type}, callback);
}; };
// on unix operating systems, we can start abiword with abicommand and // on unix operating systems, we can start abiword with abicommand and
// communicate with it via stdin/stdout // communicate with it via stdin/stdout
// thats much faster, about factor 10 // thats much faster, about factor 10
} else { } else {
// spawn the abiword process
let abiword; let abiword;
let stdoutCallback = null; let stdoutCallback = null;
const spawnAbiword = () => { const spawnAbiword = () => {
abiword = spawn(settings.abiword, ['--plugin', 'AbiCommand']); abiword = spawn(settings.abiword, ['--plugin', 'AbiCommand']);
let stdoutBuffer = ''; let stdoutBuffer = '';
let firstPrompt = true; let firstPrompt = true;
abiword.stderr.on('data', (data) => { stdoutBuffer += data.toString(); });
// append error messages to the buffer
abiword.stderr.on('data', (data) => {
stdoutBuffer += data.toString();
});
// abiword died, let's restart abiword and return an error with the callback
abiword.on('exit', (code) => { abiword.on('exit', (code) => {
spawnAbiword(); spawnAbiword();
stdoutCallback(`Abiword died with exit code ${code}`); if (stdoutCallback != null) {
stdoutCallback(new Error(`Abiword died with exit code ${code}`));
stdoutCallback = null;
}
}); });
// delegate the processing of stdout to a other function
abiword.stdout.on('data', (data) => { abiword.stdout.on('data', (data) => {
// add data to buffer
stdoutBuffer += data.toString(); stdoutBuffer += data.toString();
// we're searching for the prompt, cause this means everything we need is in the buffer // we're searching for the prompt, cause this means everything we need is in the buffer
if (stdoutBuffer.search('AbiWord:>') !== -1) { if (stdoutBuffer.search('AbiWord:>') !== -1) {
// filter the feedback message const err = stdoutBuffer.search('OK') !== -1 ? null : new Error(stdoutBuffer);
const err = stdoutBuffer.search('OK') !== -1 ? null : stdoutBuffer;
// reset the buffer
stdoutBuffer = ''; stdoutBuffer = '';
// call the callback with the error message
// skip the first prompt
if (stdoutCallback != null && !firstPrompt) { if (stdoutCallback != null && !firstPrompt) {
stdoutCallback(err); stdoutCallback(err);
stdoutCallback = null; stdoutCallback = null;
} }
firstPrompt = false; firstPrompt = false;
} }
}); });
}; };
spawnAbiword(); spawnAbiword();
doConvertTask = (task, callback) => { const queue = async.queue((task, callback) => {
abiword.stdin.write(`convert ${task.srcFile} ${task.destFile} ${task.type}\n`); abiword.stdin.write(`convert ${task.srcFile} ${task.destFile} ${task.type}\n`);
// create a callback that calls the task callback and the caller callback
stdoutCallback = (err) => { stdoutCallback = (err) => {
callback(); if (err != null) console.error('Abiword File failed to convert', err);
console.log('queue continue'); callback(err);
try {
task.callback(err);
} catch (e) {
console.error('Abiword File failed to convert', e);
}
};
}; };
}, 1);
// Queue with the converts we have to do exports.convertFile = async (srcFile, destFile, type) => {
const queue = async.queue(doConvertTask, 1); await queue.pushAsync({srcFile, destFile, type});
exports.convertFile = (srcFile, destFile, type, callback) => {
queue.push({srcFile, destFile, type, callback});
}; };
} }

View file

@ -18,7 +18,7 @@
*/ */
const async = require('async'); const async = require('async');
const fs = require('fs'); const fs = require('fs').promises;
const log4js = require('log4js'); const log4js = require('log4js');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
@ -27,18 +27,11 @@ const spawn = require('child_process').spawn;
const libreOfficeLogger = log4js.getLogger('LibreOffice'); const libreOfficeLogger = log4js.getLogger('LibreOffice');
const doConvertTask = (task, callback) => { const doConvertTask = async (task) => {
const tmpDir = os.tmpdir(); const tmpDir = os.tmpdir();
async.series([
/*
* use LibreOffice to convert task.srcFile to another format, given in
* task.type
*/
(callback) => {
libreOfficeLogger.debug( libreOfficeLogger.debug(
`Converting ${task.srcFile} to format ${task.type}. The result will be put in ${tmpDir}` `Converting ${task.srcFile} to format ${task.type}. The result will be put in ${tmpDir}`);
);
const soffice = spawn(settings.soffice, [ const soffice = spawn(settings.soffice, [
'--headless', '--headless',
'--invisible', '--invisible',
@ -57,46 +50,27 @@ const doConvertTask = (task, callback) => {
soffice.stdin.pause(); // required to kill hanging threads soffice.stdin.pause(); // required to kill hanging threads
soffice.kill(); soffice.kill();
}, 120000); }, 120000);
let stdoutBuffer = ''; let stdoutBuffer = '';
soffice.stdout.on('data', (data) => { stdoutBuffer += data.toString(); });
// Delegate the processing of stdout to another function soffice.stderr.on('data', (data) => { stdoutBuffer += data.toString(); });
soffice.stdout.on('data', (data) => { await new Promise((resolve, reject) => {
stdoutBuffer += data.toString();
});
// Append error messages to the buffer
soffice.stderr.on('data', (data) => {
stdoutBuffer += data.toString();
});
soffice.on('exit', (code) => { soffice.on('exit', (code) => {
clearTimeout(hangTimeout); clearTimeout(hangTimeout);
if (code !== 0) { if (code !== 0) {
// Throw an exception if libreoffice failed const err =
return callback(`LibreOffice died with exit code ${code} and message: ${stdoutBuffer}`); new Error(`LibreOffice died with exit code ${code} and message: ${stdoutBuffer}`);
libreOfficeLogger.error(err.stack);
return reject(err);
} }
resolve();
// if LibreOffice exited succesfully, go on with processing });
callback();
}); });
},
// Move the converted file to the correct place
(callback) => {
const filename = path.basename(task.srcFile); const filename = path.basename(task.srcFile);
const sourceFile = `${filename.substr(0, filename.lastIndexOf('.'))}.${task.fileExtension}`; const sourceFile = `${filename.substr(0, filename.lastIndexOf('.'))}.${task.fileExtension}`;
const sourcePath = path.join(tmpDir, sourceFile); const sourcePath = path.join(tmpDir, sourceFile);
libreOfficeLogger.debug(`Renaming ${sourcePath} to ${task.destFile}`); libreOfficeLogger.debug(`Renaming ${sourcePath} to ${task.destFile}`);
fs.rename(sourcePath, task.destFile, callback); await fs.rename(sourcePath, task.destFile);
},
], (err) => {
// Invoke the callback for the local queue
callback();
// Invoke the callback for the task
task.callback(err);
});
}; };
// Conversion tasks will be queued up, so we don't overload the system // Conversion tasks will be queued up, so we don't overload the system
@ -110,7 +84,7 @@ const queue = async.queue(doConvertTask, 1);
* @param {String} type The type to convert into * @param {String} type The type to convert into
* @param {Function} callback Standard callback function * @param {Function} callback Standard callback function
*/ */
exports.convertFile = (srcFile, destFile, type, callback) => { exports.convertFile = async (srcFile, destFile, type) => {
// Used for the moving of the file, not the conversion // Used for the moving of the file, not the conversion
const fileExtension = type; const fileExtension = type;
@ -129,23 +103,10 @@ exports.convertFile = (srcFile, destFile, type, callback) => {
// we need to convert to odt first, then to doc // we need to convert to odt first, then to doc
// to avoid `Error: no export filter for /tmp/xxxx.doc` error // to avoid `Error: no export filter for /tmp/xxxx.doc` error
if (type === 'doc') { if (type === 'doc') {
queue.push({ const intermediateFile = destFile.replace(/\.doc$/, '.odt');
srcFile, await queue.pushAsync({srcFile, destFile: intermediateFile, type: 'odt', fileExtension: 'odt'});
destFile: destFile.replace(/\.doc$/, '.odt'), await queue.pushAsync({srcFile: intermediateFile, destFile, type, fileExtension});
type: 'odt',
callback: () => {
queue.push(
{
srcFile: srcFile.replace(/\.html$/, '.odt'),
destFile,
type,
callback,
fileExtension,
}
);
},
});
} else { } else {
queue.push({srcFile, destFile, type, callback, fileExtension}); await queue.pushAsync({srcFile, destFile, type, fileExtension});
} }
}; };

View file

@ -14,6 +14,7 @@
, "pad_automatic_reconnect.js" , "pad_automatic_reconnect.js"
, "ace.js" , "ace.js"
, "collab_client.js" , "collab_client.js"
, "cssmanager.js"
, "pad_userlist.js" , "pad_userlist.js"
, "pad_impexp.js" , "pad_impexp.js"
, "pad_savedrevs.js" , "pad_savedrevs.js"
@ -61,7 +62,6 @@
, "Changeset.js" , "Changeset.js"
, "ChangesetUtils.js" , "ChangesetUtils.js"
, "skiplist.js" , "skiplist.js"
, "cssmanager.js"
, "colorutils.js" , "colorutils.js"
, "undomodule.js" , "undomodule.js"
, "$unorm/lib/unorm.js" , "$unorm/lib/unorm.js"

45
src/package-lock.json generated
View file

@ -1,6 +1,6 @@
{ {
"name": "ep_etherpad-lite", "name": "ep_etherpad-lite",
"version": "1.8.12", "version": "1.8.13",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -15,9 +15,9 @@
} }
}, },
"@azure/abort-controller": { "@azure/abort-controller": {
"version": "1.0.3", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.0.4.tgz",
"integrity": "sha512-kCibMwqffnwlw3c+e879rCE1Am1I2BfhjOeO54XNA8i/cEuzktnBQbTrzh67XwibHO05YuNgZzSWy9ocVfFAGw==", "integrity": "sha512-lNUmDRVGpanCsiUN3NWxFTdwmdFI53xwhkTFfHDGTYk46ca7Ind3nanJc+U6Zj9Tv+9nTCWRBscWEW1DyKOpTw==",
"requires": { "requires": {
"tslib": "^2.0.0" "tslib": "^2.0.0"
}, },
@ -287,9 +287,9 @@
"integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==" "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w=="
}, },
"@types/node": { "@types/node": {
"version": "14.14.31", "version": "14.14.34",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.31.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.34.tgz",
"integrity": "sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g==" "integrity": "sha512-dBPaxocOK6UVyvhbnpFIj2W+S+1cBTkHQbFQfeeJhoKFbzYcVUGHvddeWPSucKATb3F0+pgDq0i6ghEaZjsugA=="
}, },
"@types/node-fetch": { "@types/node-fetch": {
"version": "2.5.8", "version": "2.5.8",
@ -2051,9 +2051,9 @@
} }
}, },
"etherpad-require-kernel": { "etherpad-require-kernel": {
"version": "1.0.9", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/etherpad-require-kernel/-/etherpad-require-kernel-1.0.9.tgz", "resolved": "https://registry.npmjs.org/etherpad-require-kernel/-/etherpad-require-kernel-1.0.11.tgz",
"integrity": "sha1-7Y8E6f0szsOgBVu20t/p2ZkS5+I=" "integrity": "sha512-I03bkNiBMrcsJRSl0IqotUU70s9v6VISrITj/cQgAoVQSoRFbV/NUn2fPIF4LskysTpmwlmwJqgfL2FZpAtxEw=="
}, },
"etherpad-yajsml": { "etherpad-yajsml": {
"version": "0.0.4", "version": "0.0.4",
@ -2110,9 +2110,9 @@
} }
}, },
"express-rate-limit": { "express-rate-limit": {
"version": "5.2.5", "version": "5.2.6",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.2.5.tgz", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.2.6.tgz",
"integrity": "sha512-fv9mf4hWRKZHVlY8ChVNYnGxa49m0zQ6CrJxNiXe2IjJPqicrqoA/JOyBbvs4ufSSLZ6NTzhtgEyLcdfbe+Q6Q==" "integrity": "sha512-nE96xaxGfxiS5jP3tD3kIW1Jg9yQgX0rXCs3rCkZtmbWHEGyotwaezkLj7bnB41Z0uaOLM8W4AX6qHao4IZ2YA=="
}, },
"express-session": { "express-session": {
"version": "1.17.1", "version": "1.17.1",
@ -7689,9 +7689,9 @@
"optional": true "optional": true
}, },
"simple-git": { "simple-git": {
"version": "2.35.2", "version": "2.36.2",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-2.35.2.tgz", "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-2.36.2.tgz",
"integrity": "sha512-UjOKsrz92Bx7z00Wla5V6qLSf5X2XSp0sL2gzKw1Bh7iJfDPDaU7gK5avIup0yo1/sMOSUMQer2b9GcnF6nmTQ==", "integrity": "sha512-orBEf65GfSiQMsYedbJXSiRNnIRvhbeE5rrxZuEimCpWxDZOav0KLy2IEiPi1YJCF+zaC2quiJF8A4TsxI9/tw==",
"requires": { "requires": {
"@kwsites/file-exists": "^1.1.1", "@kwsites/file-exists": "^1.1.1",
"@kwsites/promise-deferred": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1",
@ -8436,13 +8436,12 @@
} }
}, },
"ueberdb2": { "ueberdb2": {
"version": "1.3.2", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/ueberdb2/-/ueberdb2-1.3.2.tgz", "resolved": "https://registry.npmjs.org/ueberdb2/-/ueberdb2-1.4.4.tgz",
"integrity": "sha512-7Ub5jDsIS+qjjsNV7yp1CHXHVe2K9ZUpwaHi9BZf3ai0DxtuHOfMada1wxL6iyEjwYXh/Nsu80iyId51wHFf4A==", "integrity": "sha512-hcexgTdMa6gMquv5r6rOBsr76awMlqAjQiMMJ72qrzuatLYJ6D1EQTK/Jqo4nOD/jklXHM2yFw1mNcHsrlEzrw==",
"requires": { "requires": {
"async": "^3.2.0", "async": "^3.2.0",
"cassandra-driver": "^4.5.1", "cassandra-driver": "^4.5.1",
"channels": "0.0.4",
"dirty": "^1.1.1", "dirty": "^1.1.1",
"elasticsearch": "^16.7.1", "elasticsearch": "^16.7.1",
"mongodb": "^3.6.3", "mongodb": "^3.6.3",
@ -8774,9 +8773,9 @@
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
}, },
"xmldom": { "xmldom": {
"version": "0.4.0", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.4.0.tgz", "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.5.0.tgz",
"integrity": "sha512-2E93k08T30Ugs+34HBSTQLVtpi6mCddaY8uO+pMNk1pqSjV5vElzn4mmh6KLxN3hki8rNcHSYzILoh3TEWORvA==" "integrity": "sha512-Foaj5FXVzgn7xFzsKeNIde9g6aFBxTPi37iwsno8QvApmtg7KYrr+OPyRHcJF7dud2a5nGRBXK3n0dL62Gf7PA=="
}, },
"xmlhttprequest-ssl": { "xmlhttprequest-ssl": {
"version": "1.5.5", "version": "1.5.5",

View file

@ -38,10 +38,10 @@
"cookie-parser": "1.4.5", "cookie-parser": "1.4.5",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"ejs": "^3.1.6", "ejs": "^3.1.6",
"etherpad-require-kernel": "1.0.9", "etherpad-require-kernel": "1.0.11",
"etherpad-yajsml": "0.0.4", "etherpad-yajsml": "0.0.4",
"express": "4.17.1", "express": "4.17.1",
"express-rate-limit": "5.2.5", "express-rate-limit": "5.2.6",
"express-session": "1.17.1", "express-session": "1.17.1",
"find-root": "1.1.0", "find-root": "1.1.0",
"formidable": "1.2.2", "formidable": "1.2.2",
@ -69,7 +69,7 @@
"threads": "^1.4.0", "threads": "^1.4.0",
"tiny-worker": "^2.3.0", "tiny-worker": "^2.3.0",
"tinycon": "0.6.8", "tinycon": "0.6.8",
"ueberdb2": "^1.3.2", "ueberdb2": "^1.4.4",
"underscore": "1.12.0", "underscore": "1.12.0",
"unorm": "1.6.0", "unorm": "1.6.0",
"wtfnode": "^0.8.4" "wtfnode": "^0.8.4"
@ -246,6 +246,6 @@
"test": "mocha --timeout 120000 --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs", "test": "mocha --timeout 120000 --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs",
"test-container": "mocha --timeout 5000 tests/container/specs/api" "test-container": "mocha --timeout 5000 tests/container/specs/api"
}, },
"version": "1.8.12", "version": "1.8.13",
"license": "Apache-2.0" "license": "Apache-2.0"
} }

View file

@ -4,8 +4,7 @@
@import url('./lists_and_indents.css'); @import url('./lists_and_indents.css');
html.inner-editor { html.outer-editor, html.inner-editor {
height: auto !important;
background-color: transparent !important; background-color: transparent !important;
} }
#outerdocbody { #outerdocbody {
@ -38,6 +37,11 @@ html.inner-editor {
white-space: normal; white-space: normal;
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
/*
* Make the contenteditable area at least as big as the screen so that mobile
* users can tap anywhere to bring up their device's keyboard.
*/
min-height: 100vh;
} }
#innerdocbody, #sidediv { #innerdocbody, #sidediv {

View file

@ -1,11 +1,14 @@
html, body { html, body {
width: 100%; width: 100%;
height: 100%; height: auto;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
html:not(.inner-editor), html:not(.inner-editor) body {
/* used in pad and timeslider */
html.pad, html.pad body {
overflow: hidden; overflow: hidden;
height: 100%;
} }
body { body {
display: flex; display: flex;

View file

@ -50,10 +50,9 @@ exports.error = (msg) => {
* @param b {boolean} assertion condition * @param b {boolean} assertion condition
* @param msgParts {string} error to be passed if it fails * @param msgParts {string} error to be passed if it fails
*/ */
exports.assert = (b, msgParts) => { exports.assert = (b, ...msgParts) => {
if (!b) { if (!b) {
const msg = Array.prototype.slice.call(arguments, 1).join(''); exports.error(`Failed assertion: ${msgParts.join('')}`);
exports.error(`Failed assertion: ${msg}`);
} }
}; };

View file

@ -25,22 +25,69 @@
// requires: undefined // requires: undefined
const hooks = require('./pluginfw/hooks'); const hooks = require('./pluginfw/hooks');
const makeCSSManager = require('./cssmanager').makeCSSManager;
const pluginUtils = require('./pluginfw/shared'); const pluginUtils = require('./pluginfw/shared');
const debugLog = (...args) => {}; const debugLog = (...args) => {};
window.debugLog = debugLog;
// The inner and outer iframe's locations are about:blank, so relative URLs are relative to that. // The inner and outer iframe's locations are about:blank, so relative URLs are relative to that.
// Firefox and Chrome seem to do what the developer intends if given a relative URL, but Safari // Firefox and Chrome seem to do what the developer intends if given a relative URL, but Safari
// errors out unless given an absolute URL for a JavaScript-created element. // errors out unless given an absolute URL for a JavaScript-created element.
const absUrl = (url) => new URL(url, window.location.href).href; const absUrl = (url) => new URL(url, window.location.href).href;
const scriptTag = const eventFired = async (obj, event, cleanups = [], predicate = () => true) => {
(source) => `<script type="text/javascript">\n${source.replace(/<\//g, '<\\/')}</script>`; if (typeof cleanups === 'function') {
predicate = cleanups;
cleanups = [];
}
await new Promise((resolve, reject) => {
let cleanup;
const successCb = () => {
if (!predicate()) return;
debugLog(`Ace2Editor.init() ${event} event on`, obj);
cleanup();
resolve();
};
const errorCb = () => {
const err = new Error(`Ace2Editor.init() error event while waiting for ${event} event`);
debugLog(`${err} on object`, obj);
cleanup();
reject(err);
};
cleanup = () => {
cleanup = () => {};
obj.removeEventListener(event, successCb);
obj.removeEventListener('error', errorCb);
};
cleanups.push(cleanup);
obj.addEventListener(event, successCb);
obj.addEventListener('error', errorCb);
});
};
// Resolves when the frame's document is ready to be mutated. Browsers seem to be quirky about
// iframe ready events so this function throws the kitchen sink at the problem. Maybe one day we'll
// find a concise general solution.
const frameReady = async (frame) => {
// Can't do `const doc = frame.contentDocument;` because Firefox seems to asynchronously replace
// the document object after the frame is first created for some reason. ¯\_(ツ)_/¯
const doc = () => frame.contentDocument;
const cleanups = [];
try {
await Promise.race([
eventFired(frame, 'load', cleanups),
eventFired(frame.contentWindow, 'load', cleanups),
eventFired(doc(), 'load', cleanups),
eventFired(doc(), 'DOMContentLoaded', cleanups),
eventFired(doc(), 'readystatechange', cleanups, () => doc.readyState === 'complete'),
]);
} finally {
for (const cleanup of cleanups) cleanup();
}
};
const Ace2Editor = function () { const Ace2Editor = function () {
let info = {editor: this}; let info = {editor: this};
window.ace2EditorInfo = info; // Make it accessible to iframes.
let loaded = false; let loaded = false;
let actionsPendingInit = []; let actionsPendingInit = [];
@ -109,16 +156,19 @@ const Ace2Editor = function () {
// returns array of {error: <browser Error object>, time: +new Date()} // returns array of {error: <browser Error object>, time: +new Date()}
this.getUnhandledErrors = () => loaded ? info.ace_getUnhandledErrors() : []; this.getUnhandledErrors = () => loaded ? info.ace_getUnhandledErrors() : [];
const pushStyleTagsFor = (buffer, files) => { const addStyleTagsFor = (doc, files) => {
for (const file of files) { for (const file of files) {
buffer.push(`<link rel="stylesheet" type="text/css" href="${absUrl(encodeURI(file))}"/>`); const link = doc.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = absUrl(encodeURI(file));
doc.head.appendChild(link);
} }
}; };
this.destroy = pendingInit(() => { this.destroy = pendingInit(() => {
info.ace_dispose(); info.ace_dispose();
info.frame.parentNode.removeChild(info.frame); info.frame.parentNode.removeChild(info.frame);
delete window.ace2EditorInfo;
info = null; // prevent IE 6 closure memory leaks info = null; // prevent IE 6 closure memory leaks
}); });
@ -127,7 +177,7 @@ const Ace2Editor = function () {
this.importText(initialCode); this.importText(initialCode);
const includedCSS = [ const includedCSS = [
'../static/css/iframe_editor.css', `../static/css/iframe_editor.css?v=${clientVars.randomVersionString}`,
`../static/css/pad.css?v=${clientVars.randomVersionString}`, `../static/css/pad.css?v=${clientVars.randomVersionString}`,
...hooks.callAll('aceEditorCSS').map( ...hooks.callAll('aceEditorCSS').map(
// Allow urls to external CSS - http(s):// and //some/path.css // Allow urls to external CSS - http(s):// and //some/path.css
@ -135,110 +185,135 @@ const Ace2Editor = function () {
`../static/skins/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`, `../static/skins/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`,
]; ];
const doctype = '<!doctype html>'; const skinVariants = clientVars.skinVariants.split(' ').filter((x) => x !== '');
const iframeHTML = []; const outerFrame = document.createElement('iframe');
iframeHTML.push(doctype);
iframeHTML.push(`<html class='inner-editor ${clientVars.skinVariants}'><head>`);
pushStyleTagsFor(iframeHTML, includedCSS);
const requireKernelUrl =
absUrl(`../static/js/require-kernel.js?v=${clientVars.randomVersionString}`);
iframeHTML.push(`<script type="text/javascript" src="${requireKernelUrl}"></script>`);
// Pre-fetch modules to improve load performance.
for (const module of ['ace2_inner', 'ace2_common']) {
const url = absUrl(`../javascripts/lib/ep_etherpad-lite/static/js/${module}.js` +
`?callback=require.define&v=${clientVars.randomVersionString}`);
iframeHTML.push(`<script type="text/javascript" src="${url}"></script>`);
}
iframeHTML.push(scriptTag(`(async () => {
parent.parent.debugLog('Ace2Editor.init() inner frame ready');
const require = window.require;
require.setRootURI(${JSON.stringify(absUrl('../javascripts/src'))});
require.setLibraryURI(${JSON.stringify(absUrl('../javascripts/lib'))});
require.setGlobalKeyPath('require');
// intentially moved before requiring client_plugins to save a 307
window.Ace2Inner = require('ep_etherpad-lite/static/js/ace2_inner');
window.plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins');
window.plugins.adoptPluginsFromAncestorsOf(window);
window.$ = window.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery;
parent.parent.debugLog('Ace2Editor.init() waiting for plugins');
await new Promise((resolve, reject) => window.plugins.ensure(
(err) => err != null ? reject(err) : resolve()));
parent.parent.debugLog('Ace2Editor.init() waiting for Ace2Inner.init()');
const editorInfo = parent.parent.ace2EditorInfo;
await new Promise((resolve, reject) => window.Ace2Inner.init(
editorInfo, (err) => err != null ? reject(err) : resolve()));
parent.parent.debugLog('Ace2Editor.init() Ace2Inner.init() returned');
editorInfo.onEditorReady();
})();`));
iframeHTML.push('<style type="text/css" title="dynamicsyntax"></style>');
hooks.callAll('aceInitInnerdocbodyHead', {
iframeHTML,
});
iframeHTML.push('</head><body id="innerdocbody" class="innerdocbody" role="application" ' +
'spellcheck="false">&nbsp;</body></html>');
const outerScript = `(async () => {
await new Promise((resolve) => { window.onload = () => resolve(); });
parent.debugLog('Ace2Editor.init() outer frame ready');
window.onload = null;
await new Promise((resolve) => setTimeout(resolve, 0));
const iframe = document.createElement('iframe');
iframe.name = 'ace_inner';
iframe.title = 'pad';
iframe.scrolling = 'no';
iframe.frameBorder = 0;
iframe.allowTransparency = true; // for IE
iframe.ace_outerWin = window;
document.body.insertBefore(iframe, document.body.firstChild);
const doc = iframe.contentWindow.document;
doc.open();
doc.write(${JSON.stringify(iframeHTML.join('\n'))});
doc.close();
parent.debugLog('Ace2Editor.init() waiting for inner frame');
})();`;
const outerHTML =
[doctype, `<html class="inner-editor outerdoc ${clientVars.skinVariants}"><head>`];
pushStyleTagsFor(outerHTML, includedCSS);
// bizarrely, in FF2, a file with no "external" dependencies won't finish loading properly
// (throbs busy while typing)
const pluginNames = pluginUtils.clientPluginNames();
outerHTML.push(
'<style type="text/css" title="dynamicsyntax"></style>',
'<link rel="stylesheet" type="text/css" href="data:text/css,"/>',
scriptTag(outerScript),
'</head>',
'<body id="outerdocbody" class="outerdocbody ', pluginNames.join(' '), '">',
'<div id="sidediv" class="sidediv"><!-- --></div>',
'<div id="linemetricsdiv">x</div>',
'</body></html>');
const outerFrame = document.createElement('IFRAME');
outerFrame.name = 'ace_outer'; outerFrame.name = 'ace_outer';
outerFrame.frameBorder = 0; // for IE outerFrame.frameBorder = 0; // for IE
outerFrame.title = 'Ether'; outerFrame.title = 'Ether';
// Some browsers do strange things unless the iframe has a src or srcdoc property:
// - Firefox replaces the frame's contentWindow.document object with a different object after
// the frame is created. This can be worked around by waiting for the window's load event
// before continuing.
// - Chrome never fires any events on the frame or document. Eventually the document's
// readyState becomes 'complete' even though it never fires a readystatechange event.
// - Safari behaves like Chrome.
outerFrame.srcdoc = '<!DOCTYPE html>';
info.frame = outerFrame; info.frame = outerFrame;
document.getElementById(containerId).appendChild(outerFrame); document.getElementById(containerId).appendChild(outerFrame);
const outerWindow = outerFrame.contentWindow;
const editorDocument = outerFrame.contentWindow.document;
debugLog('Ace2Editor.init() waiting for outer frame'); debugLog('Ace2Editor.init() waiting for outer frame');
await new Promise((resolve, reject) => { await frameReady(outerFrame);
info.onEditorReady = (err) => err != null ? reject(err) : resolve(); debugLog('Ace2Editor.init() outer frame ready');
editorDocument.open();
editorDocument.write(outerHTML.join('')); // Firefox might replace the outerWindow.document object after iframe creation so this variable
editorDocument.close(); // is assigned after the Window's load event.
const outerDocument = outerWindow.document;
// <html> tag
outerDocument.documentElement.classList.add('outer-editor', 'outerdoc', ...skinVariants);
// <head> tag
addStyleTagsFor(outerDocument, includedCSS);
const outerStyle = outerDocument.createElement('style');
outerStyle.type = 'text/css';
outerStyle.title = 'dynamicsyntax';
outerDocument.head.appendChild(outerStyle);
// <body> tag
outerDocument.body.id = 'outerdocbody';
outerDocument.body.classList.add('outerdocbody', ...pluginUtils.clientPluginNames());
const sideDiv = outerDocument.createElement('div');
sideDiv.id = 'sidediv';
sideDiv.classList.add('sidediv');
outerDocument.body.appendChild(sideDiv);
const lineMetricsDiv = outerDocument.createElement('div');
lineMetricsDiv.id = 'linemetricsdiv';
lineMetricsDiv.appendChild(outerDocument.createTextNode('x'));
outerDocument.body.appendChild(lineMetricsDiv);
const innerFrame = outerDocument.createElement('iframe');
innerFrame.name = 'ace_inner';
innerFrame.title = 'pad';
innerFrame.scrolling = 'no';
innerFrame.frameBorder = 0;
innerFrame.allowTransparency = true; // for IE
// The iframe MUST have a src or srcdoc property to avoid browser quirks. See the comment above
// outerFrame.srcdoc.
innerFrame.srcdoc = '<!DOCTYPE html>';
innerFrame.ace_outerWin = outerWindow;
outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild);
const innerWindow = innerFrame.contentWindow;
debugLog('Ace2Editor.init() waiting for inner frame');
await frameReady(innerFrame);
debugLog('Ace2Editor.init() inner frame ready');
// Firefox might replace the innerWindow.document object after iframe creation so this variable
// is assigned after the Window's load event.
const innerDocument = innerWindow.document;
// <html> tag
innerDocument.documentElement.classList.add('inner-editor', ...skinVariants);
// <head> tag
addStyleTagsFor(innerDocument, includedCSS);
const requireKernel = innerDocument.createElement('script');
requireKernel.type = 'text/javascript';
requireKernel.src =
absUrl(`../static/js/require-kernel.js?v=${clientVars.randomVersionString}`);
innerDocument.head.appendChild(requireKernel);
// Pre-fetch modules to improve load performance.
for (const module of ['ace2_inner', 'ace2_common']) {
const script = innerDocument.createElement('script');
script.type = 'text/javascript';
script.src = absUrl(`../javascripts/lib/ep_etherpad-lite/static/js/${module}.js` +
`?callback=require.define&v=${clientVars.randomVersionString}`);
innerDocument.head.appendChild(script);
}
const innerStyle = innerDocument.createElement('style');
innerStyle.type = 'text/css';
innerStyle.title = 'dynamicsyntax';
innerDocument.head.appendChild(innerStyle);
const headLines = [];
hooks.callAll('aceInitInnerdocbodyHead', {iframeHTML: headLines});
const tmp = innerDocument.createElement('div');
tmp.innerHTML = headLines.join('\n');
while (tmp.firstChild) innerDocument.head.appendChild(tmp.firstChild);
// <body> tag
innerDocument.body.id = 'innerdocbody';
innerDocument.body.classList.add('innerdocbody');
innerDocument.body.setAttribute('role', 'application');
innerDocument.body.setAttribute('spellcheck', 'false');
innerDocument.body.appendChild(innerDocument.createTextNode('\u00A0')); // &nbsp;
debugLog('Ace2Editor.init() waiting for require kernel load');
await eventFired(requireKernel, 'load');
debugLog('Ace2Editor.init() require kernel loaded');
const require = innerWindow.require;
require.setRootURI(absUrl('../javascripts/src'));
require.setLibraryURI(absUrl('../javascripts/lib'));
require.setGlobalKeyPath('require');
// intentially moved before requiring client_plugins to save a 307
innerWindow.Ace2Inner = require('ep_etherpad-lite/static/js/ace2_inner');
innerWindow.plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins');
innerWindow.plugins.adoptPluginsFromAncestorsOf(innerWindow);
innerWindow.$ = innerWindow.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery;
debugLog('Ace2Editor.init() waiting for plugins');
await new Promise((resolve, reject) => innerWindow.plugins.ensure(
(err) => err != null ? reject(err) : resolve()));
debugLog('Ace2Editor.init() waiting for Ace2Inner.init()');
await innerWindow.Ace2Inner.init(info, {
inner: makeCSSManager(innerStyle.sheet),
outer: makeCSSManager(outerStyle.sheet),
parent: makeCSSManager(document.querySelector('style[title="dynamicsyntax"]').sheet),
}); });
debugLog('Ace2Editor.init() Ace2Inner.init() returned');
loaded = true; loaded = true;
doActionsPendingInit(); doActionsPendingInit();
debugLog('Ace2Editor.init() done'); debugLog('Ace2Editor.init() done');

View file

@ -30,11 +30,10 @@ const htmlPrettyEscape = Ace2Common.htmlPrettyEscape;
const noop = Ace2Common.noop; const noop = Ace2Common.noop;
const hooks = require('./pluginfw/hooks'); const hooks = require('./pluginfw/hooks');
function Ace2Inner(editorInfo) { function Ace2Inner(editorInfo, cssManagers) {
const makeChangesetTracker = require('./changesettracker').makeChangesetTracker; const makeChangesetTracker = require('./changesettracker').makeChangesetTracker;
const colorutils = require('./colorutils').colorutils; const colorutils = require('./colorutils').colorutils;
const makeContentCollector = require('./contentcollector').makeContentCollector; const makeContentCollector = require('./contentcollector').makeContentCollector;
const makeCSSManager = require('./cssmanager').makeCSSManager;
const domline = require('./domline').domline; const domline = require('./domline').domline;
const AttribPool = require('./AttributePool'); const AttribPool = require('./AttributePool');
const Changeset = require('./Changeset'); const Changeset = require('./Changeset');
@ -157,10 +156,6 @@ function Ace2Inner(editorInfo) {
const scheduler = parent; // hack for opera required const scheduler = parent; // hack for opera required
let dynamicCSS = null;
let outerDynamicCSS = null;
let parentDynamicCSS = null;
const performDocumentReplaceRange = (start, end, newText) => { const performDocumentReplaceRange = (start, end, newText) => {
if (start === undefined) start = rep.selStart; if (start === undefined) start = rep.selStart;
if (end === undefined) end = rep.selEnd; if (end === undefined) end = rep.selEnd;
@ -181,12 +176,6 @@ function Ace2Inner(editorInfo) {
performDocumentApplyChangeset(cs); performDocumentApplyChangeset(cs);
}; };
const initDynamicCSS = () => {
dynamicCSS = makeCSSManager('dynamicsyntax');
outerDynamicCSS = makeCSSManager('dynamicsyntax', 'outer');
parentDynamicCSS = makeCSSManager('dynamicsyntax', 'parent');
};
const changesetTracker = makeChangesetTracker(scheduler, rep.apool, { const changesetTracker = makeChangesetTracker(scheduler, rep.apool, {
withCallbacks: (operationName, f) => { withCallbacks: (operationName, f) => {
inCallStackIfNecessary(operationName, () => { inCallStackIfNecessary(operationName, () => {
@ -214,15 +203,12 @@ function Ace2Inner(editorInfo) {
editorInfo.ace_getAuthorInfos = getAuthorInfos; editorInfo.ace_getAuthorInfos = getAuthorInfos;
const setAuthorStyle = (author, info) => { const setAuthorStyle = (author, info) => {
if (!dynamicCSS) {
return;
}
const authorSelector = getAuthorColorClassSelector(getAuthorClassName(author)); const authorSelector = getAuthorColorClassSelector(getAuthorClassName(author));
const authorStyleSet = hooks.callAll('aceSetAuthorStyle', { const authorStyleSet = hooks.callAll('aceSetAuthorStyle', {
dynamicCSS, dynamicCSS: cssManagers.inner,
parentDynamicCSS, outerDynamicCSS: cssManagers.outer,
outerDynamicCSS, parentDynamicCSS: cssManagers.parent,
info, info,
author, author,
authorSelector, authorSelector,
@ -234,16 +220,16 @@ function Ace2Inner(editorInfo) {
} }
if (!info) { if (!info) {
dynamicCSS.removeSelectorStyle(authorSelector); cssManagers.inner.removeSelectorStyle(authorSelector);
parentDynamicCSS.removeSelectorStyle(authorSelector); cssManagers.parent.removeSelectorStyle(authorSelector);
} else if (info.bgcolor) { } else if (info.bgcolor) {
let bgcolor = info.bgcolor; let bgcolor = info.bgcolor;
if ((typeof info.fade) === 'number') { if ((typeof info.fade) === 'number') {
bgcolor = fadeColor(bgcolor, info.fade); bgcolor = fadeColor(bgcolor, info.fade);
} }
const authorStyle = dynamicCSS.selectorStyle(authorSelector); const authorStyle = cssManagers.inner.selectorStyle(authorSelector);
const parentAuthorStyle = parentDynamicCSS.selectorStyle(authorSelector); const parentAuthorStyle = cssManagers.parent.selectorStyle(authorSelector);
// author color // author color
authorStyle.backgroundColor = bgcolor; authorStyle.backgroundColor = bgcolor;
@ -3895,8 +3881,8 @@ function Ace2Inner(editorInfo) {
editorInfo.ace_performDocumentApplyAttributesToRange = editorInfo.ace_performDocumentApplyAttributesToRange =
(...args) => documentAttributeManager.setAttributesOnRange(...args); (...args) => documentAttributeManager.setAttributesOnRange(...args);
this.init = (cb) => { this.init = async () => {
$(document).ready(() => { await $.ready;
doc = document; // defined as a var in scope outside doc = document; // defined as a var in scope outside
inCallStack('setup', () => { inCallStack('setup', () => {
const body = doc.getElementById('innerdocbody'); const body = doc.getElementById('innerdocbody');
@ -3906,8 +3892,6 @@ function Ace2Inner(editorInfo) {
root.classList.toggle('authorColors', true); root.classList.toggle('authorColors', true);
root.classList.toggle('doesWrap', doesWrap); root.classList.toggle('doesWrap', doesWrap);
initDynamicCSS();
enforceEditability(); enforceEditability();
// set up dom and rep // set up dom and rep
@ -3926,13 +3910,10 @@ function Ace2Inner(editorInfo) {
rep, rep,
documentAttributeManager, documentAttributeManager,
}); });
scheduler.setTimeout(cb, 0);
});
}; };
} }
exports.init = (editorInfo, cb) => { exports.init = async (editorInfo, cssManagers) => {
const editor = new Ace2Inner(editorInfo); const editor = new Ace2Inner(editorInfo, cssManagers);
editor.init(cb); await editor.init();
}; };

View file

@ -468,14 +468,14 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
BroadcastSlider.onSlider(goToRevisionIfEnabled); BroadcastSlider.onSlider(goToRevisionIfEnabled);
const dynamicCSS = makeCSSManager('dynamicsyntax'); const dynamicCSS = makeCSSManager(document.querySelector('style[title="dynamicsyntax"]').sheet);
const authorData = {}; const authorData = {};
const receiveAuthorData = (newAuthorData) => { const receiveAuthorData = (newAuthorData) => {
for (const [author, data] of Object.entries(newAuthorData)) { for (const [author, data] of Object.entries(newAuthorData)) {
const bgcolor = typeof data.colorId === 'number' const bgcolor = typeof data.colorId === 'number'
? clientVars.colorPalette[data.colorId] : data.colorId; ? clientVars.colorPalette[data.colorId] : data.colorId;
if (bgcolor && dynamicCSS) { if (bgcolor) {
const selector = dynamicCSS.selectorStyle(`.${linestylefilter.getAuthorClassName(author)}`); const selector = dynamicCSS.selectorStyle(`.${linestylefilter.getAuthorClassName(author)}`);
selector.backgroundColor = bgcolor; selector.backgroundColor = bgcolor;
selector.color = (colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5) selector.color = (colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5)

View file

@ -22,37 +22,7 @@
* limitations under the License. * limitations under the License.
*/ */
const makeCSSManager = (emptyStylesheetTitle, doc) => { exports.makeCSSManager = (browserSheet) => {
if (doc === true) {
doc = 'parent';
} else if (!doc) {
doc = 'inner';
}
const getSheetByTitle = (title) => {
let win;
if (doc === 'parent') {
win = window.parent.parent;
} else if (doc === 'inner') {
win = window;
} else if (doc === 'outer') {
win = window.parent;
} else {
throw new Error('Unknown dynamic style container');
}
const allSheets = win.document.styleSheets;
for (let i = 0; i < allSheets.length; i++) {
const s = allSheets[i];
if (s.title === title) {
return s;
}
}
return null;
};
const browserSheet = getSheetByTitle(emptyStylesheetTitle);
const browserRules = () => (browserSheet.cssRules || browserSheet.rules); const browserRules = () => (browserSheet.cssRules || browserSheet.rules);
const browserDeleteRule = (i) => { const browserDeleteRule = (i) => {
@ -100,5 +70,3 @@ const makeCSSManager = (emptyStylesheetTitle, doc) => {
info: () => `${selectorList.length}:${browserRules().length}`, info: () => `${selectorList.length}:${browserRules().length}`,
}; };
}; };
exports.makeCSSManager = makeCSSManager;

View file

@ -501,7 +501,7 @@ const pad = {
// order of inits is important here: // order of inits is important here:
padimpexp.init(this); padimpexp.init(this);
padsavedrevs.init(this); padsavedrevs.init(this);
padeditor.init(postAceInit, pad.padOptions.view || {}, this); padeditor.init(pad.padOptions.view || {}, this).then(postAceInit);
paduserlist.init(pad.myUserInfo, this); paduserlist.init(pad.myUserInfo, this);
padconnectionstatus.init(); padconnectionstatus.init();
padmodals.init(this); padmodals.init(this);

View file

@ -34,30 +34,20 @@ const padeditor = (() => {
ace: null, ace: null,
// this is accessed directly from other files // this is accessed directly from other files
viewZoom: 100, viewZoom: 100,
init: (readyFunc, initialViewOptions, _pad) => { init: async (initialViewOptions, _pad) => {
Ace2Editor = require('./ace').Ace2Editor; Ace2Editor = require('./ace').Ace2Editor;
pad = _pad; pad = _pad;
settings = pad.settings; settings = pad.settings;
self.ace = new Ace2Editor();
const aceReady = () => { await self.ace.init('editorcontainer', '');
$('#editorloadingbox').hide(); $('#editorloadingbox').hide();
if (readyFunc) {
readyFunc();
// Listen for clicks on sidediv items // Listen for clicks on sidediv items
const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody'); const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody');
$outerdoc.find('#sidedivinner').on('click', 'div', function () { $outerdoc.find('#sidedivinner').on('click', 'div', function () {
const targetLineNumber = $(this).index() + 1; const targetLineNumber = $(this).index() + 1;
window.location.hash = `L${targetLineNumber}`; window.location.hash = `L${targetLineNumber}`;
}); });
exports.focusOnLine(self.ace); exports.focusOnLine(self.ace);
}
};
self.ace = new Ace2Editor();
self.ace.init('editorcontainer', '').then(
() => aceReady(), (err) => { throw err || new Error(err); });
self.ace.setProperty('wraps', true); self.ace.setProperty('wraps', true);
if (pad.getIsDebugEnabled()) { if (pad.getIsDebugEnabled()) {
self.ace.setProperty('dmesg', pad.dmesg); self.ace.setProperty('dmesg', pad.dmesg);

View file

@ -0,0 +1 @@
/* intentionally empty */

View file

@ -7,7 +7,7 @@
<!doctype html> <!doctype html>
<% e.begin_block("htmlHead"); %> <% e.begin_block("htmlHead"); %>
<html class="<%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>"> <html class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
<% e.end_block(); %> <% e.end_block(); %>
<title><%=settings.title%></title> <title><%=settings.title%></title>

View file

@ -3,7 +3,7 @@
, langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs , langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs
%> %>
<!doctype html> <!doctype html>
<html class="<%=settings.skinVariants%>"> <html class="pad <%=settings.skinVariants%>">
<title data-l10n-id="timeslider.pageTitle" data-l10n-args='{ "appTitle": "<%=settings.title%>" }'><%=settings.title%> Timeslider</title> <title data-l10n-id="timeslider.pageTitle" data-l10n-args='{ "appTitle": "<%=settings.title%>" }'><%=settings.title%> Timeslider</title>
<script> <script>
/* /*

View file

@ -6,6 +6,7 @@
* TODO: unify those two files, and merge in a single one. * TODO: unify those two files, and merge in a single one.
*/ */
const assert = require('assert').strict;
const common = require('../../common'); const common = require('../../common');
let agent; let agent;
@ -226,6 +227,8 @@ const testImports = {
}; };
describe(__filename, function () { describe(__filename, function () {
this.timeout(1000);
before(async function () { agent = await common.init(); }); before(async function () { agent = await common.init(); });
Object.keys(testImports).forEach((testName) => { Object.keys(testImports).forEach((testName) => {
@ -237,73 +240,34 @@ describe(__filename, function () {
done(); done();
}); });
} }
it('createPad', function (done) {
this.timeout(200); it('createPad', async function () {
agent.get(`${endPoint('createPad')}&padID=${testPadId}`) const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect((res) => { .expect(200)
if (res.body.code !== 0) throw new Error('Unable to create new Pad'); .expect('Content-Type', /json/);
}) assert.equal(res.body.code, 0);
.expect('Content-Type', /json/)
.expect(200, done);
}); });
it('setHTML', function (done) { it('setHTML', async function () {
this.timeout(150); const res = await agent.get(`${endPoint('setHTML')}&padID=${testPadId}` +
agent.get(`${endPoint('setHTML')}&padID=${testPadId}` +
`&html=${encodeURIComponent(test.input)}`) `&html=${encodeURIComponent(test.input)}`)
.expect((res) => { .expect(200)
if (res.body.code !== 0) throw new Error(`Error:${testName}`); .expect('Content-Type', /json/);
}) assert.equal(res.body.code, 0);
.expect('Content-Type', /json/)
.expect(200, done);
}); });
it('getHTML', function (done) { it('getHTML', async function () {
this.timeout(150); const res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`)
agent.get(`${endPoint('getHTML')}&padID=${testPadId}`) .expect(200)
.expect((res) => { .expect('Content-Type', /json/);
const gotHtml = res.body.data.html; assert.equal(res.body.data.html, test.wantHTML);
if (gotHtml !== test.wantHTML) {
throw new Error(`HTML received from export is not the one we were expecting.
Test Name:
${testName}
Got:
${JSON.stringify(gotHtml)}
Want:
${JSON.stringify(test.wantHTML)}
Which is a different version of the originally imported one:
${test.input}`);
}
})
.expect('Content-Type', /json/)
.expect(200, done);
}); });
it('getText', function (done) { it('getText', async function () {
this.timeout(100); const res = await agent.get(`${endPoint('getText')}&padID=${testPadId}`)
agent.get(`${endPoint('getText')}&padID=${testPadId}`) .expect(200)
.expect((res) => { .expect('Content-Type', /json/);
const gotText = res.body.data.text; assert.equal(res.body.data.text, test.wantText);
if (gotText !== test.wantText) {
throw new Error(`Text received from export is not the one we were expecting.
Test Name:
${testName}
Got:
${JSON.stringify(gotText)}
Want:
${JSON.stringify(test.wantText)}
Which is a different version of the originally imported one:
${test.input}`);
}
})
.expect('Content-Type', /json/)
.expect(200, done);
}); });
}); });
}); });

View file

@ -0,0 +1,26 @@
'use strict';
const common = require('../common');
const padManager = require('../../../node/db/PadManager');
const settings = require('../../../node/utils/Settings');
describe(__filename, function () {
let agent;
const settingsBackup = {};
before(async function () {
agent = await common.init();
settingsBackup.soffice = settings.soffice;
await padManager.getPad('testExportPad', 'test content');
});
after(async function () {
Object.assign(settings, settingsBackup);
});
it('returns 500 on export error', async function () {
settings.soffice = 'false'; // '/bin/false' doesn't work on Windows
await agent.get('/p/testExportPad/export/doc')
.expect(500);
});
});

View file

@ -0,0 +1,31 @@
'use strict';
describe('height regression after ace.js refactoring', function () {
before(function (cb) {
helper.newPad(cb);
});
// everything fits inside the viewport
it('clientHeight should equal scrollHeight with few lines', function() {
const aceOuter = helper.padChrome$('iframe')[0].contentDocument;
const clientHeight = aceOuter.documentElement.clientHeight;
const scrollHeight = aceOuter.documentElement.scrollHeight;
expect(clientHeight).to.be(scrollHeight);
});
it('client height should be less than scrollHeight with many lines', async function () {
await helper.clearPad();
await helper.edit('Test line\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n');
const aceOuter = helper.padChrome$('iframe')[0].contentDocument;
const clientHeight = aceOuter.documentElement.clientHeight;
const scrollHeight = aceOuter.documentElement.scrollHeight;
expect(clientHeight).to.be.lessThan(scrollHeight);
});
});