diff --git a/.github/workflows/frontend-admin-tests.yml b/.github/workflows/frontend-admin-tests.yml index f2e2215aa..bff9228a7 100644 --- a/.github/workflows/frontend-admin-tests.yml +++ b/.github/workflows/frontend-admin-tests.yml @@ -1,4 +1,5 @@ -name: "Frontend admin tests" +# Leave the powered by Sauce Labs bit in as this means we get additional concurrency +name: "Frontend admin tests powered by Sauce Labs" on: [push] diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 1b57f6514..bc138416c 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -1,4 +1,5 @@ -name: "Frontend tests" +# Leave the powered by Sauce Labs bit in as this means we get additional concurrency +name: "Frontend tests powered by Sauce Labs" on: [push] @@ -35,7 +36,7 @@ jobs: run: | sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 0/' -i settings.json - - uses: saucelabs/sauce-connect-action@v1.1.2 + - uses: saucelabs/sauce-connect-action@v1 with: username: ${{ secrets.SAUCE_USERNAME }} accessKey: ${{ secrets.SAUCE_ACCESS_KEY }} @@ -78,6 +79,7 @@ jobs: ep_align ep_author_hover ep_cursortrace + ep_embedmedia ep_font_size ep_hash_auth ep_headings2 @@ -114,7 +116,7 @@ jobs: - name: Remove standard frontend test files, so only plugin tests are run run: rm src/tests/frontend/specs/* - - uses: saucelabs/sauce-connect-action@v1.1.2 + - uses: saucelabs/sauce-connect-action@v1 with: username: ${{ secrets.SAUCE_USERNAME }} accessKey: ${{ secrets.SAUCE_ACCESS_KEY }} diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index 787ef1ddd..f4cbbb58d 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -28,7 +28,7 @@ jobs: run: sudo npm install -g etherpad-load-test - name: Run load test - run: src/tests/frontend/travis/runnerLoadTest.sh + run: src/tests/frontend/travis/runnerLoadTest.sh 25 50 withplugins: # run on pushes to any branch @@ -80,4 +80,32 @@ jobs: # configures some settings and runs npm run test - name: Run load test - run: src/tests/frontend/travis/runnerLoadTest.sh + run: src/tests/frontend/travis/runnerLoadTest.sh 25 50 + + long: + # 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: long running + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: 12 + + - name: Install all dependencies and symlink for ep_etherpad-lite + run: src/bin/installDeps.sh + + - name: Install etherpad-load-test + run: sudo npm install -g etherpad-load-test + + + # configures some settings and runs npm run test + - name: Run load test + run: src/tests/frontend/travis/runnerLoadTest.sh 5000 5 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/.github/workflows/windows-zip.yml b/.github/workflows/windows-zip.yml index 6e5f80bd4..42a99a191 100644 --- a/.github/workflows/windows-zip.yml +++ b/.github/workflows/windows-zip.yml @@ -65,11 +65,13 @@ jobs: - name: Extract Etherpad run: 7z x etherpad-lite-win.zip -oetherpad - - name: list - run: dir etherpad + - name: Install Cypress + run: npm install cypress -g - name: Run Etherpad run: | cd etherpad node node_modules\ep_etherpad-lite\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 906212a53..12a9fd431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +# 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 ### Notable fixes 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/README.md b/README.md index 02eeac3f6..4bc972285 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,10 @@ Etherpad is extremely flexible providing you the means to modify it to solve wha ### Testing [![Backend tests](https://github.com/ether/etherpad-lite/actions/workflows/backend-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/backend-tests.yml) [![Simulated Load](https://github.com/ether/etherpad-lite/actions/workflows/load-test.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/load-test.yml) [![Rate Limit](https://github.com/ether/etherpad-lite/actions/workflows/rate-limit.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/rate-limit.yml) [![Windows Zip](https://github.com/ether/etherpad-lite/actions/workflows/windows-zip.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/windows-zip.yml) [![Docker file](https://github.com/ether/etherpad-lite/actions/workflows/dockerfile.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/dockerfile.yml) -[![Frontend admin tests](https://github.com/ether/etherpad-lite/actions/workflows/frontend-admin-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/frontend-admin-tests.yml) [![Frontend tests](https://github.com/ether/etherpad-lite/actions/workflows/frontend-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/frontend-tests.yml) [![Windows Installer](https://github.com/ether/etherpad-lite/actions/workflows/windows-installer.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/windows-installer.yml) +[![Frontend admin tests powered by Sauce Labs](https://github.com/ether/etherpad-lite/actions/workflows/frontend-admin-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/frontend-admin-tests.yml) [![Frontend tests powered by Sauce Labs](https://github.com/ether/etherpad-lite/actions/workflows/frontend-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/frontend-tests.yml) [![Sauce Test Status](https://saucelabs.com/buildstatus/etherpad.svg)](https://saucelabs.com/u/etherpad) [![Windows Installer](https://github.com/ether/etherpad-lite/actions/workflows/windows-installer.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/windows-installer.yml) ### Engagement -Docker Pulls ![Discord](https://img.shields.io/discord/741309013593030667?color=%2344b492) ![Etherpad plugins](https://img.shields.io/endpoint?url=https%3A%2F%2Fstatic.etherpad.org%2Fshields.json&color=%2344b492 "Etherpad plugins") ![Languages](https://img.shields.io/static/v1?label=Languages&message=105&color=%2344b492) ![Translation Coverage](https://img.shields.io/static/v1?label=Languages&message=98%&color=%2344b492) +Docker Pulls [![Discord](https://img.shields.io/discord/741309013593030667?color=%2344b492)](https://discord.com/invite/daEjfhw) [![Etherpad plugins](https://img.shields.io/endpoint?url=https%3A%2F%2Fstatic.etherpad.org%2Fshields.json&color=%2344b492 "Etherpad plugins")](https://static.etherpad.org/index.html) ![Languages](https://img.shields.io/static/v1?label=Languages&message=105&color=%2344b492) ![Translation Coverage](https://img.shields.io/static/v1?label=Languages&message=98%&color=%2344b492) # Installation diff --git a/src/bin/plugins/stalePlugins.js b/src/bin/plugins/stalePlugins.js new file mode 100644 index 000000000..8b2427f27 --- /dev/null +++ b/src/bin/plugins/stalePlugins.js @@ -0,0 +1,20 @@ +'use strict'; + +// Returns a list of stale plugins and their authors email + +const superagent = require('superagent'); +const currentTime = new Date(); + +(async() => { + const res = await superagent.get('https://static.etherpad.org/plugins.full.json'); + const plugins = JSON.parse(res.text); + for (const plugin of Object.keys(plugins)) { + const name = plugins[plugin].data.name; + const date = new Date(plugins[plugin].time); + const diffTime = Math.abs(currentTime - date); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + if (diffDays > (365*2)) { + console.log(`${name}, ${plugins[plugin].data.maintainers[0].email}`) + } + } +})(); 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/padurlsanitize.js b/src/node/hooks/express/padurlsanitize.js index 30297479a..b805fc4ba 100644 --- a/src/node/hooks/express/padurlsanitize.js +++ b/src/node/hooks/express/padurlsanitize.js @@ -18,7 +18,7 @@ exports.expressCreateServer = (hookName, args, cb) => { next(); } else { // the pad id was sanitized, so we redirect to the sanitized version - const realURL = encodeURIComponent(sanitizedPadId) + new URL(req.url).search; + const realURL = encodeURIComponent(sanitizedPadId) + new URL(req.url, 'http://invalid.invalid').search; res.header('Location', realURL); res.status(302).send(`You should be redirected to ${realURL}`); } 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/node/utils/Minify.js b/src/node/utils/Minify.js index 4874432b1..7969f9c14 100644 --- a/src/node/utils/Minify.js +++ b/src/node/utils/Minify.js @@ -123,6 +123,15 @@ const sanitizePathname = (p) => { return p; }; +const compatPaths = { + 'js/browser.js': 'js/vendors/browser.js', + 'js/farbtastic.js': 'js/vendors/farbtastic.js', + 'js/gritter.js': 'js/vendors/gritter.js', + 'js/html10n.js': 'js/vendors/html10n.js', + 'js/jquery.js': 'js/vendors/jquery.js', + 'js/nice-select.js': 'js/vendors/nice-select.js', +}; + /** * creates the minifed javascript for the given minified name * @param req the Express request @@ -139,11 +148,11 @@ const minify = async (req, res) => { return; } - // Backward compatibility for plugins that were written when jQuery lived at - // src/static/js/jquery.js. - if (['js/jquery.js', 'plugins/ep_etherpad-lite/static/js/jquery.js'].indexOf(filename) !== -1) { - logger.warn(`request for deprecated jQuery path: ${filename}`); - filename = 'js/vendors/jquery.js'; + // Backward compatibility for plugins that require() files from old paths. + const newLocation = compatPaths[filename.replace(/^plugins\/ep_etherpad-lite\/static\//, '')]; + if (newLocation != null) { + logger.warn(`request for deprecated path "${filename}", replacing with "${newLocation}"`); + filename = newLocation; } /* Handle static files for plugins/libraries: @@ -159,7 +168,7 @@ const minify = async (req, res) => { if (plugins.plugins[library] && match[3]) { const plugin = plugins.plugins[library]; const pluginPath = plugin.package.realPath; - filename = path.relative(ROOT_DIR, path.join(pluginPath, libraryPath)); + filename = path.join(pluginPath, libraryPath); // On Windows, path.relative converts forward slashes to backslashes. Convert them back // because some of the code below assumes forward slashes. Node.js treats both the backlash // and the forward slash characters as pathname component separators on Windows so this does @@ -211,44 +220,6 @@ const minify = async (req, res) => { } }; -// find all includes in ace.js and embed them. -const getAceFile = async () => { - let data = await fs.readFile(path.join(ROOT_DIR, 'js/ace.js'), 'utf8'); - - // Find all includes in ace.js and embed them - const filenames = []; - if (settings.minify) { - const regex = /\$\$INCLUDE_[a-zA-Z_]+\((['"])([^'"]*)\1\)/gi; - // This logic can be simplified via String.prototype.matchAll() once support for Node.js - // v11.x and older is dropped. - let matches; - while ((matches = regex.exec(data)) != null) { - filenames.push(matches[2]); - } - } - - data += 'Ace2Editor.EMBEDED = Ace2Editor.EMBEDED || {};\n'; - - // Request the contents of the included file on the server-side and write - // them into the file. - await Promise.all(filenames.map(async (filename) => { - // Hostname "invalid.invalid" is a dummy value to allow parsing as a URI. - const baseURI = 'http://invalid.invalid'; - let resourceURI = baseURI + path.join('/static/', filename); - resourceURI = resourceURI.replace(/\\/g, '/'); // Windows (safe generally?) - - const [status, , body] = await requestURI(resourceURI, 'GET', {}); - const error = !(status === 200 || status === 404); - if (!error) { - data += `Ace2Editor.EMBEDED[${JSON.stringify(filename)}] = ${ - JSON.stringify(status === 200 ? body || '' : null)};\n`; - } else { - console.error(`getAceFile(): error getting ${resourceURI}. Status code: ${status}`); - } - })); - return data; -}; - // Check for the existance of the file and get the last modification date. const statFile = async (filename, dirStatLimit) => { /* @@ -270,7 +241,7 @@ const statFile = async (filename, dirStatLimit) => { } else { let stats; try { - stats = await fs.stat(path.join(ROOT_DIR, filename)); + stats = await fs.stat(path.resolve(ROOT_DIR, filename)); } catch (err) { if (err.code === 'ENOENT') { // Stat the directory instead. @@ -357,9 +328,8 @@ const getFileCompressed = async (filename, contentType) => { }; const getFile = async (filename) => { - if (filename === 'js/ace.js') return await getAceFile(); if (filename === 'js/require-kernel.js') return requireDefinition(); - return await fs.readFile(path.join(ROOT_DIR, filename)); + return await fs.readFile(path.resolve(ROOT_DIR, filename)); }; exports.minify = (req, res, next) => minify(req, res).catch((err) => next(err || new Error(err))); diff --git a/src/node/utils/MinifyWorker.js b/src/node/utils/MinifyWorker.js index 31b5ec319..1ef6490ee 100644 --- a/src/node/utils/MinifyWorker.js +++ b/src/node/utils/MinifyWorker.js @@ -12,7 +12,7 @@ const compressJS = (content) => Terser.minify(content); const compressCSS = (filename, ROOT_DIR) => new Promise((res, rej) => { try { - const absPath = path.join(ROOT_DIR, filename); + const absPath = path.resolve(ROOT_DIR, filename); /* * Changes done to migrate CleanCSS 3.x -> 4.x: diff --git a/src/package-lock.json b/src/package-lock.json index f22457e10..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": { @@ -1413,9 +1413,9 @@ "dev": true }, "dirty": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/dirty/-/dirty-1.1.0.tgz", - "integrity": "sha1-cO3SuZlUHcmXT9Ooy9DGcP4jYHg=" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/dirty/-/dirty-1.1.1.tgz", + "integrity": "sha512-l/SMZcT+MjqOPpjarzJ8nQdxtxurURJM7js1l0Q2TQWtNbPzDYzkK++HlbT+XmM+adPFNdb3SOlVz9Jr7Df7xQ==" }, "doctrine": { "version": "3.0.0", @@ -1808,11 +1808,28 @@ } }, "eslint-config-etherpad": { - "version": "1.0.25", - "resolved": "https://registry.npmjs.org/eslint-config-etherpad/-/eslint-config-etherpad-1.0.25.tgz", - "integrity": "sha512-KYTGf08dlwvsg05Y2hm0zurCwVMyZrsxGRnPEhL2wclk26xhnPYfNfruQQqk7nghfWFLrAL+VscnkZLCQEPBXQ==", + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/eslint-config-etherpad/-/eslint-config-etherpad-1.0.26.tgz", + "integrity": "sha512-xPnDnJIpQuYJNRYGIHIucct0U6CtciyZKItpet+NqoGJgxUMkwAXgD5bzuXQvd9u4I2aj/kRU1BIL2DbAGe+pA==", "dev": true }, + "eslint-plugin-cypress": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.11.2.tgz", + "integrity": "sha512-1SergF1sGbVhsf7MYfOLiBhdOg6wqyeV9pXUAIDIffYTGMN3dTBQS9nFAzhLsHhO+Bn0GaVM1Ecm71XUidQ7VA==", + "dev": true, + "requires": { + "globals": "^11.12.0" + }, + "dependencies": { + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + } + } + }, "eslint-plugin-es": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz", @@ -2093,9 +2110,9 @@ } }, "express-rate-limit": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.2.3.tgz", - "integrity": "sha512-cjQH+oDrEPXxc569XvxhHC6QXqJiuBT6BhZ70X3bdAImcnHnTNMVuMAJaT0TXPoRiEErUrVPRcOTpZpM36VbOQ==" + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.2.5.tgz", + "integrity": "sha512-fv9mf4hWRKZHVlY8ChVNYnGxa49m0zQ6CrJxNiXe2IjJPqicrqoA/JOyBbvs4ufSSLZ6NTzhtgEyLcdfbe+Q6Q==" }, "express-session": { "version": "1.17.1", @@ -7522,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" } }, @@ -8419,14 +8436,14 @@ } }, "ueberdb2": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/ueberdb2/-/ueberdb2-1.3.1.tgz", - "integrity": "sha512-uhUSJfI5sNWdiXxae0kOg88scaMIKcV0CVeojwPQzgm93vQVuGyCqS1g1i3gTZel6SwmXRFtYtfmtAmiEe+HBQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/ueberdb2/-/ueberdb2-1.3.2.tgz", + "integrity": "sha512-7Ub5jDsIS+qjjsNV7yp1CHXHVe2K9ZUpwaHi9BZf3ai0DxtuHOfMada1wxL6iyEjwYXh/Nsu80iyId51wHFf4A==", "requires": { "async": "^3.2.0", "cassandra-driver": "^4.5.1", "channels": "0.0.4", - "dirty": "^1.1.0", + "dirty": "^1.1.1", "elasticsearch": "^16.7.1", "mongodb": "^3.6.3", "mssql": "^7.0.0-beta.2", diff --git a/src/package.json b/src/package.json index 6f3e51612..ada568d95 100644 --- a/src/package.json +++ b/src/package.json @@ -41,7 +41,7 @@ "etherpad-require-kernel": "1.0.9", "etherpad-yajsml": "0.0.4", "express": "4.17.1", - "express-rate-limit": "5.2.3", + "express-rate-limit": "5.2.5", "express-session": "1.17.1", "find-root": "1.1.0", "formidable": "1.2.2", @@ -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", @@ -69,7 +69,7 @@ "threads": "^1.4.0", "tiny-worker": "^2.3.0", "tinycon": "0.6.8", - "ueberdb2": "^1.3.1", + "ueberdb2": "^1.3.2", "underscore": "1.12.0", "unorm": "1.6.0", "wtfnode": "^0.8.4" @@ -79,7 +79,8 @@ }, "devDependencies": { "eslint": "^7.20.0", - "eslint-config-etherpad": "^1.0.25", + "eslint-config-etherpad": "^1.0.26", + "eslint-plugin-cypress": "^2.11.2", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-mocha": "^8.0.0", "eslint-plugin-node": "^11.1.0", @@ -155,9 +156,10 @@ ], "excludedFiles": [ "**/.eslintrc.js", - "tests/frontend/travis/**/*", + "tests/frontend/cypress/**/*", "tests/frontend/helper.js", "tests/frontend/helper/**/*", + "tests/frontend/travis/**/*", "tests/ratelimit/**/*" ], "extends": "etherpad/tests", @@ -195,6 +197,7 @@ ], "excludedFiles": [ "**/.eslintrc.js", + "tests/frontend/cypress/**/*", "tests/frontend/helper.js", "tests/frontend/helper/**/*", "tests/frontend/travis/**/*" @@ -215,6 +218,12 @@ } ] }, + { + "files": [ + "tests/frontend/cypress/**/*" + ], + "extends": "etherpad/tests/cypress" + }, { "files": [ "tests/frontend/travis/**/*" @@ -237,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/js/ace.js b/src/static/js/ace.js index 4038096ee..44556dd0f 100644 --- a/src/static/js/ace.js +++ b/src/static/js/ace.js @@ -27,16 +27,20 @@ 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 scriptTag = (source) => ``; const Ace2Editor = function () { - const ace2 = Ace2Editor; - - let info = { - editor: this, - id: (ace2.registry.nextId++), - }; + let info = {editor: this}; + window.ace2EditorInfo = info; // Make it accessible to iframes. let loaded = false; let actionsPendingInit = []; @@ -52,8 +56,6 @@ const Ace2Editor = function () { actionsPendingInit = []; }; - ace2.registry[info.id] = info; - // The following functions (prefixed by 'ace_') are exposed by editor, but // execution is delayed until init is complete const aceFunctionsPendingInit = [ @@ -89,8 +91,6 @@ const Ace2Editor = function () { this.exportText = () => loaded ? info.ace_exportText() : '(awaiting init)\n'; - this.getFrame = () => info.frame || null; - this.getDebugProperty = (prop) => info.ace_getDebugProperty(prop); this.getInInternationalComposition = @@ -109,207 +109,140 @@ const Ace2Editor = function () { // returns array of {error: , time: +new Date()} this.getUnhandledErrors = () => loaded ? info.ace_getUnhandledErrors() : []; - const sortFilesByEmbeded = (files) => { - const embededFiles = []; - let remoteFiles = []; - - if (Ace2Editor.EMBEDED) { - for (let i = 0, ii = files.length; i < ii; i++) { - const file = files[i]; - if (Object.prototype.hasOwnProperty.call(Ace2Editor.EMBEDED, file)) { - embededFiles.push(file); - } else { - remoteFiles.push(file); - } - } - } else { - remoteFiles = files; - } - - return {embeded: embededFiles, remote: remoteFiles}; - }; - const pushStyleTagsFor = (buffer, files) => { - const sorted = sortFilesByEmbeded(files); - const embededFiles = sorted.embeded; - const remoteFiles = sorted.remote; - - if (embededFiles.length > 0) { - buffer.push(''); - } - for (const file of remoteFiles) { - buffer.push(``); + for (const file of files) { + buffer.push(``); } }; this.destroy = pendingInit(() => { info.ace_dispose(); info.frame.parentNode.removeChild(info.frame); - delete ace2.registry[info.id]; + delete window.ace2EditorInfo; info = null; // prevent IE 6 closure memory leaks }); - this.init = function (containerId, initialCode, doneFunc) { + this.init = async function (containerId, initialCode) { + debugLog('Ace2Editor.init()'); this.importText(initialCode); - info.onEditorReady = () => { - loaded = true; - doActionsPendingInit(); - doneFunc(); - }; + const includedCSS = [ + '../static/css/iframe_editor.css', + `../static/css/pad.css?v=${clientVars.randomVersionString}`, + ...hooks.callAll('aceEditorCSS').map( + // Allow urls to external CSS - http(s):// and //some/path.css + (p) => /\/\//.test(p) ? p : `../static/plugins/${p}`), + `../static/skins/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`, + ]; - (() => { - const doctype = ''; + const doctype = ''; - const iframeHTML = []; + const iframeHTML = []; - iframeHTML.push(doctype); - iframeHTML.push(``); + 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(``); + } - // calls to these functions ($$INCLUDE_...) are replaced when this file is processed - // and compressed, putting the compressed code from the named file directly into the - // source here. - // these lines must conform to a specific format because they are passed by the build script: - let includedCSS = []; - let $$INCLUDE_CSS = (filename) => { includedCSS.push(filename); }; - $$INCLUDE_CSS('../static/css/iframe_editor.css'); + 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'); - // disableCustomScriptsAndStyles can be used to disable loading of custom scripts - if (!clientVars.disableCustomScriptsAndStyles) { - $$INCLUDE_CSS(`../static/css/pad.css?v=${clientVars.randomVersionString}`); - } + // 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); - let additionalCSS = hooks.callAll('aceEditorCSS').map((path) => { - if (path.match(/\/\//)) { // Allow urls to external CSS - http(s):// and //some/path.css - return path; - } - return `../static/plugins/${path}`; - }); - includedCSS = includedCSS.concat(additionalCSS); - $$INCLUDE_CSS( - `../static/skins/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`); + window.$ = window.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery; - pushStyleTagsFor(iframeHTML, includedCSS); - iframeHTML.push(``); - // fill the cache - iframeHTML.push(``); - iframeHTML.push(``); + 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(scriptTag( - `\n\ -require.setRootURI("../javascripts/src");\n\ -require.setLibraryURI("../javascripts/lib");\n\ -require.setGlobalKeyPath("require");\n\ -\n\ -// intentially moved before requiring client_plugins to save a 307 -var Ace2Inner = require("ep_etherpad-lite/static/js/ace2_inner");\n\ -var plugins = require("ep_etherpad-lite/static/js/pluginfw/client_plugins");\n\ -plugins.adoptPluginsFromAncestorsOf(window);\n\ -\n\ -$ = jQuery = require("ep_etherpad-lite/static/js/rjquery").jQuery; // Expose jQuery #HACK\n\ -\n\ -plugins.ensure(function () {\n\ - Ace2Inner.init();\n\ -});\n\ -`)); + iframeHTML.push(''); - iframeHTML.push(''); + hooks.callAll('aceInitInnerdocbodyHead', { + iframeHTML, + }); - hooks.callAll('aceInitInnerdocbodyHead', { - iframeHTML, - }); + iframeHTML.push(' '); - 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'); + })();`; - // eslint-disable-next-line node/no-unsupported-features/es-builtins - const gt = typeof globalThis === 'object' ? globalThis : window; - gt.ChildAccessibleAce2Editor = Ace2Editor; + const outerHTML = + [doctype, ``]; + pushStyleTagsFor(outerHTML, includedCSS); - const outerScript = `\ -editorId = ${JSON.stringify(info.id)};\n\ -editorInfo = parent.ChildAccessibleAce2Editor.registry[editorId];\n\ -window.onload = function () {\n\ - window.onload = null;\n\ - setTimeout(function () {\n\ - var iframe = document.createElement("IFRAME");\n\ - iframe.name = "ace_inner";\n\ - iframe.title = "pad";\n\ - iframe.scrolling = "no";\n\ - var outerdocbody = document.getElementById("outerdocbody");\n\ - iframe.frameBorder = 0;\n\ - iframe.allowTransparency = true; // for IE\n\ - outerdocbody.insertBefore(iframe, outerdocbody.firstChild);\n\ - iframe.ace_outerWin = window;\n\ - readyFunc = function () {\n\ - editorInfo.onEditorReady();\n\ - readyFunc = null;\n\ - editorInfo = null;\n\ - };\n\ - var doc = iframe.contentWindow.document;\n\ - doc.open();\n\ - var text = (${JSON.stringify(iframeHTML.join('\n'))});\n\ - doc.write(text);\n\ - doc.close();\n\ - }, 0);\n\ -}`; + // 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 outerHTML = - [doctype, ``]; + 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); - includedCSS = []; - $$INCLUDE_CSS = (filename) => { includedCSS.push(filename); }; - $$INCLUDE_CSS('../static/css/iframe_editor.css'); - $$INCLUDE_CSS(`../static/css/pad.css?v=${clientVars.randomVersionString}`); - - - additionalCSS = hooks.callAll('aceEditorCSS').map((path) => { - if (path.match(/\/\//)) { // Allow urls to external CSS - http(s):// and //some/path.css - return path; - } - return `../static/plugins/${path}`; - }); - includedCSS = includedCSS.concat(additionalCSS); - $$INCLUDE_CSS( - `../static/skins/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`); - - 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 editorDocument = outerFrame.contentWindow.document; + const editorDocument = outerFrame.contentWindow.document; + debugLog('Ace2Editor.init() waiting for outer frame'); + 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'); }; }; -Ace2Editor.registry = { - nextId: 1, -}; - exports.Ace2Editor = Ace2Editor; diff --git a/src/static/js/ace2_inner.js b/src/static/js/ace2_inner.js index 4858aa1b4..8fef627b5 100644 --- a/src/static/js/ace2_inner.js +++ b/src/static/js/ace2_inner.js @@ -30,7 +30,7 @@ const htmlPrettyEscape = Ace2Common.htmlPrettyEscape; const noop = Ace2Common.noop; const hooks = require('./pluginfw/hooks'); -function Ace2Inner() { +function Ace2Inner(editorInfo) { const makeChangesetTracker = require('./changesettracker').makeChangesetTracker; const colorutils = require('./colorutils').colorutils; const makeContentCollector = require('./contentcollector').makeContentCollector; @@ -57,7 +57,6 @@ function Ace2Inner() { let thisAuthor = ''; let disposed = false; - const editorInfo = parent.editorInfo; const focus = () => { window.focus(); @@ -3894,9 +3893,9 @@ function Ace2Inner() { documentAttributeManager = new AttributeManager(rep, performDocumentApplyChangeset); editorInfo.ace_performDocumentApplyAttributesToRange = - (...args) => documentAttributeManager.setAttributesOnRange(args); + (...args) => documentAttributeManager.setAttributesOnRange(...args); - this.init = () => { + this.init = (cb) => { $(document).ready(() => { doc = document; // defined as a var in scope outside inCallStack('setup', () => { @@ -3928,14 +3927,12 @@ function Ace2Inner() { documentAttributeManager, }); - scheduler.setTimeout(() => { - parent.readyFunc(); // defined in code that sets up the inner iframe - }, 0); + scheduler.setTimeout(cb, 0); }); }; } -exports.init = () => { - const editor = new Ace2Inner(); - editor.init(); +exports.init = (editorInfo, cb) => { + const editor = new Ace2Inner(editorInfo); + editor.init(cb); }; 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/pad_editor.js b/src/static/js/pad_editor.js index a32af1644..75481fe5e 100644 --- a/src/static/js/pad_editor.js +++ b/src/static/js/pad_editor.js @@ -56,7 +56,8 @@ const padeditor = (() => { }; self.ace = new Ace2Editor(); - self.ace.init('editorcontainer', '', aceReady); + self.ace.init('editorcontainer', '').then( + () => aceReady(), (err) => { throw err || new Error(err); }); self.ace.setProperty('wraps', true); if (pad.getIsDebugEnabled()) { self.ace.setProperty('dmesg', pad.dmesg); 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/templates/pad.html b/src/templates/pad.html index 39815c720..26243806a 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -170,7 +170,7 @@

