diff --git a/.github/workflows/major-version-git-pull-update.yml b/.github/workflows/major-version-git-pull-update.yml new file mode 100644 index 000000000..5d3b0d748 --- /dev/null +++ b/.github/workflows/major-version-git-pull-update.yml @@ -0,0 +1,83 @@ +name: "In-place git pull from master" + +# any branch is useful for testing before a PR is submitted +on: [push, pull_request] + +jobs: + withpluginsLinux: + # run on pushes to any branch + # run on PRs from external forks + if: | + (github.event_name != 'pull_request') + || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id) + name: Linux with Plugins + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + node: [10, 12, 14, 15] + + steps: + - name: Checkout master repository + uses: actions/checkout@v2 + with: + ref: master + + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node }} + + - name: Install Etherpad plugins + # The --legacy-peer-deps flag is required to work around a bug in npm v7: + # https://github.com/npm/cli/issues/2199 + run: > + npm install --no-save --legacy-peer-deps + ep_align + ep_author_hover + ep_cursortrace + ep_font_size + ep_hash_auth + ep_headings2 + ep_image_upload + ep_markdown + ep_readonly_guest + ep_set_title_on_pad + ep_spellcheck + ep_subscript_and_superscript + ep_table_of_contents + + # This must be run after installing the plugins, otherwise npm will try to + # hoist common dependencies by removing them from src/node_modules and + # installing them in the top-level node_modules. As of v6.14.10, npm's hoist + # logic appears to be buggy, because it sometimes removes dependencies from + # src/node_modules but fails to add them to the top-level node_modules. Even + # if npm correctly hoists the dependencies, the hoisting seems to confuse + # tools such as `npm outdated`, `npm update`, and some ESLint rules. + - name: Install all dependencies and symlink for ep_etherpad-lite + run: src/bin/installDeps.sh + + - name: Run the backend tests + run: cd src && npm test + + - name: Git fetch + run: git fetch + + - name: Checkout this branch over master + run: git checkout "${GITHUB_SHA}" + + - name: Install all dependencies and symlink for ep_etherpad-lite + run: src/bin/installDeps.sh + + - name: Run the backend tests + run: cd src && npm test + + - name: Install Cypress + run: npm install cypress -g + + - name: Run Etherpad & Test Frontend + run: | + node src/node/server.js & + curl --connect-timeout 10 --max-time 20 --retry 5 --retry-delay 10 --retry-max-time 60 --retry-connrefused http://127.0.0.1:9001/p/test + cd src/tests/frontend + cypress run --spec cypress/integration/test.js --config-file cypress/cypress.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 331a1b98e..12a9fd431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,27 @@ # 1.8.12 +Special mention: Thanks to Sauce Labs for additional testing tunnels to help us grow! :) + +### Security patches + +* Fixed a regression in v1.8.11 which caused some pad names to cause Etherpad to restart. + ### Notable fixes * Fixed a bug in the `dirty` database driver that sometimes caused Node.js to crash during shutdown and lose buffered database writes. * Fixed a regression in v1.8.8 that caused "Uncaught TypeError: Cannot read property '0' of undefined" with some plugins (#4885) +* Less warnings in server console for supported element types on import. +* Support Azure and other network share installations by using a + more truthful relative path. + +### Notable enhancements + +* Dependency updates +* Various Docker deployment improvements +* Various new translations +* Improvement of rendering of plugin hook list and error message handling # 1.8.11 diff --git a/Dockerfile b/Dockerfile index 29e2b5abc..660aacf9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,9 +40,20 @@ ENV NODE_ENV=production # # Running as non-root enables running this image in platforms like OpenShift # that do not allow images running as root. -RUN useradd --uid 5001 --create-home etherpad +# +# If any of the following args are set to the empty string, default +# values will be chosen. +ARG EP_HOME= +ARG EP_UID=5001 +ARG EP_GID=0 +ARG EP_SHELL= +RUN groupadd --system ${EP_GID:+--gid "${EP_GID}" --non-unique} etherpad && \ + useradd --system ${EP_UID:+--uid "${EP_UID}" --non-unique} --gid etherpad \ + ${EP_HOME:+--home-dir "${EP_HOME}"} --create-home \ + ${EP_SHELL:+--shell "${EP_SHELL}"} etherpad -RUN mkdir /opt/etherpad-lite && chown etherpad:0 /opt/etherpad-lite +ARG EP_DIR=/opt/etherpad-lite +RUN mkdir -p "${EP_DIR}" && chown etherpad:etherpad "${EP_DIR}" # install abiword for DOC/PDF/ODT export RUN [ -z "${INSTALL_ABIWORD}" ] || (apt update && apt -y install abiword && apt clean && rm -rf /var/lib/apt/lists/*) @@ -53,24 +64,20 @@ RUN [ -z "${INSTALL_SOFFICE}" ] || (apt update && mkdir -p /usr/share/man/man1 & USER etherpad -WORKDIR /opt/etherpad-lite +WORKDIR "${EP_DIR}" -COPY --chown=etherpad:0 ./ ./ +COPY --chown=etherpad:etherpad ./ ./ # install node dependencies for Etherpad RUN src/bin/installDeps.sh && \ rm -rf ~/.npm/_cacache -# Install the plugins, if ETHERPAD_PLUGINS is not empty. -# -# Bash trick: in the for loop ${ETHERPAD_PLUGINS} is NOT quoted, in order to be -# able to split at spaces. -RUN for PLUGIN_NAME in ${ETHERPAD_PLUGINS}; do npm install "${PLUGIN_NAME}" || exit 1; done +RUN [ -z "${ETHERPAD_PLUGINS}" ] || npm install ${ETHERPAD_PLUGINS} # Copy the configuration file. -COPY --chown=etherpad:0 ./settings.json.docker /opt/etherpad-lite/settings.json +COPY --chown=etherpad:etherpad ./settings.json.docker "${EP_DIR}"/settings.json -# Fix permissions for root group +# Fix group permissions RUN chmod -R g=u . EXPOSE 9001 diff --git a/src/locales/sl.json b/src/locales/sl.json index be11d4a76..7a768c058 100644 --- a/src/locales/sl.json +++ b/src/locales/sl.json @@ -9,10 +9,12 @@ "Upwinxp" ] }, + "admin_plugins.description": "Opis", "admin_plugins.last-update": "Zadnja posodobitev", "admin_plugins.name": "Ime", "admin_plugins.version": "Različica", "admin_settings": "Nastavitve", + "admin_settings.current_save.value": "Shrani nastavitve", "index.newPad": "Nov dokument", "index.createOpenPad": "ali pa ustvari/odpri dokument z imenom:", "pad.toolbar.bold.title": "Krepko (Ctrl-B)", diff --git a/src/node/hooks/express/adminplugins.js b/src/node/hooks/express/adminplugins.js index 7c0db0973..543f04c0f 100644 --- a/src/node/hooks/express/adminplugins.js +++ b/src/node/hooks/express/adminplugins.js @@ -26,8 +26,8 @@ exports.expressCreateServer = (hookName, args, cb) => { epVersion, installedPlugins: `
${plugins.formatPlugins().replace(/, /g, '\n')}
`, installedParts: `
${plugins.formatParts()}
`, - installedServerHooks: `
${plugins.formatHooks()}
`, - installedClientHooks: `
${plugins.formatHooks('client_hooks')}
`, + installedServerHooks: `
${plugins.formatHooks('hooks', true)}
`, + installedClientHooks: `
${plugins.formatHooks('client_hooks', true)}
`, latestVersion: UpdateCheck.getLatestVersion(), req, })); diff --git a/src/node/hooks/express/webaccess.js b/src/node/hooks/express/webaccess.js index 51d57ae2e..5ff957a52 100644 --- a/src/node/hooks/express/webaccess.js +++ b/src/node/hooks/express/webaccess.js @@ -10,12 +10,20 @@ const readOnlyManager = require('../../db/ReadOnlyManager'); hooks.deprecationNotices.authFailure = 'use the authnFailure and authzFailure hooks instead'; const staticPathsRE = new RegExp(`^/(?:${[ - 'api/.*', + 'api(?:/.*)?', 'favicon\\.ico', + 'ep/pad/connection-diagnostic-info', + 'javascript', 'javascripts/.*', + 'jserror/?', 'locales\\.json', + 'locales/.*', + 'rest/.*', 'pluginfw/.*', + 'robots.txt', 'static/.*', + 'stats/?', + 'tests/frontend(?:/.*)?' ].join('|')})$`); exports.normalizeAuthzLevel = (level) => { diff --git a/src/node/server.js b/src/node/server.js index aec0d442f..fc62b4471 100755 --- a/src/node/server.js +++ b/src/node/server.js @@ -144,7 +144,7 @@ exports.start = async () => { .join(', '); logger.info(`Installed plugins: ${installedPlugins}`); logger.debug(`Installed parts:\n${plugins.formatParts()}`); - logger.debug(`Installed hooks:\n${plugins.formatHooks()}`); + logger.debug(`Installed server-side hooks:\n${plugins.formatHooks('hooks', false)}`); await hooks.aCallAll('loadSettings', {settings}); await hooks.aCallAll('createServer'); } catch (err) { diff --git a/src/node/utils/ImportEtherpad.js b/src/node/utils/ImportEtherpad.js index 7f5c2ab2d..d47443733 100644 --- a/src/node/utils/ImportEtherpad.js +++ b/src/node/utils/ImportEtherpad.js @@ -18,15 +18,14 @@ const db = require('../db/DB'); const hooks = require('../../static/js/pluginfw/hooks'); +const supportedElems = require('../../static/js/contentcollector').supportedElems; exports.setPadRaw = (padId, r) => { const records = JSON.parse(r); - const blockElems = ['div', 'br', 'p', 'pre', 'li', 'author', 'lmkr', 'insertorder']; - // get supported block Elements from plugins, we will use this later. hooks.callAll('ccRegisterBlockElements').forEach((element) => { - blockElems.push(element); + supportedElems.push(element); }); Object.keys(records).forEach(async (key) => { @@ -65,7 +64,7 @@ exports.setPadRaw = (padId, r) => { if (value.pool) { for (const attrib of Object.keys(value.pool.numToAttrib)) { const attribName = value.pool.numToAttrib[attrib][0]; - if (blockElems.indexOf(attribName) === -1) { + if (supportedElems.indexOf(attribName) === -1) { console.warn('Plugin missing: ' + `You might want to install a plugin to support this node name: ${attribName}`); } diff --git a/src/package-lock.json b/src/package-lock.json index 4a3b1aa6c..14421f869 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -1,6 +1,6 @@ { "name": "ep_etherpad-lite", - "version": "1.8.11", + "version": "1.8.12", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -7539,11 +7539,11 @@ } }, "resolve": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", - "integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", + "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", "requires": { - "is-core-module": "^2.1.0", + "is-core-module": "^2.2.0", "path-parse": "^1.0.6" } }, diff --git a/src/package.json b/src/package.json index a56a656ac..ada568d95 100644 --- a/src/package.json +++ b/src/package.json @@ -61,7 +61,7 @@ "rehype": "^10.0.0", "rehype-minify-whitespace": "^4.0.5", "request": "2.88.2", - "resolve": "1.19.0", + "resolve": "1.20.0", "security": "1.0.0", "semver": "5.7.1", "socket.io": "^2.4.1", @@ -246,6 +246,6 @@ "test": "mocha --timeout 120000 --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs", "test-container": "mocha --timeout 5000 tests/container/specs/api" }, - "version": "1.8.11", + "version": "1.8.12", "license": "Apache-2.0" } diff --git a/src/static/css/iframe_editor.css b/src/static/css/iframe_editor.css index 2a63c3802..156eaff6a 100644 --- a/src/static/css/iframe_editor.css +++ b/src/static/css/iframe_editor.css @@ -4,8 +4,7 @@ @import url('./lists_and_indents.css'); -html.inner-editor { - height: auto !important; +html.outer-editor, html.inner-editor { background-color: transparent !important; } #outerdocbody { diff --git a/src/static/css/pad/layout.css b/src/static/css/pad/layout.css index e5b79c268..df3408319 100644 --- a/src/static/css/pad/layout.css +++ b/src/static/css/pad/layout.css @@ -1,11 +1,12 @@ html, body { width: 100%; - height: 100%; + height: auto; margin: 0; padding: 0; } -html:not(.inner-editor), html:not(.inner-editor) body { +html.pad, html.pad body { overflow: hidden; + height: 100%; } body { display: flex; diff --git a/src/static/js/ace.js b/src/static/js/ace.js index 6d07ecbad..0d273b85b 100644 --- a/src/static/js/ace.js +++ b/src/static/js/ace.js @@ -28,88 +28,19 @@ const hooks = require('./pluginfw/hooks'); const pluginUtils = require('./pluginfw/shared'); const debugLog = (...args) => {}; +window.debugLog = debugLog; // 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 // errors out unless given an absolute URL for a JavaScript-created element. const absUrl = (url) => new URL(url, window.location.href).href; -const eventFired = async (obj, event, cleanups = [], predicate = () => true) => { - 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); - }); -}; - -const pollCondition = async (predicate, cleanups, pollPeriod, timeout) => { - let done = false; - cleanups.push(() => { done = true; }); - // Pause a tick to give the predicate a chance to become true before adding latency. - await new Promise((resolve) => setTimeout(resolve, 0)); - const start = Date.now(); - while (!done && !predicate()) { - if (Date.now() - start > timeout) throw new Error('timeout'); - await new Promise((resolve) => setTimeout(resolve, pollPeriod)); - debugLog('Ace2Editor.init() polling'); - } - if (!done) debugLog('Ace2Editor.init() poll condition became true'); -}; - -// Resolves when the frame's document is ready to be mutated: -// - Firefox seems to replace the frame's contentWindow.document object with a different object -// after the frame is created so we need to wait for the window's load event before continuing. -// - Chrome doesn't need any waiting (not even next tick), but on Windows it never seems to fire -// any events. Eventually the document's readyState becomes 'complete' (even though it never -// fires a readystatechange event), so this function waits for that to happen to avoid returning -// too soon on Firefox. -// - Safari behaves like Chrome. -// I'm not sure how other browsers behave, 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'), - // If all else fails, poll. - pollCondition(() => doc().readyState === 'complete', cleanups, 10, 5000), - ]); - } finally { - for (const cleanup of cleanups) cleanup(); - } -}; +const scriptTag = + (source) => ``; const Ace2Editor = function () { let info = {editor: this}; + window.ace2EditorInfo = info; // Make it accessible to iframes. let loaded = false; let actionsPendingInit = []; @@ -178,19 +109,16 @@ const Ace2Editor = function () { // returns array of {error: , time: +new Date()} this.getUnhandledErrors = () => loaded ? info.ace_getUnhandledErrors() : []; - const addStyleTagsFor = (doc, files) => { + const pushStyleTagsFor = (buffer, files) => { for (const file of files) { - const link = doc.createElement('link'); - link.rel = 'stylesheet'; - link.type = 'text/css'; - link.href = absUrl(encodeURI(file)); - doc.head.appendChild(link); + buffer.push(``); } }; this.destroy = pendingInit(() => { info.ace_dispose(); info.frame.parentNode.removeChild(info.frame); + delete window.ace2EditorInfo; info = null; // prevent IE 6 closure memory leaks }); @@ -207,128 +135,109 @@ const Ace2Editor = function () { `../static/skins/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`, ]; - const skinVariants = clientVars.skinVariants.split(' ').filter((x) => x !== ''); + const doctype = ''; - const outerFrame = document.createElement('iframe'); + const iframeHTML = []; + + iframeHTML.push(doctype); + iframeHTML.push(``); + pushStyleTagsFor(iframeHTML, includedCSS); + const requireKernelUrl = + absUrl(`../static/js/require-kernel.js?v=${clientVars.randomVersionString}`); + iframeHTML.push(``); + // 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(``); + } + + 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(''); + + hooks.callAll('aceInitInnerdocbodyHead', { + iframeHTML, + }); + + iframeHTML.push(' '); + + 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, ``]; + 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( + '', + scriptTag(outerScript), + '', + '', + '
', + '
x
', + ''); + + const outerFrame = document.createElement('IFRAME'); outerFrame.name = 'ace_outer'; outerFrame.frameBorder = 0; // for IE outerFrame.title = 'Ether'; info.frame = outerFrame; document.getElementById(containerId).appendChild(outerFrame); - const outerWindow = outerFrame.contentWindow; - // For some unknown reason Firefox replaces outerWindow.document with a new Document object some - // time between running the above code and firing the outerWindow load event. Work around it by - // waiting until the load event fires before mutating the Document object. + const editorDocument = outerFrame.contentWindow.document; + debugLog('Ace2Editor.init() waiting for outer frame'); - await frameReady(outerFrame); - debugLog('Ace2Editor.init() outer frame ready'); - - // This must be done after the Window's load event. See above comment. - const outerDocument = outerWindow.document; - - // tag - outerDocument.documentElement.classList.add('inner-editor', 'outerdoc', ...skinVariants); - - // tag - addStyleTagsFor(outerDocument, includedCSS); - const outerStyle = outerDocument.createElement('style'); - outerStyle.type = 'text/css'; - outerStyle.title = 'dynamicsyntax'; - outerDocument.head.appendChild(outerStyle); - const link = outerDocument.createElement('link'); - link.rel = 'stylesheet'; - link.type = 'text/css'; - link.href = 'data:text/css,'; - outerDocument.head.appendChild(link); - - // 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 - innerFrame.ace_outerWin = outerWindow; - outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild); - const innerWindow = innerFrame.contentWindow; - - // Wait before mutating the inner document. See above comment recarding outerWindow load. - debugLog('Ace2Editor.init() waiting for inner frame'); - await frameReady(innerFrame); - debugLog('Ace2Editor.init() inner frame ready'); - - // This must be done after the Window's load event. See above comment. - const innerDocument = innerWindow.document; - - // tag - innerDocument.documentElement.classList.add('inner-editor', ...skinVariants); - - // 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); - - // 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')); //   - - 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 new Promise((resolve, reject) => innerWindow.Ace2Inner.init( - info, (err) => err != null ? reject(err) : resolve())); - debugLog('Ace2Editor.init() Ace2Inner.init() returned'); + await new Promise((resolve, reject) => { + info.onEditorReady = (err) => err != null ? reject(err) : resolve(); + editorDocument.open(); + editorDocument.write(outerHTML.join('')); + editorDocument.close(); + }); loaded = true; doActionsPendingInit(); debugLog('Ace2Editor.init() done'); diff --git a/src/static/js/contentcollector.js b/src/static/js/contentcollector.js index c250008bb..cf921cd47 100644 --- a/src/static/js/contentcollector.js +++ b/src/static/js/contentcollector.js @@ -55,6 +55,28 @@ const getAttribute = (n, a) => { if (n.attribs != null) return n.attribs[a]; return null; }; +// supportedElems are Supported natively within Etherpad and don't require a plugin +const supportedElems = [ + 'author', + 'b', + 'bold', + 'br', + 'div', + 'font', + 'i', + 'insertorder', + 'italic', + 'li', + 'lmkr', + 'ol', + 'p', + 'pre', + 'strong', + 's', + 'span', + 'u', + 'ul', +]; const makeContentCollector = (collectStyles, abrowser, apool, className2Author) => { const _blockElems = { @@ -66,6 +88,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) hooks.callAll('ccRegisterBlockElements').forEach((element) => { _blockElems[element] = 1; + supportedElems.push(element); }); const isBlockElement = (n) => !!_blockElems[tagName(n) || '']; @@ -315,9 +338,11 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) const localAttribs = state.localAttribs; state.localAttribs = null; const isBlock = isBlockElement(node); - if (!isBlock && node.name && (node.name !== 'body') && (node.name !== 'br')) { - console.warn('Plugin missing: ' + + if (!isBlock && node.name && (node.name !== 'body')) { + if (supportedElems.indexOf(node.name) === -1) { + console.warn('Plugin missing: ' + `You might want to install a plugin to support this node name: ${node.name}`); + } } const isEmpty = _isEmpty(node, state); if (isBlock) _ensureColumnZero(state); @@ -701,3 +726,4 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author) exports.sanitizeUnicode = sanitizeUnicode; exports.makeContentCollector = makeContentCollector; +exports.supportedElems = supportedElems; diff --git a/src/static/js/pluginfw/plugins.js b/src/static/js/pluginfw/plugins.js index b705fb73a..74fbbafc8 100644 --- a/src/static/js/pluginfw/plugins.js +++ b/src/static/js/pluginfw/plugins.js @@ -28,16 +28,48 @@ exports.formatPlugins = () => Object.keys(defs.plugins).join(', '); exports.formatParts = () => defs.parts.map((part) => part.full_name).join('\n'); -exports.formatHooks = (hookSetName) => { - const res = []; - const hooks = pluginUtils.extractHooks(defs.parts, hookSetName || 'hooks'); - for (const registeredHooks of Object.values(hooks)) { - for (const hook of registeredHooks) { - res.push(`
${hook.hook_name}
${hook.hook_fn_name} ` + - `from ${hook.part.full_name}
`); +exports.formatHooks = (hookSetName, html) => { + let hooks = new Map(); + for (const [pluginName, def] of Object.entries(defs.plugins)) { + for (const part of def.parts) { + for (const [hookName, hookFnName] of Object.entries(part[hookSetName] || {})) { + let hookEntry = hooks.get(hookName); + if (!hookEntry) { + hookEntry = new Map(); + hooks.set(hookName, hookEntry); + } + let pluginEntry = hookEntry.get(pluginName); + if (!pluginEntry) { + pluginEntry = new Map(); + hookEntry.set(pluginName, pluginEntry); + } + pluginEntry.set(part.name, hookFnName); + } } } - return `
${res.join('\n')}
`; + const lines = []; + const sortStringKeys = (a, b) => String(a[0]).localeCompare(b[0]); + if (html) lines.push('
'); + hooks = new Map([...hooks].sort(sortStringKeys)); + for (const [hookName, hookEntry] of hooks) { + lines.push(html ? `
${hookName}:
` : ` ${hookName}:`); + const sortedHookEntry = new Map([...hookEntry].sort(sortStringKeys)); + hooks.set(hookName, sortedHookEntry); + for (const [pluginName, pluginEntry] of sortedHookEntry) { + lines.push(html ? `
${pluginName}:
` : ` ${pluginName}:`); + const sortedPluginEntry = new Map([...pluginEntry].sort(sortStringKeys)); + sortedHookEntry.set(pluginName, sortedPluginEntry); + for (const [partName, hookFnName] of sortedPluginEntry) { + lines.push(html + ? `
${partName}:
${hookFnName}
` + : ` ${partName}: ${hookFnName}`); + } + if (html) lines.push('
'); + } + if (html) lines.push('
'); + } + if (html) lines.push('
'); + return lines.join('\n'); }; const callInit = async () => { diff --git a/src/static/js/pluginfw/shared.js b/src/static/js/pluginfw/shared.js index a78697a2a..2c81ccd81 100644 --- a/src/static/js/pluginfw/shared.js +++ b/src/static/js/pluginfw/shared.js @@ -55,9 +55,10 @@ const extractHooks = (parts, hookSetName, normalizer) => { try { hookFn = loadFn(hookFnName, hookName); if (!hookFn) throw new Error('Not a function'); - } catch (exc) { - console.error(`Failed to load '${hookFnName}' for ` + - `'${part.full_name}/${hookSetName}/${hookName}': ${exc.toString()}`); + } catch (err) { + console.error(`Failed to load hook function "${hookFnName}" for plugin "${part.plugin}" ` + + `part "${part.name}" hook set "${hookSetName}" hook "${hookName}": ` + + `${err.stack || err}`); } if (hookFn) { if (hooks[hookName] == null) hooks[hookName] = []; diff --git a/src/static/skins/no-skin/pad.css b/src/static/skins/no-skin/pad.css index e69de29bb..a9eae81f2 100644 --- a/src/static/skins/no-skin/pad.css +++ b/src/static/skins/no-skin/pad.css @@ -0,0 +1 @@ +/* intentionally empty */ diff --git a/src/templates/pad.html b/src/templates/pad.html index 26243806a..7bf2346f9 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -7,7 +7,7 @@ <% e.begin_block("htmlHead"); %> - + <% e.end_block(); %> <%=settings.title%> diff --git a/src/tests/frontend/cypress/integration/test.js b/src/tests/frontend/cypress/integration/test.js index 6574696db..893d4b669 100644 --- a/src/tests/frontend/cypress/integration/test.js +++ b/src/tests/frontend/cypress/integration/test.js @@ -10,6 +10,7 @@ Cypress.Commands.add('iframe', {prevSubject: 'element'}, describe(__filename, () => { it('Pad content exists', () => { cy.visit('http://127.0.0.1:9001/p/test'); + cy.wait(10000); // wait for Minified JS to be built... cy.get('iframe[name="ace_outer"]', {timeout: 10000}).iframe() .find('.line-number:first') .should('have.text', '1');