About

Powered by - Etherpad-lite + Etherpad <% if (settings.exposeVersion) { %>(commit <%=settings.getGitCommit()%>)<% } %> diff --git a/src/tests/backend/specs/pads-with-spaces.js b/src/tests/backend/specs/pads-with-spaces.js new file mode 100644 index 000000000..0db99865b --- /dev/null +++ b/src/tests/backend/specs/pads-with-spaces.js @@ -0,0 +1,24 @@ +'use strict'; + +const common = require('../common'); +const assert = require('../assert-legacy').strict; + +let agent; + +describe(__filename, function () { + before(async function () { + agent = await common.init(); + }); + + it('supports pads with spaces, regression test for #4883', async function () { + await agent.get('/p/pads with spaces') + .expect(302) + .expect('location', 'pads_with_spaces'); + }); + + it('supports pads with spaces and query, regression test for #4883', async function () { + await agent.get('/p/pads with spaces?showChat=true&noColors=false') + .expect(302) + .expect('location', 'pads_with_spaces?showChat=true&noColors=false'); + }); +}); diff --git a/src/tests/frontend/cypress/.gitignore b/src/tests/frontend/cypress/.gitignore new file mode 100644 index 000000000..b3bf4d3e2 --- /dev/null +++ b/src/tests/frontend/cypress/.gitignore @@ -0,0 +1,5 @@ +fixtures/* +plugins/* +support/* +videos/* +screenshots/* diff --git a/src/tests/frontend/cypress/README.md b/src/tests/frontend/cypress/README.md new file mode 100644 index 000000000..4cc3f8121 --- /dev/null +++ b/src/tests/frontend/cypress/README.md @@ -0,0 +1,10 @@ +# Cypress Etherpad guide +We don't install Etherpad as a dev dep or dep within Etherpad because it's not +our core Frontend testing tool + +## Quick start +``` +npm i -g cypress +cd src/tests/frontend/cypress/ +cypress open +``` diff --git a/src/tests/frontend/cypress/cypress.json b/src/tests/frontend/cypress/cypress.json new file mode 100644 index 000000000..780d73fae --- /dev/null +++ b/src/tests/frontend/cypress/cypress.json @@ -0,0 +1,3 @@ +{ + "baseUrl": "http://127.0.0.1:9001" +} diff --git a/src/tests/frontend/cypress/integration/test.js b/src/tests/frontend/cypress/integration/test.js new file mode 100644 index 000000000..893d4b669 --- /dev/null +++ b/src/tests/frontend/cypress/integration/test.js @@ -0,0 +1,23 @@ +'use strict'; + +Cypress.Commands.add('iframe', {prevSubject: 'element'}, + ($iframe) => new Cypress.Promise((resolve) => { + $iframe.ready(() => { + resolve($iframe.contents().find('body')); + }); + })); + +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'); + cy.get('iframe[name="ace_outer"]').iframe() + .find('iframe[name="ace_inner"]').iframe() + .find('.ace-line:first') + .should('be.visible') + .should('have.text', 'Welcome to Etherpad!'); + }); +}); diff --git a/src/tests/frontend/travis/runnerLoadTest.sh b/src/tests/frontend/travis/runnerLoadTest.sh index 3fce737bc..8a813c20a 100755 --- a/src/tests/frontend/travis/runnerLoadTest.sh +++ b/src/tests/frontend/travis/runnerLoadTest.sh @@ -42,7 +42,9 @@ try curl http://localhost:9001/p/minifyme -f -s >/dev/null sleep 10 log "Running the load tests..." -etherpad-loadtest -d 25 +# -d is duration of test, -a is number of authors to test with +# by specifying the number of authors we set the overall rate of messages +etherpad-loadtest -d $1 -a $2 exit_code=$? kill "$ep_pid" && wait "$ep_pid"