mirror of
https://github.com/ether/etherpad-lite.git
synced 2025-04-21 07:56:16 -04:00
Merge branch 'develop'
This commit is contained in:
commit
b1c34b5dac
90 changed files with 13938 additions and 10920 deletions
12
.github/workflows/frontend-admin-tests.yml
vendored
12
.github/workflows/frontend-admin-tests.yml
vendored
|
@ -8,6 +8,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
withplugins:
|
||||
if: ${{ github.actor != 'dependabot[bot]' }}
|
||||
name: with plugins
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
@ -17,17 +18,6 @@ jobs:
|
|||
node: [16, 18, 20]
|
||||
|
||||
steps:
|
||||
-
|
||||
name: Fail if Dependabot
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
run: |
|
||||
cat <<EOF >&2
|
||||
Frontend tests skipped because Dependabot can't access secrets.
|
||||
Manually re-run the jobs to run the frontend tests.
|
||||
For more information, see:
|
||||
https://github.blog/changelog/2021-02-19-github-actions-workflows-triggered-by-dependabot-prs-will-run-with-read-only-permissions/
|
||||
EOF
|
||||
exit 1
|
||||
-
|
||||
name: Generate Sauce Labs strings
|
||||
id: sauce_strings
|
||||
|
|
26
.github/workflows/frontend-tests.yml
vendored
26
.github/workflows/frontend-tests.yml
vendored
|
@ -10,18 +10,9 @@ jobs:
|
|||
withoutplugins:
|
||||
name: without plugins
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.actor != 'dependabot[bot]' }}
|
||||
|
||||
steps:
|
||||
-
|
||||
name: Fail if Dependabot
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
run: |
|
||||
cat <<EOF >&2
|
||||
Frontend tests skipped because Dependabot can't access secrets.
|
||||
Manually re-run the jobs to run the frontend tests.
|
||||
For more information, see:
|
||||
https://github.blog/changelog/2021-02-19-github-actions-workflows-triggered-by-dependabot-prs-will-run-with-read-only-permissions/
|
||||
EOF
|
||||
exit 1
|
||||
-
|
||||
name: Generate Sauce Labs strings
|
||||
id: sauce_strings
|
||||
|
@ -74,18 +65,9 @@ jobs:
|
|||
withplugins:
|
||||
name: with plugins
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.actor != 'dependabot[bot]' }}
|
||||
|
||||
steps:
|
||||
-
|
||||
name: Fail if Dependabot
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
run: |
|
||||
cat <<EOF >&2
|
||||
Frontend tests skipped because Dependabot can't access secrets.
|
||||
Manually re-run the jobs to run the frontend tests.
|
||||
For more information, see:
|
||||
https://github.blog/changelog/2021-02-19-github-actions-workflows-triggered-by-dependabot-prs-will-run-with-read-only-permissions/
|
||||
EOF
|
||||
exit 1
|
||||
-
|
||||
name: Generate Sauce Labs strings
|
||||
id: sauce_strings
|
||||
|
|
17
.github/workflows/windows.yml
vendored
17
.github/workflows/windows.yml
vendored
|
@ -15,13 +15,19 @@ jobs:
|
|||
|| (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)
|
||||
name: Build .zip
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: msys2 {0}
|
||||
steps:
|
||||
-
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
release: false
|
||||
update: false
|
||||
path-type: inherit
|
||||
install: >-
|
||||
zip
|
||||
rsync
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
@ -35,16 +41,17 @@ jobs:
|
|||
src/bin/doc/package-lock.json
|
||||
-
|
||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
shell: msys2 {0}
|
||||
run: src/bin/installDeps.sh
|
||||
run: |
|
||||
set MSYSTEM=winsymlinks:lnk
|
||||
src/bin/installDeps.sh
|
||||
-
|
||||
name: Run the backend tests
|
||||
shell: msys2 {0}
|
||||
run: cd src && npm test
|
||||
-
|
||||
name: Build the .zip
|
||||
shell: msys2 {0}
|
||||
run: src/bin/buildForWindows.sh
|
||||
run: |
|
||||
set MSYSTEM=winsymlinks:lnk
|
||||
src/bin/buildForWindows.sh
|
||||
-
|
||||
name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
|
|
28
CHANGELOG.md
28
CHANGELOG.md
|
@ -1,3 +1,26 @@
|
|||
# 1.9.2
|
||||
|
||||
### Notable enhancements and fixes
|
||||
|
||||
* Security
|
||||
* Enable session key rotation: This setting can be enabled in the settings.json. It changes the signing key for the cookie authentication in a fixed interval.
|
||||
|
||||
* Bugfixes
|
||||
* Fix appendRevision when creating a new pad via the API without a text.
|
||||
|
||||
|
||||
* Enhancements
|
||||
* Bump JQuery to version 3.7
|
||||
* Update elasticsearch connector to version 8
|
||||
|
||||
### Compatibility changes
|
||||
|
||||
* No compability changes as JQuery maintains excellent backwards compatibility.
|
||||
|
||||
#### For plugin authors
|
||||
|
||||
* Please update to JQuery 3.7. There is an excellent deprecation guide over [here](https://api.jquery.com/category/deprecated/). Version 3.1 to 3.7 are relevant for the upgrade.
|
||||
|
||||
# 1.9.1
|
||||
|
||||
### Notable enhancements and fixes
|
||||
|
@ -37,6 +60,11 @@
|
|||
session expires (with some exceptions that will be fixed in the future).
|
||||
* Requests for static content (e.g., `/robots.txt`) and special pages (e.g.,
|
||||
the HTTP API, `/stats`) no longer create login session state.
|
||||
* The secret used to sign the `express_sid` cookie is now automatically
|
||||
regenerated every day (called *key rotation*) by default. If key rotation is
|
||||
enabled, the now-deprecated `SESSIONKEY.txt` file can be safely deleted
|
||||
after Etherpad starts up (its content is read and saved to the database and
|
||||
used to validate signatures from old cookies until they expire).
|
||||
* The following settings from `settings.json` are now applied as expected (they
|
||||
were unintentionally ignored before):
|
||||
* `padOptions.lang`
|
||||
|
|
28
Dockerfile
28
Dockerfile
|
@ -4,15 +4,18 @@
|
|||
#
|
||||
# Author: muxator
|
||||
|
||||
FROM node:lts-slim
|
||||
FROM node:lts-alpine
|
||||
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad-lite"
|
||||
|
||||
ARG TIMEZONE=
|
||||
|
||||
RUN \
|
||||
[ -z "${TIMEZONE}" ] || { \
|
||||
ln -sf /usr/share/zoneinfo/"${TIMEZONE#/usr/share/zoneinfo/}" /etc/localtime; \
|
||||
dpkg-reconfigure -f noninteractive tzdata; \
|
||||
apk add --no-cache tzdata && \
|
||||
cp /usr/share/zoneinfo/${TIMEZONE} /etc/localtime && \
|
||||
echo "${TIMEZONE}" > /etc/timezone; \
|
||||
}
|
||||
ENV TIMEZONE=${TIMEZONE}
|
||||
|
||||
# plugins to install while building the container. By default no plugins are
|
||||
# installed.
|
||||
|
@ -42,7 +45,9 @@ ARG INSTALL_SOFFICE=
|
|||
# leaner (development dependencies are not installed) and runs faster (among
|
||||
# other things, assets are minified & compressed).
|
||||
ENV NODE_ENV=production
|
||||
|
||||
ENV ETHERPAD_PRODUCTION=true
|
||||
# Install dependencies required for modifying access.
|
||||
RUN apk add shadow bash
|
||||
# Follow the principle of least privilege: run as unprivileged user.
|
||||
#
|
||||
# Running as non-root enables running this image in platforms like OpenShift
|
||||
|
@ -54,6 +59,9 @@ ARG EP_HOME=
|
|||
ARG EP_UID=5001
|
||||
ARG EP_GID=0
|
||||
ARG EP_SHELL=
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
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 \
|
||||
|
@ -64,18 +72,14 @@ RUN mkdir -p "${EP_DIR}" && chown etherpad:etherpad "${EP_DIR}"
|
|||
|
||||
# the mkdir is needed for configuration of openjdk-11-jre-headless, see
|
||||
# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=863199
|
||||
RUN export DEBIAN_FRONTEND=noninteractive; \
|
||||
RUN \
|
||||
mkdir -p /usr/share/man/man1 && \
|
||||
apt-get -qq update && \
|
||||
apt-get -qq dist-upgrade && \
|
||||
apt-get -qq --no-install-recommends install \
|
||||
apk update && apk upgrade && \
|
||||
apk add \
|
||||
ca-certificates \
|
||||
git \
|
||||
${INSTALL_ABIWORD:+abiword} \
|
||||
${INSTALL_SOFFICE:+libreoffice default-jre libreoffice-java-common} \
|
||||
&& \
|
||||
apt-get -qq clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
${INSTALL_SOFFICE:+libreoffice openjdk8-jre libreoffice-common}
|
||||
|
||||
USER etherpad
|
||||
|
||||
|
|
|
@ -370,6 +370,10 @@ For the editor container, you can also make it full width by adding `full-width-
|
|||
| Description
|
||||
| Default
|
||||
|
||||
|`COOKIE_KEY_ROTATION_INTERVAL`
|
||||
|How often (ms) to rotate in a new secret for signing cookies
|
||||
|`86400000` (1 day)
|
||||
|
||||
| `COOKIE_SAME_SITE`
|
||||
| Value of the SameSite cookie property.
|
||||
| `"Lax"`
|
||||
|
|
0
doc/docker.md
Normal file
0
doc/docker.md
Normal file
|
@ -363,6 +363,23 @@
|
|||
* Settings controlling the session cookie issued by Etherpad.
|
||||
*/
|
||||
"cookie": {
|
||||
/*
|
||||
* How often (in milliseconds) the key used to sign the express_sid cookie
|
||||
* should be rotated. Long rotation intervals reduce signature verification
|
||||
* overhead (because there are fewer historical keys to check) and database
|
||||
* load (fewer historical keys to store, and less frequent queries to
|
||||
* get/update the keys). Short rotation intervals are slightly more secure.
|
||||
*
|
||||
* Multiple Etherpad processes sharing the same database (table) is
|
||||
* supported as long as the clock sync error is significantly less than this
|
||||
* value.
|
||||
*
|
||||
* Key rotation can be disabled (not recommended) by setting this to 0 or
|
||||
* null, or by disabling session expiration (see sessionLifetime).
|
||||
*/
|
||||
// 86400000 = 1d * 24h/d * 60m/h * 60s/m * 1000ms/s
|
||||
"keyRotationInterval": "${COOKIE_KEY_ROTATION_INTERVAL:86400000}",
|
||||
|
||||
/*
|
||||
* Value of the SameSite cookie property. "Lax" is recommended unless
|
||||
* Etherpad will be embedded in an iframe from another site, in which case
|
||||
|
@ -392,6 +409,8 @@
|
|||
* indefinitely without consulting authentication or authorization
|
||||
* hooks, so once a user has accessed a pad, the user can continue to
|
||||
* use the pad until the user leaves for longer than sessionLifetime.
|
||||
* - More historical keys (sessionLifetime / keyRotationInterval) must be
|
||||
* checked when verifying signatures.
|
||||
*
|
||||
* Session lifetime can be set to infinity (not recommended) by setting this
|
||||
* to null or 0. Note that if the session does not expire, most browsers
|
||||
|
@ -634,5 +653,10 @@
|
|||
"customLocaleStrings": {},
|
||||
|
||||
/* Disable Admin UI tests */
|
||||
"enableAdminUITests": false
|
||||
"enableAdminUITests": false,
|
||||
|
||||
/*
|
||||
* Enable/Disable case-insensitive pad names.
|
||||
*/
|
||||
"lowerCasePadIds": "${LOWER_CASE_PAD_IDS:false}"
|
||||
}
|
||||
|
|
|
@ -364,6 +364,22 @@
|
|||
* Settings controlling the session cookie issued by Etherpad.
|
||||
*/
|
||||
"cookie": {
|
||||
/*
|
||||
* How often (in milliseconds) the key used to sign the express_sid cookie
|
||||
* should be rotated. Long rotation intervals reduce signature verification
|
||||
* overhead (because there are fewer historical keys to check) and database
|
||||
* load (fewer historical keys to store, and less frequent queries to
|
||||
* get/update the keys). Short rotation intervals are slightly more secure.
|
||||
*
|
||||
* Multiple Etherpad processes sharing the same database (table) is
|
||||
* supported as long as the clock sync error is significantly less than this
|
||||
* value.
|
||||
*
|
||||
* Key rotation can be disabled (not recommended) by setting this to 0 or
|
||||
* null, or by disabling session expiration (see sessionLifetime).
|
||||
*/
|
||||
"keyRotationInterval": 86400000, // = 1d * 24h/d * 60m/h * 60s/m * 1000ms/s
|
||||
|
||||
/*
|
||||
* Value of the SameSite cookie property. "Lax" is recommended unless
|
||||
* Etherpad will be embedded in an iframe from another site, in which case
|
||||
|
@ -393,6 +409,8 @@
|
|||
* indefinitely without consulting authentication or authorization
|
||||
* hooks, so once a user has accessed a pad, the user can continue to
|
||||
* use the pad until the user leaves for longer than sessionLifetime.
|
||||
* - More historical keys (sessionLifetime / keyRotationInterval) must be
|
||||
* checked when verifying signatures.
|
||||
*
|
||||
* Session lifetime can be set to infinity (not recommended) by setting this
|
||||
* to null or 0. Note that if the session does not expire, most browsers
|
||||
|
@ -635,5 +653,10 @@
|
|||
"customLocaleStrings": {},
|
||||
|
||||
/* Disable Admin UI tests */
|
||||
"enableAdminUITests": false
|
||||
"enableAdminUITests": false,
|
||||
|
||||
/*
|
||||
* Enable/Disable case-insensitive pad names.
|
||||
*/
|
||||
"lowerCasePadIds": false
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ try cd "${workdir}"
|
|||
[ -f src/package.json ] || fatal "failed to cd to etherpad root directory"
|
||||
|
||||
# See https://github.com/msys2/MSYS2-packages/issues/1216
|
||||
export MSYS=winsymlinks:lnk
|
||||
export MSYSTEM=winsymlinks:lnk
|
||||
|
||||
OUTPUT=${workdir}/etherpad-win.zip
|
||||
|
||||
|
@ -29,10 +29,12 @@ trap 'exit 1' HUP INT TERM
|
|||
trap 'log "cleaning up..."; try cd / && try rm -rf "${TMP_FOLDER}"' EXIT
|
||||
|
||||
log "create a clean environment in $TMP_FOLDER..."
|
||||
try git archive --format=tar HEAD | (try cd "${TMP_FOLDER}" && try tar xf -) \
|
||||
try export GIT_WORK_TREE=${TMP_FOLDER}; git checkout HEAD -f \
|
||||
|| fatal "failed to copy etherpad to temporary folder"
|
||||
try mkdir "${TMP_FOLDER}"/.git
|
||||
try git rev-parse HEAD >${TMP_FOLDER}/.git/HEAD
|
||||
try cp -r ./src/node_modules "${TMP_FOLDER}"/src/node_modules
|
||||
|
||||
try cd "${TMP_FOLDER}"
|
||||
[ -f src/package.json ] || fatal "failed to copy etherpad to temporary folder"
|
||||
|
||||
|
|
6
src/bin/doc/package-lock.json
generated
6
src/bin/doc/package-lock.json
generated
|
@ -5,9 +5,9 @@
|
|||
"requires": true,
|
||||
"dependencies": {
|
||||
"marked": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-5.1.0.tgz",
|
||||
"integrity": "sha512-z3/nBe7aTI8JDszlYLk7dDVNpngjw0o1ZJtrA9kIfkkHcIF+xH7mO23aISl4WxP83elU+MFROgahqdpd05lMEQ=="
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-7.0.3.tgz",
|
||||
"integrity": "sha512-ev2uM40p0zQ/GbvqotfKcSWEa59fJwluGZj5dcaUOwDRrB1F3dncdXy8NWUApk4fi8atU3kTBOwjyjZ0ud0dxw=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
"node": ">=12.17.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^5.1.0"
|
||||
"marked": "^7.0.3"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"optionalDependencies": {},
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
#!/bin/sh
|
||||
|
||||
|
||||
# Move to the Etherpad base directory.
|
||||
MY_DIR=$(cd "${0%/*}" && pwd -P) || exit 1
|
||||
cd "${MY_DIR}/../.." || exit 1
|
||||
|
@ -36,14 +37,22 @@ if [ ! -f "$settings" ]; then
|
|||
cp settings.json.template "$settings" || exit 1
|
||||
fi
|
||||
|
||||
|
||||
log "Installing dependencies..."
|
||||
(
|
||||
mkdir -p node_modules &&
|
||||
cd node_modules &&
|
||||
{ [ -d ep_etherpad-lite ] || ln -sf ../src ep_etherpad-lite; } &&
|
||||
cd ep_etherpad-lite &&
|
||||
npm ci --no-optional
|
||||
) || exit 1
|
||||
(mkdir -p node_modules &&
|
||||
cd node_modules &&
|
||||
{ [ -d ep_etherpad-lite ] || ln -sf ../src ep_etherpad-lite; } &&
|
||||
cd ep_etherpad-lite)
|
||||
|
||||
cd src
|
||||
|
||||
if [ -z "${ETHERPAD_PRODUCTION}" ]; then
|
||||
log "Installing dev dependencies"
|
||||
npm ci --no-optional --omit=optional --include=dev --lockfile-version 1 || exit 1
|
||||
else
|
||||
log "Installing production dependencies"
|
||||
npm ci --no-optional --omit=optional --omit=dev --lockfile-version 1 --production || exit 1
|
||||
fi
|
||||
|
||||
# Remove all minified data to force node creating it new
|
||||
log "Clearing minified cache..."
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
"Mklehr",
|
||||
"Nipsky",
|
||||
"Predatorix",
|
||||
"SamTV",
|
||||
"Sebastian Wallroth",
|
||||
"Thargon",
|
||||
"Tim.krieger",
|
||||
|
@ -17,7 +18,7 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "Admin Dashboard - Etherpad",
|
||||
"admin_plugins": "Plugins verwalten",
|
||||
"admin_plugins": "Pluginverwaltung",
|
||||
"admin_plugins.available": "Verfügbare Plugins",
|
||||
"admin_plugins.available_not-found": "Keine Plugins gefunden.",
|
||||
"admin_plugins.available_fetching": "Wird abgerufen...",
|
||||
|
@ -40,12 +41,12 @@
|
|||
"admin_plugins_info.plugins": "Installierte Plugins",
|
||||
"admin_plugins_info.page-title": "Plugin Informationen - Etherpad",
|
||||
"admin_plugins_info.version": "Etherpad Version",
|
||||
"admin_plugins_info.version_latest": "Neueste Version",
|
||||
"admin_plugins_info.version_latest": "Neueste verfügbare Version",
|
||||
"admin_plugins_info.version_number": "Versionsnummer",
|
||||
"admin_settings": "Einstellungen",
|
||||
"admin_settings.current": "Derzeitige Konfiguration",
|
||||
"admin_settings.current_example-devel": "Beispielhafte Entwicklungseinstellungs-Templates",
|
||||
"admin_settings.current_example-prod": "Beispiel einer Vorlage für Produktionseinstellungen",
|
||||
"admin_settings.current_example-prod": "Beispiel eines produktiven Templates",
|
||||
"admin_settings.current_restart.value": "Etherpad neustarten",
|
||||
"admin_settings.current_save.value": "Einstellungen speichern",
|
||||
"admin_settings.page-title": "Einstellungen - Etherpad",
|
||||
|
@ -71,9 +72,9 @@
|
|||
"pad.toolbar.showusers.title": "Benutzer dieses Pads anzeigen",
|
||||
"pad.colorpicker.save": "Speichern",
|
||||
"pad.colorpicker.cancel": "Abbrechen",
|
||||
"pad.loading": "Lade …",
|
||||
"pad.loading": "Laden …",
|
||||
"pad.noCookie": "Das Cookie konnte nicht gefunden werden. Bitte erlaube Cookies in deinem Browser! Deine Sitzung und Einstellungen werden zwischen den Besuchen nicht gespeichert. Dies kann darauf zurückzuführen sein, dass Etherpad in einigen Browsern in einem iFrame enthalten ist. Bitte stelle sicher, dass sich Etherpad auf der gleichen Subdomain/Domain wie der übergeordnete iFrame befindet.",
|
||||
"pad.permissionDenied": "Du hast keine Berechtigung, um auf dieses Pad zuzugreifen",
|
||||
"pad.permissionDenied": "Du hast keine Berechtigung, um auf dieses Pad zuzugreifen.",
|
||||
"pad.settings.padSettings": "Pad-Einstellungen",
|
||||
"pad.settings.myView": "Eigene Ansicht",
|
||||
"pad.settings.stickychat": "Unterhaltung immer anzeigen",
|
||||
|
@ -85,7 +86,7 @@
|
|||
"pad.settings.fontType.normal": "Normal",
|
||||
"pad.settings.language": "Sprache:",
|
||||
"pad.settings.about": "Über",
|
||||
"pad.settings.poweredBy": "Powered by",
|
||||
"pad.settings.poweredBy": "Betrieben von",
|
||||
"pad.importExport.import_export": "Import/Export",
|
||||
"pad.importExport.import": "Textdatei oder Dokument hochladen",
|
||||
"pad.importExport.importSuccessful": "Erfolgreich!",
|
||||
|
@ -120,7 +121,7 @@
|
|||
"pad.modals.corruptPad.cause": "Dies könnte an einer falschen Serverkonfiguration oder einem anderen unerwarteten Verhalten liegen. Bitte kontaktiere den Administrator dieses Dienstes.",
|
||||
"pad.modals.deleted": "Gelöscht.",
|
||||
"pad.modals.deleted.explanation": "Dieses Pad wurde entfernt.",
|
||||
"pad.modals.rateLimited": "Begrenzte Rate.",
|
||||
"pad.modals.rateLimited": "Durchsatzratenbegrenzt",
|
||||
"pad.modals.rateLimited.explanation": "Sie haben zu viele Nachrichten an dieses Pad gesendet, so dass die Verbindung unterbrochen wurde.",
|
||||
"pad.modals.rejected.explanation": "Der Server hat eine Nachricht abgelehnt, die von deinem Browser gesendet wurde.",
|
||||
"pad.modals.rejected.cause": "Möglicherweise wurde der Server aktualisiert, während du das Pad angesehen hast, oder es existiert ein Fehler in Etherpad. Versuche, die Seite neu zu laden.",
|
||||
|
@ -140,7 +141,7 @@
|
|||
"timeslider.pageTitle": "{{appTitle}} Bearbeitungsverlauf",
|
||||
"timeslider.toolbar.returnbutton": "Zurück zum Pad",
|
||||
"timeslider.toolbar.authors": "Autoren:",
|
||||
"timeslider.toolbar.authorsList": "keine Autoren",
|
||||
"timeslider.toolbar.authorsList": "Keine Autoren",
|
||||
"timeslider.toolbar.exportlink.title": "Diese Version exportieren",
|
||||
"timeslider.exportCurrent": "Exportiere diese Version als:",
|
||||
"timeslider.version": "Version {{version}}",
|
||||
|
@ -163,7 +164,7 @@
|
|||
"timeslider.month.december": "Dezember",
|
||||
"timeslider.unnamedauthors": "{{num}} {[plural(num) one: unbenannter Autor, other: unbenannte Autoren ]}",
|
||||
"pad.savedrevs.marked": "Diese Version wurde jetzt als gespeicherte Version gekennzeichnet",
|
||||
"pad.savedrevs.timeslider": "Du kannst gespeicherte Versionen durch den Aufruf des Bearbeitungsverlaufes ansehen.",
|
||||
"pad.savedrevs.timeslider": "Du kannst gespeicherte Versionen durch den Aufruf des Bearbeitungsverlaufs ansehen.",
|
||||
"pad.userlist.entername": "Dein Name?",
|
||||
"pad.userlist.unnamed": "unbenannt",
|
||||
"pad.editbar.clearcolors": "Autorenfarben im gesamten Dokument zurücksetzen? Dies kann nicht rückgängig gemacht werden",
|
||||
|
|
|
@ -75,7 +75,7 @@ class Pad {
|
|||
|
||||
async appendRevision(aChangeset, authorId = '') {
|
||||
const newAText = Changeset.applyToAText(aChangeset, this.atext, this.pool);
|
||||
if (newAText.text === this.atext.text && newAText.attribs === this.atext.attribs) {
|
||||
if (newAText.text === this.atext.text && newAText.attribs === this.atext.attribs && this.head !== -1) {
|
||||
return this.head;
|
||||
}
|
||||
Changeset.copyAText(newAText, this.atext);
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
const CustomError = require('../utils/customError');
|
||||
const Pad = require('../db/Pad');
|
||||
const db = require('./DB');
|
||||
const settings = require('../utils/Settings');
|
||||
|
||||
/**
|
||||
* A cache of all loaded Pads.
|
||||
|
@ -170,6 +171,8 @@ exports.sanitizePadId = async (padId) => {
|
|||
padId = padId.replace(from, to);
|
||||
}
|
||||
|
||||
if (settings.lowerCasePadIds) padId = padId.toLowerCase();
|
||||
|
||||
// we're out of possible transformations, so just return it
|
||||
return padId;
|
||||
};
|
||||
|
|
|
@ -89,24 +89,24 @@ const doImport = async (req, res, padId, authorId) => {
|
|||
maxFileSize: settings.importMaxFileSize,
|
||||
});
|
||||
|
||||
// locally wrapped Promise, since form.parse requires a callback
|
||||
let srcFile = await new Promise((resolve, reject) => {
|
||||
form.parse(req, (err, fields, files) => {
|
||||
if (err != null) {
|
||||
let srcFile;
|
||||
let files;
|
||||
let fields;
|
||||
try {
|
||||
[fields, files] = await form.parse(req);
|
||||
} catch (err) {
|
||||
logger.warn(`Import failed due to form error: ${err.stack || err}`);
|
||||
// I hate doing indexOf here but I can't see anything to use...
|
||||
if (err && err.stack && err.stack.indexOf('maxFileSize') !== -1) {
|
||||
return reject(new ImportError('maxFileSize'));
|
||||
if (err.code === Formidable.formidableErrors.biggerThanMaxFileSize) {
|
||||
throw new ImportError('maxFileSize');
|
||||
}
|
||||
return reject(new ImportError('uploadFailed'));
|
||||
throw new ImportError('uploadFailed');
|
||||
}
|
||||
if (!files.file) {
|
||||
logger.warn('Import failed because form had no file');
|
||||
return reject(new ImportError('uploadFailed'));
|
||||
throw new ImportError('uploadFailed');
|
||||
} else {
|
||||
srcFile = files.file[0].filepath;
|
||||
}
|
||||
resolve(files.file.filepath);
|
||||
});
|
||||
});
|
||||
|
||||
// ensure this is a file ending we know, else we change the file ending to .txt
|
||||
// this allows us to accept source code files like .c or .java
|
||||
|
|
|
@ -236,6 +236,11 @@ exports.handleMessage = async (socket, message) => {
|
|||
padID: message.padId,
|
||||
token: message.token,
|
||||
};
|
||||
|
||||
// Pad does not exist, so we need to sanitize the id
|
||||
if (!(await padManager.doesPadExist(thisSession.auth.padID))) {
|
||||
thisSession.auth.padID = await padManager.sanitizePadId(thisSession.auth.padID);
|
||||
}
|
||||
const padIds = await readOnlyManager.getIds(thisSession.auth.padID);
|
||||
thisSession.padId = padIds.padId;
|
||||
thisSession.readOnlyPadId = padIds.readOnlyPadId;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
const _ = require('underscore');
|
||||
const SecretRotator = require('../security/SecretRotator');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const events = require('events');
|
||||
const express = require('express');
|
||||
|
@ -14,6 +15,7 @@ const stats = require('../stats');
|
|||
const util = require('util');
|
||||
const webaccess = require('./express/webaccess');
|
||||
|
||||
let secretRotator = null;
|
||||
const logger = log4js.getLogger('http');
|
||||
let serverName;
|
||||
let sessionStore;
|
||||
|
@ -53,6 +55,8 @@ const closeServer = async () => {
|
|||
}
|
||||
if (sessionStore) sessionStore.shutdown();
|
||||
sessionStore = null;
|
||||
if (secretRotator) secretRotator.stop();
|
||||
secretRotator = null;
|
||||
};
|
||||
|
||||
exports.createServer = async () => {
|
||||
|
@ -174,13 +178,23 @@ exports.restartServer = async () => {
|
|||
}));
|
||||
}
|
||||
|
||||
app.use(cookieParser(settings.sessionKey, {}));
|
||||
const {keyRotationInterval, sessionLifetime} = settings.cookie;
|
||||
let secret = settings.sessionKey;
|
||||
if (keyRotationInterval && sessionLifetime) {
|
||||
secretRotator = new SecretRotator(
|
||||
'expressSessionSecrets', keyRotationInterval, sessionLifetime, settings.sessionKey);
|
||||
await secretRotator.start();
|
||||
secret = secretRotator.secrets;
|
||||
}
|
||||
if (!secret) throw new Error('missing cookie signing secret');
|
||||
|
||||
app.use(cookieParser(secret, {}));
|
||||
|
||||
sessionStore = new SessionStore(settings.cookie.sessionRefreshInterval);
|
||||
exports.sessionMiddleware = expressSession({
|
||||
propagateTouch: true,
|
||||
rolling: true,
|
||||
secret: settings.sessionKey,
|
||||
secret,
|
||||
store: sessionStore,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
|
@ -188,7 +202,7 @@ exports.restartServer = async () => {
|
|||
// cleaner :)
|
||||
name: 'express_sid',
|
||||
cookie: {
|
||||
maxAge: settings.cookie.sessionLifetime || null, // Convert 0 to null.
|
||||
maxAge: sessionLifetime || null, // Convert 0 to null.
|
||||
sameSite: settings.cookie.sameSite,
|
||||
|
||||
// The automatic express-session mechanism for determining if the application is being served
|
||||
|
|
|
@ -8,20 +8,19 @@ const util = require('util');
|
|||
|
||||
exports.expressPreSession = async (hookName, {app}) => {
|
||||
// The Etherpad client side sends information about how a disconnect happened
|
||||
app.post('/ep/pad/connection-diagnostic-info', (req, res) => {
|
||||
new Formidable().parse(req, (err, fields, files) => {
|
||||
app.post('/ep/pad/connection-diagnostic-info', async (req, res) => {
|
||||
const [fields, files] = await (new Formidable({})).parse(req);
|
||||
clientLogger.info(`DIAGNOSTIC-INFO: ${fields.diagnosticInfo}`);
|
||||
res.end('OK');
|
||||
});
|
||||
});
|
||||
|
||||
const parseJserrorForm = async (req) => await new Promise((resolve, reject) => {
|
||||
const parseJserrorForm = async (req) => {
|
||||
const form = new Formidable({
|
||||
maxFileSize: 1, // Files are not expected. Not sure if 0 means unlimited, so 1 is used.
|
||||
});
|
||||
form.on('error', (err) => reject(err));
|
||||
form.parse(req, (err, fields) => err != null ? reject(err) : resolve(fields.errorInfo));
|
||||
});
|
||||
const [fields, files] = await form.parse(req);
|
||||
return fields.errorInfo;
|
||||
};
|
||||
|
||||
// The Etherpad client side sends information about client side javscript errors
|
||||
app.post('/jserror', (req, res, next) => {
|
||||
|
|
|
@ -15,8 +15,7 @@
|
|||
*/
|
||||
|
||||
const OpenAPIBackend = require('openapi-backend').default;
|
||||
const formidable = require('formidable');
|
||||
const {promisify} = require('util');
|
||||
const IncomingForm = require('formidable').IncomingForm;
|
||||
const cloneDeep = require('lodash.clonedeep');
|
||||
const createHTTPError = require('http-errors');
|
||||
|
||||
|
@ -596,9 +595,13 @@ exports.expressPreSession = async (hookName, {app}) => {
|
|||
// read form data if method was POST
|
||||
let formData = {};
|
||||
if (c.request.method === 'post') {
|
||||
const form = new formidable.IncomingForm();
|
||||
const parseForm = promisify(form.parse).bind(form);
|
||||
formData = await parseForm(req);
|
||||
const form = new IncomingForm();
|
||||
formData = (await form.parse(req))[0];
|
||||
for (const k of Object.keys(formData)) {
|
||||
if (formData[k] instanceof Array) {
|
||||
formData[k] = formData[k][0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fields = Object.assign({}, header, params, query, formData);
|
||||
|
|
|
@ -149,7 +149,10 @@ const checkAccess = async (req, res, next) => {
|
|||
if (!(await aCallFirst0('authenticate', ctx))) {
|
||||
// Fall back to HTTP basic auth.
|
||||
const {[ctx.username]: {password} = {}} = settings.users;
|
||||
if (!httpBasicAuth || !ctx.username || password == null || password !== ctx.password) {
|
||||
|
||||
if (!httpBasicAuth ||
|
||||
!ctx.username ||
|
||||
password == null || password.toString() !== ctx.password) {
|
||||
httpLogger.info(`Failed authentication from IP ${req.ip}`);
|
||||
if (await aCallFirst0('authnFailure', {req, res})) return;
|
||||
if (await aCallFirst0('authFailure', {req, res, next})) return;
|
||||
|
|
251
src/node/security/SecretRotator.js
Normal file
251
src/node/security/SecretRotator.js
Normal file
|
@ -0,0 +1,251 @@
|
|||
'use strict';
|
||||
|
||||
const {Buffer} = require('buffer');
|
||||
const crypto = require('./crypto');
|
||||
const db = require('../db/DB');
|
||||
const log4js = require('log4js');
|
||||
|
||||
class Kdf {
|
||||
async generateParams() { throw new Error('not implemented'); }
|
||||
async derive(params, info) { throw new Error('not implemented'); }
|
||||
}
|
||||
|
||||
class LegacyStaticSecret extends Kdf {
|
||||
async derive(params, info) { return params; }
|
||||
}
|
||||
|
||||
class Hkdf extends Kdf {
|
||||
constructor(digest, keyLen) {
|
||||
super();
|
||||
this._digest = digest;
|
||||
this._keyLen = keyLen;
|
||||
}
|
||||
|
||||
async generateParams() {
|
||||
const [secret, salt] = (await Promise.all([
|
||||
crypto.randomBytes(this._keyLen),
|
||||
crypto.randomBytes(this._keyLen),
|
||||
])).map((b) => b.toString('hex'));
|
||||
return {digest: this._digest, keyLen: this._keyLen, salt, secret};
|
||||
}
|
||||
|
||||
async derive(p, info) {
|
||||
return Buffer.from(
|
||||
await crypto.hkdf(p.digest, p.secret, p.salt, info, p.keyLen)).toString('hex');
|
||||
}
|
||||
}
|
||||
|
||||
// Key derivation algorithms. Do not modify entries in this array, except:
|
||||
// * It is OK to replace an unused algorithm with `null` after any entries in the database
|
||||
// using the algorithm have been deleted.
|
||||
// * It is OK to append a new algorithm to the end.
|
||||
// If the entries are modified in any other way then key derivation might fail or produce invalid
|
||||
// results due to broken compatibility with existing database records.
|
||||
const algorithms = [
|
||||
new LegacyStaticSecret(),
|
||||
new Hkdf('sha256', 32),
|
||||
];
|
||||
const defaultAlgId = algorithms.length - 1;
|
||||
|
||||
// In JavaScript, the % operator is remainder, not modulus.
|
||||
const mod = (a, n) => ((a % n) + n) % n;
|
||||
const intervalStart = (t, interval) => t - mod(t, interval);
|
||||
|
||||
/**
|
||||
* Maintains an array of secrets across one or more Etherpad instances sharing the same database,
|
||||
* periodically rotating in a new secret and removing the oldest secret.
|
||||
*
|
||||
* The secrets are generated using a key derivation function (KDF) with input keying material coming
|
||||
* from a long-lived secret stored in the database (generated if missing).
|
||||
*/
|
||||
class SecretRotator {
|
||||
/**
|
||||
* @param {string} dbPrefix - Database key prefix to use for tracking secret metadata.
|
||||
* @param {number} interval - How often to rotate in a new secret.
|
||||
* @param {number} lifetime - How long after the end of an interval before the secret is no longer
|
||||
* useful.
|
||||
* @param {string} [legacyStaticSecret] - Optional secret to facilitate migration to secret
|
||||
* rotation. If the oldest known secret starts after `lifetime` ago, this secret will cover
|
||||
* the time period starting `lifetime` ago and ending at the start of that secret.
|
||||
*/
|
||||
constructor(dbPrefix, interval, lifetime, legacyStaticSecret = null) {
|
||||
/**
|
||||
* The secrets. The first secret in this array is the one that should be used to generate new
|
||||
* MACs. All of the secrets in this array should be used when attempting to authenticate an
|
||||
* existing MAC. The contents of this array will be updated every `interval` milliseconds, but
|
||||
* the Array object itself will never be replaced with a new Array object.
|
||||
*
|
||||
* @type {string[]}
|
||||
* @public
|
||||
*/
|
||||
this.secrets = [];
|
||||
Object.defineProperty(this, 'secrets', {writable: false}); // Defend against bugs.
|
||||
|
||||
if (/[*:%]/.test(dbPrefix)) throw new Error(`dbPrefix contains an invalid char: ${dbPrefix}`);
|
||||
this._dbPrefix = dbPrefix;
|
||||
this._interval = interval;
|
||||
this._legacyStaticSecret = legacyStaticSecret;
|
||||
this._lifetime = lifetime;
|
||||
this._logger = log4js.getLogger(`secret-rotation ${dbPrefix}`);
|
||||
this._logger.debug(`new secret rotator (interval ${interval}, lifetime: ${lifetime})`);
|
||||
this._updateTimeout = null;
|
||||
|
||||
// Indirections to facilitate testing.
|
||||
this._t = {now: Date.now.bind(Date), setTimeout, clearTimeout, algorithms};
|
||||
}
|
||||
|
||||
async _publish(params, id = null) {
|
||||
// Params are published to the db with a randomly generated key to avoid race conditions with
|
||||
// other instances.
|
||||
if (id == null) id = `${this._dbPrefix}:${(await crypto.randomBytes(32)).toString('hex')}`;
|
||||
await db.set(id, params);
|
||||
return id;
|
||||
}
|
||||
|
||||
async start() {
|
||||
this._logger.debug('starting secret rotation');
|
||||
if (this._updateTimeout != null) return; // Already started.
|
||||
await this._update();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._logger.debug('stopping secret rotation');
|
||||
this._t.clearTimeout(this._updateTimeout);
|
||||
this._updateTimeout = null;
|
||||
}
|
||||
|
||||
async _deriveSecrets(p, now) {
|
||||
this._logger.debug('deriving secrets from', p);
|
||||
if (!p.interval) return [await algorithms[p.algId].derive(p.algParams, null)];
|
||||
const t0 = intervalStart(now, p.interval);
|
||||
// Start of the first interval covered by these params. To accommodate clock skew, p.interval is
|
||||
// subtracted. If we did not do this, then the following could happen:
|
||||
// 1. Instance (A) starts up and publishes params starting at the current interval.
|
||||
// 2. Instance (B) starts up with a clock that is in the previous interval.
|
||||
// 3. Instance (B) reads the params published by instance (A) and sees that there's no
|
||||
// coverage of what it thinks is the current interval.
|
||||
// 4. Instance (B) generates and publishes new params that covers what it thinks is the
|
||||
// current interval.
|
||||
// 5. Instance (B) starts generating MACs from a secret derived from the new params.
|
||||
// 6. Instance (A) fails to validate the MACs generated by instance (B) until it re-reads
|
||||
// the published params, which might take as long as interval.
|
||||
// An alternative approach is to backdate p.start by p.interval when creating new params, but
|
||||
// this could affect the end time of legacy secrets.
|
||||
const tA = intervalStart(p.start - p.interval, p.interval);
|
||||
const tZ = intervalStart(p.end - 1, p.interval);
|
||||
this._logger.debug('now:', now, 't0:', t0, 'tA:', tA, 'tZ:', tZ);
|
||||
// Starts of intervals to derive keys for.
|
||||
const tNs = [];
|
||||
// Whether the derived secret for the interval starting at tN is still relevant. If there was no
|
||||
// clock skew, a derived secret is relevant until p.lifetime has elapsed since the end of the
|
||||
// interval. To accommodate clock skew, this end time is extended by p.interval.
|
||||
const expired = (tN) => now >= tN + (2 * p.interval) + p.lifetime;
|
||||
// Walk from t0 back until either the start of coverage or the derived secret is expired. t0
|
||||
// must always be the first entry in case p is the current params. (The first derived secret is
|
||||
// used for generating MACs, so the secret derived for t0 must be before the secrets derived for
|
||||
// other times.)
|
||||
for (let tN = Math.min(t0, tZ); tN >= tA && !expired(tN); tN -= p.interval) tNs.push(tN);
|
||||
// Include a future derived secret to accommodate clock skew.
|
||||
if (t0 + p.interval <= tZ) tNs.push(t0 + p.interval);
|
||||
this._logger.debug('deriving secrets for intervals with start times:', tNs);
|
||||
return await Promise.all(
|
||||
tNs.map(async (tN) => await algorithms[p.algId].derive(p.algParams, `${tN}`)));
|
||||
}
|
||||
|
||||
async _update() {
|
||||
const now = this._t.now();
|
||||
const t0 = intervalStart(now, this._interval);
|
||||
let next = t0 + this._interval; // When this._update() should be called again.
|
||||
let legacyEnd = now;
|
||||
// TODO: This is racy. If two instances start up at the same time and there are no existing
|
||||
// matching publications, each will generate and publish their own paramters. In practice this
|
||||
// is unlikely to happen, and if it does it can be fixed by restarting both Etherpad instances.
|
||||
const dbKeys = await db.findKeys(`${this._dbPrefix}:*`, null);
|
||||
let currentParams = null;
|
||||
let currentId = null;
|
||||
const dbWrites = [];
|
||||
const allParams = [];
|
||||
const legacyParams = [];
|
||||
await Promise.all(dbKeys.map(async (dbKey) => {
|
||||
const p = await db.get(dbKey);
|
||||
if (p.algId === 0 && p.algParams === this._legacyStaticSecret) legacyParams.push(p);
|
||||
if (p.start < legacyEnd) legacyEnd = p.start;
|
||||
// Check if the params have expired. Params are still useful if a MAC generated by a secret
|
||||
// derived from the params is still valid, which can be true up to p.end + p.lifetime if
|
||||
// there was no clock skew. The p.interval factor is added to accommodate clock skew.
|
||||
// p.interval is null for legacy secrets, so fall back to this._interval.
|
||||
if (now >= p.end + p.lifetime + (p.interval || this._interval)) {
|
||||
// This initial keying material (or legacy secret) is expired.
|
||||
dbWrites.push(db.remove(dbKey));
|
||||
dbWrites[dbWrites.length - 1].catch(() => {}); // Prevent unhandled Promise rejections.
|
||||
return;
|
||||
}
|
||||
const t1 = p.interval && intervalStart(now, p.interval) + p.interval; // Start of next intrvl.
|
||||
const tA = intervalStart(p.start, p.interval); // Start of interval containing p.start.
|
||||
if (p.interval) next = Math.min(next, t1);
|
||||
// Determine if these params can be used to generate the current (active) secret. Note that
|
||||
// p.start is allowed to be in the next interval in case there is clock skew.
|
||||
if (p.interval && p.interval === this._interval && p.lifetime === this._lifetime &&
|
||||
tA <= t1 && p.end > now && (currentParams == null || p.start > currentParams.start)) {
|
||||
if (currentParams) allParams.push(currentParams);
|
||||
currentParams = p;
|
||||
currentId = dbKey;
|
||||
} else {
|
||||
allParams.push(p);
|
||||
}
|
||||
}));
|
||||
if (this._legacyStaticSecret && now < legacyEnd + this._lifetime + this._interval &&
|
||||
!legacyParams.find((p) => p.end + p.lifetime >= legacyEnd + this._lifetime)) {
|
||||
const d = new Date(legacyEnd).toJSON();
|
||||
this._logger.debug(`adding legacy static secret for ${d} with lifetime ${this._lifetime}`);
|
||||
const p = {
|
||||
algId: 0,
|
||||
algParams: this._legacyStaticSecret,
|
||||
// The start time is equal to the end time so that this legacy secret does not affect the
|
||||
// end times of any legacy secrets published by other instances.
|
||||
start: legacyEnd,
|
||||
end: legacyEnd,
|
||||
interval: null,
|
||||
lifetime: this._lifetime,
|
||||
};
|
||||
allParams.push(p);
|
||||
dbWrites.push(this._publish(p));
|
||||
dbWrites[dbWrites.length - 1].catch(() => {}); // Prevent unhandled Promise rejections.
|
||||
}
|
||||
if (currentParams == null) {
|
||||
currentParams = {
|
||||
algId: defaultAlgId,
|
||||
algParams: await algorithms[defaultAlgId].generateParams(),
|
||||
start: now,
|
||||
end: now, // Extended below.
|
||||
interval: this._interval,
|
||||
lifetime: this._lifetime,
|
||||
};
|
||||
}
|
||||
// Advance currentParams's expiration time to the end of the next interval if needed. (The next
|
||||
// interval is used so that the parameters never expire under normal circumstances.) This must
|
||||
// be done before deriving any secrets from currentParams so that a secret for the next interval
|
||||
// can be included (in case there is clock skew).
|
||||
currentParams.end = Math.max(currentParams.end, t0 + (2 * this._interval));
|
||||
dbWrites.push(this._publish(currentParams, currentId));
|
||||
dbWrites[dbWrites.length - 1].catch(() => {}); // Prevent unhandled Promise rejections.
|
||||
// The secrets derived from currentParams MUST be the first secrets.
|
||||
const secrets = await this._deriveSecrets(currentParams, now);
|
||||
await Promise.all(
|
||||
allParams.map(async (p) => secrets.push(...await this._deriveSecrets(p, now))));
|
||||
// Update this.secrets all at once to avoid race conditions.
|
||||
this.secrets.length = 0;
|
||||
this.secrets.push(...secrets);
|
||||
this._logger.debug('active secrets:', this.secrets);
|
||||
// Wait for db writes to finish after updating this.secrets so that the new secrets become
|
||||
// active as soon as possible.
|
||||
await Promise.all(dbWrites);
|
||||
// Use an async function so that test code can tell when it's done publishing the new secrets.
|
||||
// The standard setTimeout() function ignores the callback's return value, but some of the tests
|
||||
// await the returned Promise.
|
||||
this._updateTimeout =
|
||||
this._t.setTimeout(async () => await this._update(), next - this._t.now());
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SecretRotator;
|
15
src/node/security/crypto.js
Normal file
15
src/node/security/crypto.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const util = require('util');
|
||||
|
||||
|
||||
/**
|
||||
* Promisified version of Node.js's crypto.hkdf.
|
||||
*/
|
||||
exports.hkdf = util.promisify(crypto.hkdf);
|
||||
|
||||
/**
|
||||
* Promisified version of Node.js's crypto.randomBytes
|
||||
*/
|
||||
exports.randomBytes = util.promisify(crypto.randomBytes);
|
|
@ -276,3 +276,4 @@ exports.exit = async (err = null) => {
|
|||
};
|
||||
|
||||
if (require.main === module) exports.start();
|
||||
if (typeof(PhusionPassenger) !== 'undefined') exports.start();
|
||||
|
|
|
@ -297,9 +297,9 @@ exports.indentationOnNewLine = true;
|
|||
exports.logconfig = defaultLogConfig();
|
||||
|
||||
/*
|
||||
* Session Key, do not sure this.
|
||||
* Deprecated cookie signing key.
|
||||
*/
|
||||
exports.sessionKey = false;
|
||||
exports.sessionKey = null;
|
||||
|
||||
/*
|
||||
* Trust Proxy, whether or not trust the x-forwarded-for header.
|
||||
|
@ -310,6 +310,7 @@ exports.trustProxy = false;
|
|||
* Settings controlling the session cookie issued by Etherpad.
|
||||
*/
|
||||
exports.cookie = {
|
||||
keyRotationInterval: 1 * 24 * 60 * 60 * 1000,
|
||||
/*
|
||||
* Value of the SameSite cookie property. "Lax" is recommended unless
|
||||
* Etherpad will be embedded in an iframe from another site, in which case
|
||||
|
@ -430,6 +431,11 @@ exports.importMaxFileSize = 50 * 1024 * 1024;
|
|||
*/
|
||||
exports.enableAdminUITests = false;
|
||||
|
||||
/*
|
||||
* Enable auto conversion of pad Ids to lowercase.
|
||||
* e.g. /p/EtHeRpAd to /p/etherpad
|
||||
*/
|
||||
exports.lowerCasePadIds = false;
|
||||
|
||||
// checks if abiword is avaiable
|
||||
exports.abiwordAvailable = () => {
|
||||
|
@ -800,12 +806,14 @@ exports.reloadSettings = () => {
|
|||
});
|
||||
}
|
||||
|
||||
if (!exports.sessionKey) {
|
||||
const sessionkeyFilename = absolutePaths.makeAbsolute(argv.sessionkey || './SESSIONKEY.txt');
|
||||
if (!exports.sessionKey) {
|
||||
try {
|
||||
exports.sessionKey = fs.readFileSync(sessionkeyFilename, 'utf8');
|
||||
logger.info(`Session key loaded from: ${sessionkeyFilename}`);
|
||||
} catch (e) {
|
||||
} catch (err) { /* ignored */ }
|
||||
const keyRotationEnabled = exports.cookie.keyRotationInterval && exports.cookie.sessionLifetime;
|
||||
if (!exports.sessionKey && !keyRotationEnabled) {
|
||||
logger.info(
|
||||
`Session key file "${sessionkeyFilename}" not found. Creating with random contents.`);
|
||||
exports.sessionKey = randomString(32);
|
||||
|
@ -817,6 +825,10 @@ exports.reloadSettings = () => {
|
|||
'If you are seeing this error after restarting using the Admin User ' +
|
||||
'Interface then you can ignore this message.');
|
||||
}
|
||||
if (exports.sessionKey) {
|
||||
logger.warn(`The sessionKey setting and ${sessionkeyFilename} file are deprecated; ` +
|
||||
'use automatic key rotation instead (see the cookie.keyRotationInterval setting).');
|
||||
}
|
||||
|
||||
if (exports.dbType === 'dirty') {
|
||||
const dirtyWarning = 'DirtyDB is used. This is not recommended for production.';
|
||||
|
|
|
@ -1,30 +1,34 @@
|
|||
'use strict';
|
||||
const semver = require('semver');
|
||||
const settings = require('./Settings');
|
||||
const request = require('request');
|
||||
|
||||
const axios = require('axios');
|
||||
let infos;
|
||||
|
||||
const loadEtherpadInformations = () => new Promise((resolve, reject) => {
|
||||
request('https://static.etherpad.org/info.json', (er, response, body) => {
|
||||
if (er) return reject(er);
|
||||
|
||||
const loadEtherpadInformations = () =>
|
||||
axios.get('https://static.etherpad.org/info.json')
|
||||
.then(async resp => {
|
||||
try {
|
||||
infos = JSON.parse(body);
|
||||
return resolve(infos);
|
||||
} catch (err) {
|
||||
return reject(err);
|
||||
infos = await resp.data;
|
||||
if (infos === undefined || infos === null) {
|
||||
await Promise.reject("Could not retrieve current version")
|
||||
return
|
||||
}
|
||||
});
|
||||
});
|
||||
return await Promise.resolve(infos);
|
||||
}
|
||||
catch (err) {
|
||||
return await Promise.reject(err);
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
exports.getLatestVersion = () => {
|
||||
exports.needsUpdate();
|
||||
return infos.latestVersion;
|
||||
};
|
||||
|
||||
exports.needsUpdate = (cb) => {
|
||||
loadEtherpadInformations().then((info) => {
|
||||
exports.needsUpdate = async (cb) => {
|
||||
await loadEtherpadInformations()
|
||||
.then((info) => {
|
||||
if (semver.gt(info.latestVersion, settings.getEpVersion())) {
|
||||
if (cb) return cb(true);
|
||||
}
|
||||
|
|
5034
src/package-lock.json
generated
5034
src/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -31,6 +31,7 @@
|
|||
],
|
||||
"dependencies": {
|
||||
"async": "^3.2.4",
|
||||
"axios": "^1.4.0",
|
||||
"clean-css": "^5.3.2",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cross-spawn": "^7.0.3",
|
||||
|
@ -38,11 +39,11 @@
|
|||
"etherpad-require-kernel": "^1.0.15",
|
||||
"etherpad-yajsml": "0.0.12",
|
||||
"express": "4.18.2",
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"express-session": "npm:@etherpad/express-session@^1.18.1",
|
||||
"express-rate-limit": "^6.9.0",
|
||||
"express-session": "npm:@etherpad/express-session@^1.18.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"find-root": "1.1.0",
|
||||
"formidable": "^2.1.2",
|
||||
"formidable": "^3.5.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsdom": "^20.0.0",
|
||||
|
@ -52,22 +53,21 @@
|
|||
"log4js": "0.6.38",
|
||||
"measured-core": "^2.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"npm": "^6.14.15",
|
||||
"npm": "^6.14.18",
|
||||
"openapi-backend": "^5.9.2",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"rate-limiter-flexible": "^2.4.1",
|
||||
"rate-limiter-flexible": "^2.4.2",
|
||||
"rehype": "^12.0.1",
|
||||
"rehype-minify-whitespace": "^5.0.1",
|
||||
"request": "2.88.2",
|
||||
"resolve": "1.22.2",
|
||||
"resolve": "1.22.4",
|
||||
"security": "1.0.0",
|
||||
"semver": "^7.5.3",
|
||||
"socket.io": "^2.4.1",
|
||||
"superagent": "^8.0.9",
|
||||
"terser": "^5.18.1",
|
||||
"semver": "^7.5.4",
|
||||
"socket.io": "^2.5.0",
|
||||
"superagent": "^8.1.1",
|
||||
"terser": "^5.19.2",
|
||||
"threads": "^1.7.0",
|
||||
"tinycon": "0.6.8",
|
||||
"ueberdb2": "^4.0.1",
|
||||
"ueberdb2": "^4.1.20",
|
||||
"underscore": "1.13.6",
|
||||
"unorm": "1.6.0",
|
||||
"wtfnode": "^0.9.1"
|
||||
|
@ -78,14 +78,14 @@
|
|||
"etherpad-lite": "node/server.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.43.0",
|
||||
"eslint-config-etherpad": "^3.0.13",
|
||||
"etherpad-cli-client": "^2.0.1",
|
||||
"eslint": "^8.47.0",
|
||||
"eslint-config-etherpad": "^3.0.21",
|
||||
"etherpad-cli-client": "^2.0.2",
|
||||
"mocha": "^10.0.0",
|
||||
"mocha-froth": "^0.2.10",
|
||||
"nodeify": "^1.0.1",
|
||||
"openapi-schema-validation": "^0.4.2",
|
||||
"selenium-webdriver": "^4.10.0",
|
||||
"selenium-webdriver": "^4.11.1",
|
||||
"set-cookie-parser": "^2.6.0",
|
||||
"sinon": "^15.2.0",
|
||||
"split-grid": "^1.0.11",
|
||||
|
@ -103,8 +103,9 @@
|
|||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"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",
|
||||
"dev": "bash ./bin/run.sh"
|
||||
},
|
||||
"version": "1.9.1",
|
||||
"version": "1.9.2",
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
|
|
|
@ -2585,17 +2585,17 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
const firstEditbarElement = parent.parent.$('#editbar')
|
||||
.children('ul').first().children().first()
|
||||
.children().first().children().first();
|
||||
$(this).blur();
|
||||
firstEditbarElement.focus();
|
||||
$(this).trigger('blur');
|
||||
firstEditbarElement.trigger('focus');
|
||||
evt.preventDefault();
|
||||
}
|
||||
if (!specialHandled && type === 'keydown' &&
|
||||
altKey && keyCode === 67 &&
|
||||
padShortcutEnabled.altC) {
|
||||
// Alt c focuses on the Chat window
|
||||
$(this).blur();
|
||||
$(this).trigger('blur');
|
||||
parent.parent.chat.show();
|
||||
parent.parent.$('#chatinput').focus();
|
||||
parent.parent.$('#chatinput').trigger('focus');
|
||||
evt.preventDefault();
|
||||
}
|
||||
if (!specialHandled && type === 'keydown' &&
|
||||
|
|
|
@ -164,7 +164,7 @@
|
|||
$(window).resize(adjust);
|
||||
|
||||
// Allow for manual triggering if needed.
|
||||
$ta.bind('autosize', adjust);
|
||||
$ta.on('autosize', adjust);
|
||||
|
||||
// Call adjust in case the textarea already contains text.
|
||||
adjust();
|
||||
|
|
|
@ -112,15 +112,15 @@ $(document).ready(() => {
|
|||
|
||||
const updateHandlers = () => {
|
||||
// Search
|
||||
$('#search-query').unbind('keyup').keyup(() => {
|
||||
$('#search-query').off('keyup').on('keyup', () => {
|
||||
search($('#search-query').val());
|
||||
});
|
||||
|
||||
// Prevent form submit
|
||||
$('#search-query').parent().bind('submit', () => false);
|
||||
$('#search-query').parent().on('submit', () => false);
|
||||
|
||||
// update & install
|
||||
$('.do-install, .do-update').unbind('click').click(function (e) {
|
||||
$('.do-install, .do-update').off('click').on('click', function (e) {
|
||||
const $row = $(e.target).closest('tr');
|
||||
const plugin = $row.data('plugin');
|
||||
if ($(this).hasClass('do-install')) {
|
||||
|
@ -134,7 +134,7 @@ $(document).ready(() => {
|
|||
});
|
||||
|
||||
// uninstall
|
||||
$('.do-uninstall').unbind('click').click((e) => {
|
||||
$('.do-uninstall').off('click').on('click', (e) => {
|
||||
const $row = $(e.target).closest('tr');
|
||||
const pluginName = $row.data('plugin');
|
||||
socket.emit('uninstall', pluginName);
|
||||
|
@ -143,14 +143,14 @@ $(document).ready(() => {
|
|||
});
|
||||
|
||||
// Sort
|
||||
$('.sort.up').unbind('click').click(function () {
|
||||
$('.sort.up').off('click').on('click', function () {
|
||||
search.sortBy = $(this).attr('data-label').toLowerCase();
|
||||
search.sortDir = false;
|
||||
search.offset = 0;
|
||||
search(search.searchTerm, search.results.length);
|
||||
search.results = [];
|
||||
});
|
||||
$('.sort.down, .sort.none').unbind('click').click(function () {
|
||||
$('.sort.down, .sort.none').off('click').on('click', function () {
|
||||
search.sortBy = $(this).attr('data-label').toLowerCase();
|
||||
search.sortDir = true;
|
||||
search.offset = 0;
|
||||
|
@ -164,7 +164,7 @@ $(document).ready(() => {
|
|||
if (data.query.offset === 0) search.results = [];
|
||||
search.messages.hide('nothing-found');
|
||||
search.messages.hide('fetching');
|
||||
$('#search-query').removeAttr('disabled');
|
||||
$('#search-query').prop('disabled', false);
|
||||
|
||||
console.log('got search results', data);
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@ $(document).ready(() => {
|
|||
/* Check to make sure the JSON is clean before proceeding */
|
||||
if (isJSONClean(settings.results)) {
|
||||
$('.settings').append(settings.results);
|
||||
$('.settings').focus();
|
||||
$('.settings').trigger('focus');
|
||||
$('.settings').autosize();
|
||||
} else {
|
||||
alert('Invalid JSON');
|
||||
|
@ -40,7 +40,7 @@ $(document).ready(() => {
|
|||
socket.emit('saveSettings', $('.settings').val());
|
||||
} else {
|
||||
alert('Invalid JSON');
|
||||
$('.settings').focus();
|
||||
$('.settings').trigger('focus');
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -62,7 +62,7 @@ const isJSONClean = (data) => {
|
|||
// this is a bit naive. In theory some key/value might contain the sequences ',]' or ',}'
|
||||
cleanSettings = cleanSettings.replace(',]', ']').replace(',}', '}');
|
||||
try {
|
||||
return typeof jQuery.parseJSON(cleanSettings) === 'object';
|
||||
return typeof JSON.parse(cleanSettings) === 'object';
|
||||
} catch (e) {
|
||||
return false; // the JSON failed to be parsed
|
||||
}
|
||||
|
|
|
@ -67,7 +67,7 @@ const loadBroadcastSliderJS = (fireWhenAllScriptsAreLoaded) => {
|
|||
newSavedRevision.css(
|
||||
'left', (position * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0)) - 1);
|
||||
$('#ui-slider-bar').append(newSavedRevision);
|
||||
newSavedRevision.mouseup((evt) => {
|
||||
newSavedRevision.on('mouseup', (evt) => {
|
||||
BroadcastSlider.setSliderPosition(position);
|
||||
});
|
||||
savedRevisions.push(newSavedRevision);
|
||||
|
@ -209,21 +209,21 @@ const loadBroadcastSliderJS = (fireWhenAllScriptsAreLoaded) => {
|
|||
|
||||
// assign event handlers to html UI elements after page load
|
||||
fireWhenAllScriptsAreLoaded.push(() => {
|
||||
$(document).keyup((e) => {
|
||||
$(document).on('keyup', (e) => {
|
||||
if (!e) e = window.event;
|
||||
const code = e.keyCode || e.which;
|
||||
|
||||
if (code === 37) { // left
|
||||
if (e.shiftKey) {
|
||||
$('#leftstar').click();
|
||||
$('#leftstar').trigger('click');
|
||||
} else {
|
||||
$('#leftstep').click();
|
||||
$('#leftstep').trigger('click');
|
||||
}
|
||||
} else if (code === 39) { // right
|
||||
if (e.shiftKey) {
|
||||
$('#rightstar').click();
|
||||
$('#rightstar').trigger('click');
|
||||
} else {
|
||||
$('#rightstep').click();
|
||||
$('#rightstep').trigger('click');
|
||||
}
|
||||
} else if (code === 32) { // spacebar
|
||||
$('#playpause_button_icon').trigger('click');
|
||||
|
@ -231,22 +231,22 @@ const loadBroadcastSliderJS = (fireWhenAllScriptsAreLoaded) => {
|
|||
});
|
||||
|
||||
// Resize
|
||||
$(window).resize(() => {
|
||||
$(window).on('resize', () => {
|
||||
updateSliderElements();
|
||||
});
|
||||
|
||||
// Slider click
|
||||
$('#ui-slider-bar').mousedown((evt) => {
|
||||
$('#ui-slider-bar').on('mousedown', (evt) => {
|
||||
$('#ui-slider-handle').css('left', (evt.clientX - $('#ui-slider-bar').offset().left));
|
||||
$('#ui-slider-handle').trigger(evt);
|
||||
});
|
||||
|
||||
// Slider dragging
|
||||
$('#ui-slider-handle').mousedown(function (evt) {
|
||||
$('#ui-slider-handle').on('mousedown', function (evt) {
|
||||
this.startLoc = evt.clientX;
|
||||
this.currentLoc = parseInt($(this).css('left'));
|
||||
sliderActive = true;
|
||||
$(document).mousemove((evt2) => {
|
||||
$(document).on('mousemove', (evt2) => {
|
||||
$(this).css('pointer', 'move');
|
||||
let newloc = this.currentLoc + (evt2.clientX - this.startLoc);
|
||||
if (newloc < 0) newloc = 0;
|
||||
|
@ -257,9 +257,9 @@ const loadBroadcastSliderJS = (fireWhenAllScriptsAreLoaded) => {
|
|||
$(this).css('left', newloc);
|
||||
if (getSliderPosition() !== version) _callSliderCallbacks(version);
|
||||
});
|
||||
$(document).mouseup((evt2) => {
|
||||
$(document).unbind('mousemove');
|
||||
$(document).unbind('mouseup');
|
||||
$(document).on('mouseup', (evt2) => {
|
||||
$(document).off('mousemove');
|
||||
$(document).off('mouseup');
|
||||
sliderActive = false;
|
||||
let newloc = this.currentLoc + (evt2.clientX - this.startLoc);
|
||||
if (newloc < 0) newloc = 0;
|
||||
|
@ -276,12 +276,12 @@ const loadBroadcastSliderJS = (fireWhenAllScriptsAreLoaded) => {
|
|||
});
|
||||
|
||||
// play/pause toggling
|
||||
$('#playpause_button_icon').click((evt) => {
|
||||
$('#playpause_button_icon').on('click', (evt) => {
|
||||
BroadcastSlider.playpause();
|
||||
});
|
||||
|
||||
// next/prev saved revision and changeset
|
||||
$('.stepper').click(function (evt) {
|
||||
$('.stepper').on('click', function (evt) {
|
||||
switch ($(this).attr('id')) {
|
||||
case 'leftstep':
|
||||
setSliderPosition(getSliderPosition() - 1);
|
||||
|
|
|
@ -42,11 +42,14 @@ exports.chat = (() => {
|
|||
},
|
||||
focus: () => {
|
||||
setTimeout(() => {
|
||||
$('#chatinput').focus();
|
||||
$('#chatinput').trigger('focus');
|
||||
}, 100);
|
||||
},
|
||||
// Make chat stick to right hand side of screen
|
||||
stickToScreen(fromInitialCall) {
|
||||
if ($('#options-stickychat').prop('checked')) {
|
||||
$('#options-stickychat').prop('checked', false);
|
||||
}
|
||||
if (pad.settings.hideChat) {
|
||||
return;
|
||||
}
|
||||
|
@ -68,7 +71,7 @@ exports.chat = (() => {
|
|||
this.stickToScreen(true);
|
||||
$('#options-stickychat').prop('checked', true);
|
||||
$('#options-chatandusers').prop('checked', true);
|
||||
$('#options-stickychat').prop('disabled', 'disabled');
|
||||
$('#options-stickychat').prop('disabled', true);
|
||||
userAndChat = true;
|
||||
} else {
|
||||
$('#options-stickychat').prop('disabled', false);
|
||||
|
@ -223,14 +226,14 @@ exports.chat = (() => {
|
|||
// Send the users focus back to the pad
|
||||
if ((evt.altKey === true && evt.which === 67) || evt.which === 27) {
|
||||
// If we're in chat already..
|
||||
$(':focus').blur(); // required to do not try to remove!
|
||||
$(':focus').trigger('blur'); // required to do not try to remove!
|
||||
padeditor.ace.focus(); // Sends focus back to pad
|
||||
evt.preventDefault();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// Clear the chat mentions when the user clicks on the chat input box
|
||||
$('#chatinput').click(() => {
|
||||
$('#chatinput').on('click', () => {
|
||||
chatMentions = 0;
|
||||
Tinycon.setBubble(0);
|
||||
});
|
||||
|
@ -239,14 +242,14 @@ exports.chat = (() => {
|
|||
$('body:not(#chatinput)').on('keypress', function (evt) {
|
||||
if (evt.altKey && evt.which === 67) {
|
||||
// Alt c focuses on the Chat window
|
||||
$(this).blur();
|
||||
$(this).trigger('blur');
|
||||
self.show();
|
||||
$('#chatinput').focus();
|
||||
$('#chatinput').trigger('focus');
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
$('#chatinput').keypress((evt) => {
|
||||
$('#chatinput').on('keypress', (evt) => {
|
||||
// if the user typed enter, fire the send
|
||||
if (evt.key === 'Enter' && !evt.shiftKey) {
|
||||
evt.preventDefault();
|
||||
|
@ -257,7 +260,7 @@ exports.chat = (() => {
|
|||
// initial messages are loaded in pad.js' _afterHandshake
|
||||
|
||||
$('#chatcounter').text(0);
|
||||
$('#chatloadmessagesbutton').click(() => {
|
||||
$('#chatloadmessagesbutton').on('click', () => {
|
||||
const start = Math.max(this.historyPointer - 20, 0);
|
||||
const end = this.historyPointer;
|
||||
|
||||
|
|
|
@ -66,7 +66,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
if (browser.firefox) {
|
||||
// Prevent "escape" from taking effect and canceling a comet connection;
|
||||
// doesn't work if focus is on an iframe.
|
||||
$(window).bind('keydown', (evt) => {
|
||||
$(window).on('keydown', (evt) => {
|
||||
if (evt.which === 27) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@ const randomPadName = () => {
|
|||
};
|
||||
|
||||
$(() => {
|
||||
$('#go2Name').submit(() => {
|
||||
$('#go2Name').on('submit', () => {
|
||||
const padname = $('#padname').val();
|
||||
if (padname.length > 0) {
|
||||
window.location = `p/${encodeURIComponent(padname.trim())}`;
|
||||
|
@ -51,7 +51,7 @@ $(() => {
|
|||
return false;
|
||||
});
|
||||
|
||||
$('#button').click(() => {
|
||||
$('#button').on('click', () => {
|
||||
window.location = `p/${randomPadName()}`;
|
||||
});
|
||||
|
||||
|
|
|
@ -412,10 +412,12 @@ const pad = {
|
|||
setTimeout(() => {
|
||||
padeditor.ace.focus();
|
||||
}, 0);
|
||||
const optionsStickyChat = $('#options-stickychat');
|
||||
optionsStickyChat.on('click', () => { chat.stickToScreen(); });
|
||||
// if we have a cookie for always showing chat then show it
|
||||
if (padcookie.getPref('chatAlwaysVisible')) {
|
||||
chat.stickToScreen(true); // stick it to the screen
|
||||
$('#options-stickychat').prop('checked', true); // set the checkbox to on
|
||||
optionsStickyChat.prop('checked', true); // set the checkbox to on
|
||||
}
|
||||
// if we have a cookie for always showing chat then show it
|
||||
if (padcookie.getPref('chatAndUsers')) {
|
||||
|
@ -437,8 +439,8 @@ const pad = {
|
|||
// Prevent sticky chat or chat and users to be checked for mobiles
|
||||
const checkChatAndUsersVisibility = (x) => {
|
||||
if (x.matches) { // If media query matches
|
||||
$('#options-chatandusers:checked').click();
|
||||
$('#options-stickychat:checked').click();
|
||||
$('#options-chatandusers:checked').trigger('click');
|
||||
$('#options-stickychat:checked').trigger('click');
|
||||
}
|
||||
};
|
||||
const mobileMatch = window.matchMedia('(max-width: 800px)');
|
||||
|
@ -711,7 +713,7 @@ const pad = {
|
|||
$('form#reconnectform input.diagnosticInfo').val(JSON.stringify(pad.diagnosticInfo));
|
||||
$('form#reconnectform input.missedChanges')
|
||||
.val(JSON.stringify(pad.collabClient.getMissedChanges()));
|
||||
$('form#reconnectform').submit();
|
||||
$('form#reconnectform').trigger('submit');
|
||||
},
|
||||
callWhenNotCommitting: (f) => {
|
||||
pad.collabClient.callWhenNotCommitting(f);
|
||||
|
|
|
@ -96,7 +96,7 @@ const whenConnectionIsRestablishedWithServer = (callback, pad) => {
|
|||
};
|
||||
|
||||
const forceReconnection = ($modal) => {
|
||||
$modal.find('#forcereconnect').click();
|
||||
$modal.find('#forcereconnect').trigger('click');
|
||||
};
|
||||
|
||||
const updateCountDownTimerMessage = ($modal, minutes, seconds) => {
|
||||
|
|
|
@ -31,7 +31,7 @@ const padconnectionstatus = (() => {
|
|||
|
||||
const self = {
|
||||
init: () => {
|
||||
$('button#forcereconnect').click(() => {
|
||||
$('button#forcereconnect').on('click', () => {
|
||||
window.location.reload();
|
||||
});
|
||||
},
|
||||
|
|
|
@ -65,13 +65,13 @@ class ToolbarItem {
|
|||
|
||||
bind(callback) {
|
||||
if (this.isButton()) {
|
||||
this.$el.click((event) => {
|
||||
$(':focus').blur();
|
||||
this.$el.on('click', (event) => {
|
||||
$(':focus').trigger('blur');
|
||||
callback(this.getCommand(), this);
|
||||
event.preventDefault();
|
||||
});
|
||||
} else if (this.isSelect()) {
|
||||
this.$el.find('select').change(() => {
|
||||
this.$el.find('select').on('change', () => {
|
||||
callback(this.getCommand(), this);
|
||||
});
|
||||
}
|
||||
|
@ -134,7 +134,7 @@ exports.padeditbar = new class {
|
|||
$('#editbar .editbarbutton').attr('unselectable', 'on'); // for IE
|
||||
this.enable();
|
||||
$('#editbar [data-key]').each((i, elt) => {
|
||||
$(elt).unbind('click');
|
||||
$(elt).off('click');
|
||||
new ToolbarItem($(elt)).bind((command, item) => {
|
||||
this.triggerCommand(command, item);
|
||||
});
|
||||
|
@ -144,11 +144,11 @@ exports.padeditbar = new class {
|
|||
this._bodyKeyEvent(evt);
|
||||
});
|
||||
|
||||
$('.show-more-icon-btn').click(() => {
|
||||
$('.show-more-icon-btn').on('click', () => {
|
||||
$('.toolbar').toggleClass('full-icons');
|
||||
});
|
||||
this.checkAllIconsAreDisplayedInToolbar();
|
||||
$(window).resize(_.debounce(() => this.checkAllIconsAreDisplayedInToolbar(), 100));
|
||||
$(window).on('resize', _.debounce(() => this.checkAllIconsAreDisplayedInToolbar(), 100));
|
||||
|
||||
this._registerDefaultCommands();
|
||||
|
||||
|
@ -168,7 +168,7 @@ exports.padeditbar = new class {
|
|||
}
|
||||
|
||||
// When editor is scrolled, we add a class to style the editbar differently
|
||||
$('iframe[name="ace_outer"]').contents().scroll((ev) => {
|
||||
$('iframe[name="ace_outer"]').contents().on('scroll', (ev) => {
|
||||
$('#editbar').toggleClass('editor-scrolled', $(ev.currentTarget).scrollTop() > 2);
|
||||
});
|
||||
}
|
||||
|
@ -305,12 +305,12 @@ exports.padeditbar = new class {
|
|||
// Close any dropdowns we have open..
|
||||
this.toggleDropDown('none');
|
||||
// Shift focus away from any drop downs
|
||||
$(':focus').blur(); // required to do not try to remove!
|
||||
$(':focus').trigger('blur'); // required to do not try to remove!
|
||||
// Check we're on a pad and not on the timeslider
|
||||
// Or some other window I haven't thought about!
|
||||
if (typeof pad === 'undefined') {
|
||||
// Timeslider probably..
|
||||
$('#editorcontainerbox').focus(); // Focus back onto the pad
|
||||
$('#editorcontainerbox').trigger('focus'); // Focus back onto the pad
|
||||
} else {
|
||||
padeditor.ace.focus(); // Sends focus back to pad
|
||||
// The above focus doesn't always work in FF, you have to hit enter afterwards
|
||||
|
@ -320,8 +320,8 @@ exports.padeditbar = new class {
|
|||
// Focus on the editbar :)
|
||||
const firstEditbarElement = parent.parent.$('#editbar button').first();
|
||||
|
||||
$(evt.currentTarget).blur();
|
||||
firstEditbarElement.focus();
|
||||
$(evt.currentTarget).trigger('blur');
|
||||
firstEditbarElement.trigger('focus');
|
||||
evt.preventDefault();
|
||||
}
|
||||
}
|
||||
|
@ -341,7 +341,7 @@ exports.padeditbar = new class {
|
|||
this._editbarPosition--;
|
||||
// Allow focus to shift back to end of row and start of row
|
||||
if (this._editbarPosition === -1) this._editbarPosition = focusItems.length - 1;
|
||||
$(focusItems[this._editbarPosition]).focus();
|
||||
$(focusItems[this._editbarPosition]).trigger('focus');
|
||||
}
|
||||
|
||||
// On right arrow move to next button in editbar
|
||||
|
@ -352,7 +352,7 @@ exports.padeditbar = new class {
|
|||
this._editbarPosition++;
|
||||
// Allow focus to shift back to end of row and start of row
|
||||
if (this._editbarPosition >= focusItems.length) this._editbarPosition = 0;
|
||||
$(focusItems[this._editbarPosition]).focus();
|
||||
$(focusItems[this._editbarPosition]).trigger('focus');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -366,7 +366,7 @@ exports.padeditbar = new class {
|
|||
|
||||
this.registerCommand('settings', () => {
|
||||
this.toggleDropDown('settings');
|
||||
$('#options-stickychat').focus();
|
||||
$('#options-stickychat').trigger('focus');
|
||||
});
|
||||
|
||||
this.registerCommand('import_export', () => {
|
||||
|
@ -374,22 +374,22 @@ exports.padeditbar = new class {
|
|||
// If Import file input exists then focus on it..
|
||||
if ($('#importfileinput').length !== 0) {
|
||||
setTimeout(() => {
|
||||
$('#importfileinput').focus();
|
||||
$('#importfileinput').trigger('focus');
|
||||
}, 100);
|
||||
} else {
|
||||
$('.exportlink').first().focus();
|
||||
$('.exportlink').first().trigger('focus');
|
||||
}
|
||||
});
|
||||
|
||||
this.registerCommand('showusers', () => {
|
||||
this.toggleDropDown('users');
|
||||
$('#myusernameedit').focus();
|
||||
$('#myusernameedit').trigger('focus');
|
||||
});
|
||||
|
||||
this.registerCommand('embed', () => {
|
||||
this.setEmbedLinks();
|
||||
this.toggleDropDown('embed');
|
||||
$('#linkinput').focus().select();
|
||||
$('#linkinput').trigger('focus').trigger('select');
|
||||
});
|
||||
|
||||
this.registerCommand('savedRevision', () => {
|
||||
|
|
|
@ -76,7 +76,7 @@ const padeditor = (() => {
|
|||
});
|
||||
|
||||
// font family change
|
||||
$('#viewfontmenu').change(() => {
|
||||
$('#viewfontmenu').on('change', () => {
|
||||
pad.changeViewOption('padFontFamily', $('#viewfontmenu').val());
|
||||
});
|
||||
|
||||
|
@ -97,7 +97,7 @@ const padeditor = (() => {
|
|||
});
|
||||
});
|
||||
$('#languagemenu').val(html10n.getLanguage());
|
||||
$('#languagemenu').change(() => {
|
||||
$('#languagemenu').on('change', () => {
|
||||
Cookies.set('language', $('#languagemenu').val());
|
||||
window.html10n.localize([$('#languagemenu').val(), 'en']);
|
||||
if ($('select').niceSelect) {
|
||||
|
|
|
@ -38,7 +38,7 @@ const padimpexp = (() => {
|
|||
const fileInputUpdated = () => {
|
||||
$('#importsubmitinput').addClass('throbbold');
|
||||
$('#importformfilediv').addClass('importformenabled');
|
||||
$('#importsubmitinput').removeAttr('disabled');
|
||||
$('#importsubmitinput').prop('disabled', false);
|
||||
$('#importmessagefail').fadeOut('fast');
|
||||
};
|
||||
|
||||
|
@ -69,8 +69,8 @@ const padimpexp = (() => {
|
|||
$('#import_export').removeClass('popup-show');
|
||||
if (directDatabaseAccess) window.location.reload();
|
||||
}
|
||||
$('#importsubmitinput').removeAttr('disabled').val(html10n.get('pad.impexp.importbutton'));
|
||||
window.setTimeout(() => $('#importfileinput').removeAttr('disabled'), 0);
|
||||
$('#importsubmitinput').prop('disabled', false).val(html10n.get('pad.impexp.importbutton'));
|
||||
window.setTimeout(() => $('#importfileinput').prop('disabled', false), 0);
|
||||
$('#importstatusball').hide();
|
||||
addImportFrames();
|
||||
})();
|
||||
|
@ -162,9 +162,9 @@ const padimpexp = (() => {
|
|||
}
|
||||
|
||||
addImportFrames();
|
||||
$('#importfileinput').change(fileInputUpdated);
|
||||
$('#importform').unbind('submit').submit(fileInputSubmit);
|
||||
$('.disabledexport').click(cantExport);
|
||||
$('#importfileinput').on('change', fileInputUpdated);
|
||||
$('#importform').off('submit').on('submit', fileInputSubmit);
|
||||
$('.disabledexport').on('click', cantExport);
|
||||
},
|
||||
disable: () => {
|
||||
$('#impexp-disabled-clickcatcher').show();
|
||||
|
|
|
@ -325,23 +325,23 @@ const paduserlist = (() => {
|
|||
};
|
||||
|
||||
const setUpEditable = (jqueryNode, valueGetter, valueSetter) => {
|
||||
jqueryNode.bind('focus', (evt) => {
|
||||
jqueryNode.on('focus', (evt) => {
|
||||
const oldValue = valueGetter();
|
||||
if (jqueryNode.val() !== oldValue) {
|
||||
jqueryNode.val(oldValue);
|
||||
}
|
||||
jqueryNode.addClass('editactive').removeClass('editempty');
|
||||
});
|
||||
jqueryNode.bind('blur', (evt) => {
|
||||
jqueryNode.on('blur', (evt) => {
|
||||
const newValue = jqueryNode.removeClass('editactive').val();
|
||||
valueSetter(newValue);
|
||||
});
|
||||
padutils.bindEnterAndEscape(jqueryNode, () => {
|
||||
jqueryNode.blur();
|
||||
jqueryNode.trigger('blur');
|
||||
}, () => {
|
||||
jqueryNode.val(valueGetter()).blur();
|
||||
jqueryNode.val(valueGetter()).trigger('blur');
|
||||
});
|
||||
jqueryNode.removeAttr('disabled').addClass('editable');
|
||||
jqueryNode.prop('disabled', false).addClass('editable');
|
||||
};
|
||||
|
||||
let pad = undefined;
|
||||
|
@ -369,15 +369,15 @@ const paduserlist = (() => {
|
|||
});
|
||||
|
||||
// color picker
|
||||
$('#myswatchbox').click(showColorPicker);
|
||||
$('#mycolorpicker .pickerswatchouter').click(function () {
|
||||
$('#myswatchbox').on('click', showColorPicker);
|
||||
$('#mycolorpicker .pickerswatchouter').on('click', function () {
|
||||
$('#mycolorpicker .pickerswatchouter').removeClass('picked');
|
||||
$(this).addClass('picked');
|
||||
});
|
||||
$('#mycolorpickersave').click(() => {
|
||||
$('#mycolorpickersave').on('click', () => {
|
||||
closeColorPicker(true);
|
||||
});
|
||||
$('#mycolorpickercancel').click(() => {
|
||||
$('#mycolorpickercancel').on('click', () => {
|
||||
closeColorPicker(false);
|
||||
});
|
||||
//
|
||||
|
@ -587,7 +587,7 @@ const showColorPicker = () => {
|
|||
|
||||
li.appendTo(colorsList);
|
||||
|
||||
li.bind('click', (event) => {
|
||||
li.on('click', (event) => {
|
||||
$('#colorpickerswatches li').removeClass('picked');
|
||||
$(event.target).addClass('picked');
|
||||
|
||||
|
|
|
@ -224,7 +224,7 @@ const padutils = {
|
|||
// It is work on Windows (IE8, Chrome 6.0.472), CentOs (Firefox 3.0) and Mac OSX (Firefox
|
||||
// 3.6.10, Chrome 6.0.472, Safari 5.0).
|
||||
if (onEnter) {
|
||||
node.keypress((evt) => {
|
||||
node.on('keypress', (evt) => {
|
||||
if (evt.which === 13) {
|
||||
onEnter(evt);
|
||||
}
|
||||
|
@ -232,7 +232,7 @@ const padutils = {
|
|||
}
|
||||
|
||||
if (onEscape) {
|
||||
node.keydown((evt) => {
|
||||
node.on('keydown', (evt) => {
|
||||
if (evt.which === 27) {
|
||||
onEscape(evt);
|
||||
}
|
||||
|
@ -299,7 +299,7 @@ const padutils = {
|
|||
}
|
||||
field.removeClass('editempty');
|
||||
});
|
||||
field.blur(() => {
|
||||
field.on('blur', () => {
|
||||
if (!field.val()) {
|
||||
clear();
|
||||
}
|
||||
|
@ -313,11 +313,11 @@ const padutils = {
|
|||
if (value) {
|
||||
$(node).attr('checked', 'checked');
|
||||
} else {
|
||||
$(node).removeAttr('checked');
|
||||
$(node).prop('checked', false);
|
||||
}
|
||||
},
|
||||
bindCheckboxChange: (node, func) => {
|
||||
$(node).change(func);
|
||||
$(node).on('change', func);
|
||||
},
|
||||
encodeUserId: (userId) => userId.replace(/[^a-y0-9]/g, (c) => {
|
||||
if (c === '.') return '-';
|
||||
|
|
|
@ -3,9 +3,9 @@
|
|||
const log4js = require('log4js');
|
||||
const plugins = require('./plugins');
|
||||
const hooks = require('./hooks');
|
||||
const request = require('request');
|
||||
const runCmd = require('../../../node/utils/run_cmd');
|
||||
const settings = require('../../../node/utils/Settings');
|
||||
const axios = require('axios');
|
||||
|
||||
const logger = log4js.getLogger('plugins');
|
||||
|
||||
|
@ -71,28 +71,20 @@ let cacheTimestamp = 0;
|
|||
exports.getAvailablePlugins = (maxCacheAge) => {
|
||||
const nowTimestamp = Math.round(Date.now() / 1000);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
// check cache age before making any request
|
||||
if (exports.availablePlugins && maxCacheAge && (nowTimestamp - cacheTimestamp) <= maxCacheAge) {
|
||||
return resolve(exports.availablePlugins);
|
||||
}
|
||||
|
||||
request('https://static.etherpad.org/plugins.json', (er, response, plugins) => {
|
||||
if (er) return reject(er);
|
||||
|
||||
try {
|
||||
plugins = JSON.parse(plugins);
|
||||
} catch (err) {
|
||||
logger.error(`error parsing plugins.json: ${err.stack || err}`);
|
||||
plugins = [];
|
||||
}
|
||||
|
||||
exports.availablePlugins = plugins;
|
||||
await axios.get('https://static.etherpad.org/plugins.json')
|
||||
.then(pluginsLoaded => {
|
||||
exports.availablePlugins = pluginsLoaded.data;
|
||||
cacheTimestamp = nowTimestamp;
|
||||
resolve(plugins);
|
||||
});
|
||||
});
|
||||
};
|
||||
resolve(exports.availablePlugins);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
exports.search = (searchTerm, maxCacheAge) => exports.getAvailablePlugins(maxCacheAge).then(
|
||||
|
|
|
@ -46,7 +46,7 @@ if (window.location.hash.toLowerCase() === '#skinvariantsbuilder') {
|
|||
$('#skin-variant-full-width').prop('checked', $('html').hasClass('full-width-editor'));
|
||||
};
|
||||
|
||||
$('.skin-variant').change(() => {
|
||||
$('.skin-variant').on('change', () => {
|
||||
updateSkinVariantsClasses();
|
||||
});
|
||||
|
||||
|
|
|
@ -82,7 +82,7 @@ const init = () => {
|
|||
// get all the export links
|
||||
exportLinks = $('#export > .exportlink');
|
||||
|
||||
$('button#forcereconnect').click(() => {
|
||||
$('button#forcereconnect').on('click', () => {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
|
@ -159,7 +159,7 @@ const handleClientVars = (message) => {
|
|||
$('#rightstep').attr('title', html10n.get('timeslider.forwardRevision'));
|
||||
|
||||
// font family change
|
||||
$('#viewfontmenu').change(function () {
|
||||
$('#viewfontmenu').on('change', function () {
|
||||
$('#innerdocbody').css('font-family', $(this).val() || '');
|
||||
});
|
||||
};
|
||||
|
|
12
src/static/js/vendors/farbtastic.js
vendored
12
src/static/js/vendors/farbtastic.js
vendored
|
@ -33,7 +33,7 @@ $._farbtastic = function (container, options) {
|
|||
fb.linkTo = function (callback) {
|
||||
// Unbind previous nodes
|
||||
if (typeof fb.callback == 'object') {
|
||||
$(fb.callback).unbind('keyup', fb.updateValue);
|
||||
$(fb.callback).off('keyup').on('keyup', fb.updateValue);
|
||||
}
|
||||
|
||||
// Reset color
|
||||
|
@ -45,7 +45,7 @@ $._farbtastic = function (container, options) {
|
|||
}
|
||||
else if (typeof callback == 'object' || typeof callback == 'string') {
|
||||
fb.callback = $(callback);
|
||||
fb.callback.bind('keyup', fb.updateValue);
|
||||
fb.callback.on('keyup', fb.updateValue);
|
||||
if (fb.callback[0].value) {
|
||||
fb.setColor(fb.callback[0].value);
|
||||
}
|
||||
|
@ -388,7 +388,7 @@ $._farbtastic = function (container, options) {
|
|||
fb.mousedown = function (event) {
|
||||
// Capture mouse
|
||||
if (!$._farbtastic.dragging) {
|
||||
$(document).bind('mousemove', fb.mousemove).bind('mouseup', fb.mouseup);
|
||||
$(document).on('mousemove', fb.mousemove).on('mouseup', fb.mouseup);
|
||||
$._farbtastic.dragging = true;
|
||||
}
|
||||
|
||||
|
@ -429,8 +429,8 @@ $._farbtastic = function (container, options) {
|
|||
*/
|
||||
fb.mouseup = function () {
|
||||
// Uncapture mouse
|
||||
$(document).unbind('mousemove', fb.mousemove);
|
||||
$(document).unbind('mouseup', fb.mouseup);
|
||||
$(document).off('mousemove', fb.mousemove);
|
||||
$(document).off('mouseup', fb.mouseup);
|
||||
$._farbtastic.dragging = false;
|
||||
}
|
||||
|
||||
|
@ -519,7 +519,7 @@ $._farbtastic = function (container, options) {
|
|||
fb.initWidget();
|
||||
|
||||
// Install mousedown handler (the others are set on the document on-demand)
|
||||
$('canvas.farbtastic-overlay', container).mousedown(fb.mousedown);
|
||||
$('canvas.farbtastic-overlay', container).on('mousedown',fb.mousedown);
|
||||
|
||||
// Set linked elements/callback
|
||||
if (options.callback) {
|
||||
|
|
5315
src/static/js/vendors/jquery.js
vendored
5315
src/static/js/vendors/jquery.js
vendored
File diff suppressed because it is too large
Load diff
2
src/static/js/vendors/nice-select.js
vendored
2
src/static/js/vendors/nice-select.js
vendored
|
@ -123,7 +123,7 @@
|
|||
$dropdown.find('.list').css('max-height', $maxListHeight + 'px');
|
||||
|
||||
} else {
|
||||
$dropdown.focus();
|
||||
$dropdown.trigger('focus');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -2,6 +2,6 @@
|
|||
|
||||
window.customStart = () => {
|
||||
$('#pad_title').show();
|
||||
$('.buttonicon').mousedown(function () { $(this).parent().addClass('pressed'); });
|
||||
$('.buttonicon').mouseup(function () { $(this).parent().removeClass('pressed'); });
|
||||
$('.buttonicon').on('mousedown', function () { $(this).parent().addClass('pressed'); });
|
||||
$('.buttonicon').on('mouseup', function () { $(this).parent().removeClass('pressed'); });
|
||||
};
|
||||
|
|
|
@ -122,7 +122,7 @@
|
|||
<% e.begin_block("mySettings"); %>
|
||||
<h2 data-l10n-id="pad.settings.myView"></h2>
|
||||
<p class="hide-for-mobile">
|
||||
<input type="checkbox" id="options-stickychat" onClick="chat.stickToScreen();">
|
||||
<input type="checkbox" id="options-stickychat">
|
||||
<label for="options-stickychat" data-l10n-id="pad.settings.stickychat"></label>
|
||||
</p>
|
||||
<p class="hide-for-mobile">
|
||||
|
|
|
@ -4,10 +4,9 @@
|
|||
*/
|
||||
const common = require('./common');
|
||||
const host = `http://${settings.ip}:${settings.port}`;
|
||||
const request = require('request');
|
||||
const froth = require('mocha-froth');
|
||||
const settings = require('../container/loadSettings').loadSettings();
|
||||
|
||||
const axios = require('axios');
|
||||
const apiKey = common.apiKey;
|
||||
const apiVersion = 1;
|
||||
const testPadId = `TEST_fuzz${makeid()}`;
|
||||
|
@ -23,22 +22,18 @@ console.log('Tests will start in 5 seconds, click the URL now!');
|
|||
|
||||
setTimeout(() => {
|
||||
for (let i = 1; i < 1000000; i++) { // 1M runs
|
||||
setTimeout(() => {
|
||||
runTest(i);
|
||||
setTimeout(async () => {
|
||||
await runTest(i);
|
||||
}, i * 100); // 100 ms
|
||||
}
|
||||
}, 5000); // wait 5 seconds
|
||||
|
||||
function runTest(number) {
|
||||
request(`${host + endPoint('createPad')}&padID=${testPadId}`, (err, res, body) => {
|
||||
const req = request.post(`${host}/p/${testPadId}/import`, (err, res, body) => {
|
||||
if (err) {
|
||||
throw new Error('FAILURE', err);
|
||||
} else {
|
||||
async function runTest(number) {
|
||||
await axios.get(`${host + endPoint('createPad')}&padID=${testPadId}`)
|
||||
.then(() => {
|
||||
const req = axios.post(`${host}/p/${testPadId}/import`)
|
||||
.then(() => {
|
||||
console.log('Success');
|
||||
}
|
||||
});
|
||||
|
||||
let fN = '/test.txt';
|
||||
let cT = 'text/plain';
|
||||
|
||||
|
@ -55,6 +50,10 @@ function runTest(number) {
|
|||
contentType: cT,
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
throw new Error('FAILURE', err);
|
||||
})
|
||||
}
|
||||
|
||||
function makeid() {
|
||||
|
|
555
src/tests/backend/specs/SecretRotator.js
Normal file
555
src/tests/backend/specs/SecretRotator.js
Normal file
|
@ -0,0 +1,555 @@
|
|||
'use strict';
|
||||
|
||||
const SecretRotator = require('../../../node/security/SecretRotator');
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../common');
|
||||
const crypto = require('../../../node/security/crypto');
|
||||
const db = require('../../../node/db/DB');
|
||||
|
||||
const logger = common.logger;
|
||||
|
||||
// Greatest common divisor.
|
||||
const gcd = (...args) => (
|
||||
args.length === 1 ? args[0]
|
||||
: args.length === 2 ? ((args[1]) ? gcd(args[1], args[0] % args[1]) : Math.abs(args[0]))
|
||||
: gcd(args[0], gcd(...args.slice(1))));
|
||||
// Least common multiple.
|
||||
const lcm = (...args) => (
|
||||
args.length === 1 ? args[0]
|
||||
: args.length === 2 ? Math.abs(args[0] * args[1]) / gcd(...args)
|
||||
: lcm(args[0], lcm(...args.slice(1))));
|
||||
|
||||
class FakeClock {
|
||||
constructor() {
|
||||
logger.debug('new fake clock');
|
||||
this._now = 0;
|
||||
this._nextId = 1;
|
||||
this._idle = Promise.resolve();
|
||||
this.timeouts = new Map();
|
||||
}
|
||||
|
||||
_next() { return Math.min(...[...this.timeouts.values()].map((x) => x.when)); }
|
||||
async setNow(t) {
|
||||
logger.debug(`setting fake time to ${t}`);
|
||||
assert(t >= this._now);
|
||||
assert(t < Infinity);
|
||||
let n;
|
||||
while ((n = this._next()) <= t) {
|
||||
this._now = Math.max(this._now, Math.min(n, t));
|
||||
logger.debug(`fake time set to ${this._now}; firing timeouts...`);
|
||||
await this._fire();
|
||||
}
|
||||
this._now = t;
|
||||
logger.debug(`fake time set to ${this._now}`);
|
||||
}
|
||||
async advance(t) { await this.setNow(this._now + t); }
|
||||
async advanceToNext() {
|
||||
const n = this._next();
|
||||
if (n < this._now) await this._fire();
|
||||
else if (n < Infinity) await this.setNow(n);
|
||||
}
|
||||
async _fire() {
|
||||
// This method MUST NOT execute any of the setTimeout callbacks synchronously, otherwise
|
||||
// fc.setTimeout(fn, 0) would execute fn before fc.setTimeout() returns. Fortunately, the
|
||||
// ECMAScript standard guarantees that a function passed to Promise.prototype.then() will run
|
||||
// asynchronously.
|
||||
this._idle = this._idle.then(() => Promise.all(
|
||||
[...this.timeouts.values()]
|
||||
.filter(({when}) => when <= this._now)
|
||||
.sort((a, b) => a.when - b.when)
|
||||
.map(async ({id, fn}) => {
|
||||
this.clearTimeout(id);
|
||||
// With the standard setTimeout(), the callback function's return value is ignored.
|
||||
// Here we await the return value so that test code can block until timeout work is
|
||||
// done.
|
||||
await fn();
|
||||
})));
|
||||
await this._idle;
|
||||
}
|
||||
|
||||
get now() { return this._now; }
|
||||
setTimeout(fn, wait = 0) {
|
||||
const when = this._now + wait;
|
||||
const id = this._nextId++;
|
||||
this.timeouts.set(id, {id, fn, when});
|
||||
this._fire();
|
||||
return id;
|
||||
}
|
||||
clearTimeout(id) { this.timeouts.delete(id); }
|
||||
}
|
||||
|
||||
// In JavaScript, the % operator is remainder, not modulus.
|
||||
const mod = (a, n) => ((a % n) + n) % n;
|
||||
|
||||
describe(__filename, function () {
|
||||
let dbPrefix;
|
||||
let sr;
|
||||
let interval = 1e3;
|
||||
const lifetime = 1e4;
|
||||
const intervalStart = (t) => t - mod(t, interval);
|
||||
const hkdf = async (secret, salt, tN) => Buffer.from(
|
||||
await crypto.hkdf('sha256', secret, salt, `${tN}`, 32)).toString('hex');
|
||||
|
||||
const newRotator = (s = null) => new SecretRotator(dbPrefix, interval, lifetime, s);
|
||||
|
||||
const setFakeClock = (sr, fc = null) => {
|
||||
if (fc == null) fc = new FakeClock();
|
||||
sr._t = {
|
||||
now: () => fc.now,
|
||||
setTimeout: fc.setTimeout.bind(fc),
|
||||
clearTimeout: fc.clearTimeout.bind(fc),
|
||||
};
|
||||
return fc;
|
||||
};
|
||||
|
||||
before(async function () {
|
||||
await common.init();
|
||||
});
|
||||
|
||||
beforeEach(async function () {
|
||||
dbPrefix = `test-SecretRotator-${common.randomString()}`;
|
||||
interval = 1e3;
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
if (sr != null) sr.stop();
|
||||
sr = null;
|
||||
await Promise.all(
|
||||
(await db.findKeys(`${dbPrefix}:*`, null)).map(async (dbKey) => await db.remove(dbKey)));
|
||||
});
|
||||
|
||||
describe('constructor', function () {
|
||||
it('creates empty secrets array', async function () {
|
||||
sr = newRotator();
|
||||
assert.deepEqual(sr.secrets, []);
|
||||
});
|
||||
|
||||
for (const invalidChar of '*:%') {
|
||||
it(`rejects database prefixes containing ${invalidChar}`, async function () {
|
||||
dbPrefix += invalidChar;
|
||||
assert.throws(newRotator, /invalid char/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('start', function () {
|
||||
it('does not replace secrets array', async function () {
|
||||
sr = newRotator();
|
||||
setFakeClock(sr);
|
||||
const {secrets} = sr;
|
||||
await sr.start();
|
||||
assert.equal(sr.secrets, secrets);
|
||||
});
|
||||
|
||||
it('derives secrets', async function () {
|
||||
sr = newRotator();
|
||||
setFakeClock(sr);
|
||||
await sr.start();
|
||||
assert.equal(sr.secrets.length, 3); // Current (active), previous, and next.
|
||||
for (const s of sr.secrets) {
|
||||
assert.equal(typeof s, 'string');
|
||||
assert(s);
|
||||
}
|
||||
assert.equal(new Set(sr.secrets).size, sr.secrets.length); // The secrets should all differ.
|
||||
});
|
||||
|
||||
it('publishes params', async function () {
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
await sr.start();
|
||||
const dbKeys = await db.findKeys(`${dbPrefix}:*`, null);
|
||||
assert.equal(dbKeys.length, 1);
|
||||
const [id] = dbKeys;
|
||||
assert(id.startsWith(`${dbPrefix}:`));
|
||||
assert.notEqual(id.slice(dbPrefix.length + 1), '');
|
||||
const p = await db.get(id);
|
||||
const {secret, salt} = p.algParams;
|
||||
assert.deepEqual(p, {
|
||||
algId: 1,
|
||||
algParams: {
|
||||
digest: 'sha256',
|
||||
keyLen: 32,
|
||||
salt,
|
||||
secret,
|
||||
},
|
||||
start: fc.now,
|
||||
end: fc.now + (2 * interval),
|
||||
interval,
|
||||
lifetime,
|
||||
});
|
||||
assert.equal(typeof salt, 'string');
|
||||
assert.match(salt, /^[0-9a-f]{64}$/);
|
||||
assert.equal(typeof secret, 'string');
|
||||
assert.match(secret, /^[0-9a-f]{64}$/);
|
||||
assert.deepEqual(sr.secrets, await Promise.all(
|
||||
[0, -interval, interval].map(async (tN) => await hkdf(secret, salt, tN))));
|
||||
});
|
||||
|
||||
it('reuses matching publication if unexpired', async function () {
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
await sr.start();
|
||||
const {secrets} = sr;
|
||||
const dbKeys = await db.findKeys(`${dbPrefix}:*`, null);
|
||||
sr.stop();
|
||||
sr = newRotator();
|
||||
setFakeClock(sr, fc);
|
||||
await sr.start();
|
||||
assert.deepEqual(sr.secrets, secrets);
|
||||
assert.deepEqual(await db.findKeys(`${dbPrefix}:*`, null), dbKeys);
|
||||
});
|
||||
|
||||
it('deletes expired publications', async function () {
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
await sr.start();
|
||||
const [oldId] = await db.findKeys(`${dbPrefix}:*`, null);
|
||||
assert(oldId != null);
|
||||
sr.stop();
|
||||
const p = await db.get(oldId);
|
||||
await fc.setNow(p.end + p.lifetime + p.interval);
|
||||
sr = newRotator();
|
||||
setFakeClock(sr, fc);
|
||||
await sr.start();
|
||||
const ids = await db.findKeys(`${dbPrefix}:*`, null);
|
||||
assert.equal(ids.length, 1);
|
||||
const [newId] = ids;
|
||||
assert.notEqual(newId, oldId);
|
||||
});
|
||||
|
||||
it('keeps expired publications until interval past expiration', async function () {
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
await sr.start();
|
||||
const [, , future] = sr.secrets;
|
||||
sr.stop();
|
||||
const [origId] = await db.findKeys(`${dbPrefix}:*`, null);
|
||||
const p = await db.get(origId);
|
||||
await fc.advance(p.end + p.lifetime + p.interval - 1);
|
||||
sr = newRotator();
|
||||
setFakeClock(sr, fc);
|
||||
await sr.start();
|
||||
assert(sr.secrets.slice(1).includes(future));
|
||||
// It should have created a new publication, not extended the life of the old publication.
|
||||
assert.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 2);
|
||||
assert.deepEqual(await db.get(origId), p);
|
||||
});
|
||||
|
||||
it('idempotent', async function () {
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
await sr.start();
|
||||
assert.equal(fc.timeouts.size, 1);
|
||||
const secrets = [...sr.secrets];
|
||||
const dbKeys = await db.findKeys(`${dbPrefix}:*`, null);
|
||||
await sr.start();
|
||||
assert.equal(fc.timeouts.size, 1);
|
||||
assert.deepEqual(sr.secrets, secrets);
|
||||
assert.deepEqual(await db.findKeys(`${dbPrefix}:*`, null), dbKeys);
|
||||
});
|
||||
|
||||
describe(`schedules update at next interval (= ${interval})`, function () {
|
||||
const testCases = [
|
||||
{now: 0, want: interval},
|
||||
{now: 1, want: interval},
|
||||
{now: interval - 1, want: interval},
|
||||
{now: interval, want: 2 * interval},
|
||||
{now: interval + 1, want: 2 * interval},
|
||||
];
|
||||
for (const {now, want} of testCases) {
|
||||
it(`${now} -> ${want}`, async function () {
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
await fc.setNow(now);
|
||||
await sr.start();
|
||||
assert.equal(fc.timeouts.size, 1);
|
||||
const [{when}] = fc.timeouts.values();
|
||||
assert.equal(when, want);
|
||||
});
|
||||
}
|
||||
|
||||
it('multiple active params with different intervals', async function () {
|
||||
const intervals = [400, 600, 1000];
|
||||
const lcmi = lcm(...intervals);
|
||||
const wants = new Set();
|
||||
for (const i of intervals) for (let t = i; t <= lcmi; t += i) wants.add(t);
|
||||
const fcs = new FakeClock();
|
||||
const srs = intervals.map((i) => {
|
||||
interval = i;
|
||||
const sr = newRotator();
|
||||
setFakeClock(sr, fcs);
|
||||
return sr;
|
||||
});
|
||||
try {
|
||||
for (const sr of srs) await sr.start(); // Don't use Promise.all() otherwise they race.
|
||||
interval = intervals[intervals.length - 1];
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr); // Independent clock to test a single instance's behavior.
|
||||
await sr.start();
|
||||
for (const want of [...wants].sort((a, b) => a - b)) {
|
||||
logger.debug(`next timeout should be at ${want}`);
|
||||
await fc.advanceToNext();
|
||||
await fcs.setNow(fc.now); // Keep all of the publications alive.
|
||||
assert.equal(fc.now, want);
|
||||
}
|
||||
} finally {
|
||||
for (const sr of srs) sr.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop', function () {
|
||||
it('clears timeout', async function () {
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
await sr.start();
|
||||
assert.notEqual(fc.timeouts.size, 0);
|
||||
sr.stop();
|
||||
assert.equal(fc.timeouts.size, 0);
|
||||
});
|
||||
|
||||
it('safe to call multiple times', async function () {
|
||||
sr = newRotator();
|
||||
setFakeClock(sr);
|
||||
await sr.start();
|
||||
sr.stop();
|
||||
sr.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy secret', function () {
|
||||
it('ends at now if there are no previously published secrets', async function () {
|
||||
sr = newRotator('legacy');
|
||||
const fc = setFakeClock(sr);
|
||||
// Use a time that isn't a multiple of interval in case there is a modular arithmetic bug that
|
||||
// would otherwise go undetected.
|
||||
await fc.setNow(1);
|
||||
assert(mod(fc.now, interval) !== 0);
|
||||
await sr.start();
|
||||
assert.equal(sr.secrets.length, 4); // 1 for the legacy secret, 3 for past, current, future
|
||||
assert(sr.secrets.slice(1).includes('legacy')); // Should not be the current secret.
|
||||
const ids = await db.findKeys(`${dbPrefix}:*`, null);
|
||||
const params = (await Promise.all(ids.map(async (id) => await db.get(id))))
|
||||
.sort((a, b) => a.algId - b.algId);
|
||||
assert.deepEqual(params, [
|
||||
{
|
||||
algId: 0,
|
||||
algParams: 'legacy',
|
||||
// The start time must equal the end time so that legacy secrets do not affect the end
|
||||
// times of legacy secrets published by other instances.
|
||||
start: fc.now,
|
||||
end: fc.now,
|
||||
lifetime,
|
||||
interval: null,
|
||||
},
|
||||
{
|
||||
algId: 1,
|
||||
algParams: params[1].algParams,
|
||||
start: fc.now,
|
||||
end: intervalStart(fc.now) + (2 * interval),
|
||||
interval,
|
||||
lifetime,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ends at the start of the oldest previously published secret', async function () {
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
await fc.setNow(1);
|
||||
assert(mod(fc.now, interval) !== 0);
|
||||
const wantTime = fc.now;
|
||||
await sr.start();
|
||||
assert.equal(sr.secrets.length, 3);
|
||||
const [s1, s0, s2] = sr.secrets; // s1=current, s0=previous, s2=next
|
||||
sr.stop();
|
||||
// Use a time that is not a multiple of interval off of epoch or wantTime just in case there
|
||||
// is a modular arithmetic bug that would otherwise go undetected.
|
||||
await fc.advance(interval + 1);
|
||||
assert(mod(fc.now, interval) !== 0);
|
||||
assert(mod(fc.now - wantTime, interval) !== 0);
|
||||
sr = newRotator('legacy');
|
||||
setFakeClock(sr, fc);
|
||||
await sr.start();
|
||||
assert.equal(sr.secrets.length, 5); // s0 through s3 and the legacy secret.
|
||||
assert.deepEqual(sr.secrets, [s2, s1, s0, sr.secrets[3], 'legacy']);
|
||||
const ids = await db.findKeys(`${dbPrefix}:*`, null);
|
||||
const params = (await Promise.all(ids.map(async (id) => await db.get(id))))
|
||||
.sort((a, b) => a.algId - b.algId);
|
||||
assert.deepEqual(params, [
|
||||
{
|
||||
algId: 0,
|
||||
algParams: 'legacy',
|
||||
start: wantTime,
|
||||
end: wantTime,
|
||||
interval: null,
|
||||
lifetime,
|
||||
},
|
||||
{
|
||||
algId: 1,
|
||||
algParams: params[1].algParams,
|
||||
start: wantTime,
|
||||
end: intervalStart(fc.now) + (2 * interval),
|
||||
interval,
|
||||
lifetime,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('multiple instances with different legacy secrets', async function () {
|
||||
sr = newRotator('legacy1');
|
||||
const fc = setFakeClock(sr);
|
||||
await sr.start();
|
||||
sr.stop();
|
||||
sr = newRotator('legacy2');
|
||||
setFakeClock(sr, fc);
|
||||
await sr.start();
|
||||
assert(sr.secrets.slice(1).includes('legacy1'));
|
||||
assert(sr.secrets.slice(1).includes('legacy2'));
|
||||
});
|
||||
|
||||
it('multiple instances with the same legacy secret', async function () {
|
||||
sr = newRotator('legacy');
|
||||
const fc = setFakeClock(sr);
|
||||
await sr.start();
|
||||
sr.stop();
|
||||
sr = newRotator('legacy');
|
||||
setFakeClock(sr, fc);
|
||||
await sr.start();
|
||||
assert.deepEqual(sr.secrets, [...new Set(sr.secrets)]);
|
||||
// There shouldn't be multiple publications for the same legacy secret.
|
||||
assert.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 2);
|
||||
});
|
||||
|
||||
it('legacy secret is included for interval after expiration', async function () {
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
await sr.start();
|
||||
sr.stop();
|
||||
await fc.advance(lifetime + interval - 1);
|
||||
sr = newRotator('legacy');
|
||||
setFakeClock(sr, fc);
|
||||
await sr.start();
|
||||
assert(sr.secrets.slice(1).includes('legacy'));
|
||||
});
|
||||
|
||||
it('legacy secret is not included if the oldest secret is old enough', async function () {
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
await sr.start();
|
||||
sr.stop();
|
||||
await fc.advance(lifetime + interval);
|
||||
sr = newRotator('legacy');
|
||||
setFakeClock(sr, fc);
|
||||
await sr.start();
|
||||
assert(!sr.secrets.includes('legacy'));
|
||||
});
|
||||
|
||||
it('dead secrets still affect legacy secret end time', async function () {
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
await sr.start();
|
||||
const secrets = new Set(sr.secrets);
|
||||
sr.stop();
|
||||
await fc.advance(lifetime + (3 * interval));
|
||||
sr = newRotator('legacy');
|
||||
setFakeClock(sr, fc);
|
||||
await sr.start();
|
||||
assert(!sr.secrets.includes('legacy'));
|
||||
assert(!sr.secrets.some((s) => secrets.has(s)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotation', function () {
|
||||
it('no rotation before start of interval', async function () {
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
assert.equal(fc.now, 0);
|
||||
await sr.start();
|
||||
const secrets = [...sr.secrets];
|
||||
await fc.advance(interval - 1);
|
||||
assert.deepEqual(sr.secrets, secrets);
|
||||
});
|
||||
|
||||
it('does not replace secrets array', async function () {
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
await sr.start();
|
||||
const [current] = sr.secrets;
|
||||
const secrets = sr.secrets;
|
||||
await fc.advance(interval);
|
||||
assert.notEqual(sr.secrets[0], current);
|
||||
assert.equal(sr.secrets, secrets);
|
||||
});
|
||||
|
||||
it('future secret becomes current, new future is generated', async function () {
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
await sr.start();
|
||||
const secrets = new Set(sr.secrets);
|
||||
assert.equal(secrets.size, 3);
|
||||
const [s1, s0, s2] = sr.secrets;
|
||||
await fc.advance(interval);
|
||||
assert.deepEqual(sr.secrets, [s2, s1, s0, sr.secrets[3]]);
|
||||
assert(!secrets.has(sr.secrets[3]));
|
||||
});
|
||||
|
||||
it('expired publications are deleted', async function () {
|
||||
const origInterval = interval;
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
await sr.start();
|
||||
sr.stop();
|
||||
++interval; // Force new params so that the old params can expire.
|
||||
sr = newRotator();
|
||||
setFakeClock(sr, fc);
|
||||
await sr.start();
|
||||
assert.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 2);
|
||||
await fc.advance(lifetime + (3 * origInterval));
|
||||
assert.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 1);
|
||||
});
|
||||
|
||||
it('old secrets are eventually removed', async function () {
|
||||
sr = newRotator();
|
||||
const fc = setFakeClock(sr);
|
||||
await sr.start();
|
||||
const [, s0] = sr.secrets;
|
||||
await fc.advance(lifetime + interval - 1);
|
||||
assert(sr.secrets.slice(1).includes(s0));
|
||||
await fc.advance(1);
|
||||
assert(!sr.secrets.includes(s0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('clock skew', function () {
|
||||
it('out of sync works if in adjacent interval', async function () {
|
||||
const srs = [newRotator(), newRotator()];
|
||||
const fcs = srs.map((sr) => setFakeClock(sr));
|
||||
for (const sr of srs) await sr.start(); // Don't use Promise.all() otherwise they race.
|
||||
assert.deepEqual(srs[0].secrets, srs[1].secrets);
|
||||
// Advance fcs[0] to the end of the interval after fcs[1].
|
||||
await fcs[0].advance((2 * interval) - 1);
|
||||
assert(srs[0].secrets.includes(srs[1].secrets[0]));
|
||||
assert(srs[1].secrets.includes(srs[0].secrets[0]));
|
||||
// Advance both by an interval.
|
||||
await Promise.all([fcs[1].advance(interval), fcs[0].advance(interval)]);
|
||||
assert(srs[0].secrets.includes(srs[1].secrets[0]));
|
||||
assert(srs[1].secrets.includes(srs[0].secrets[0]));
|
||||
// Advance fcs[1] to the end of the interval after fcs[0].
|
||||
await Promise.all([fcs[1].advance((3 * interval) - 1), fcs[0].advance(1)]);
|
||||
assert(srs[0].secrets.includes(srs[1].secrets[0]));
|
||||
assert(srs[1].secrets.includes(srs[0].secrets[0]));
|
||||
});
|
||||
|
||||
it('start up out of sync', async function () {
|
||||
const srs = [newRotator(), newRotator()];
|
||||
const fcs = srs.map((sr) => setFakeClock(sr));
|
||||
await fcs[0].advance((2 * interval) - 1);
|
||||
await srs[0].start(); // Must start before srs[1] so that srs[1] starts in srs[0]'s past.
|
||||
await srs[1].start();
|
||||
assert(srs[0].secrets.includes(srs[1].secrets[0]));
|
||||
assert(srs[1].secrets.includes(srs[0].secrets[0]));
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,6 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
const common = require('../../common');
|
||||
const assert = require('assert').strict;
|
||||
|
||||
let agent;
|
||||
const apiKey = common.apiKey;
|
||||
|
@ -15,14 +16,14 @@ describe(__filename, function () {
|
|||
before(async function () { agent = await common.init(); });
|
||||
|
||||
describe('API Versioning', function () {
|
||||
it('errors if can not connect', function (done) {
|
||||
agent.get('/api/')
|
||||
it('errors if can not connect', async function () {
|
||||
await agent.get('/api/')
|
||||
.expect((res) => {
|
||||
apiVersion = res.body.currentVersion;
|
||||
if (!res.body.currentVersion) throw new Error('No version set in API');
|
||||
return;
|
||||
})
|
||||
.expect(200, done);
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -38,20 +39,18 @@ describe(__filename, function () {
|
|||
-> getChatHistory(padID)
|
||||
*/
|
||||
|
||||
describe('createPad', function () {
|
||||
it('creates a new Pad', function (done) {
|
||||
agent.get(`${endPoint('createPad')}&padID=${padID}`)
|
||||
describe('Chat functionality', function () {
|
||||
it('creates a new Pad', async function () {
|
||||
await agent.get(`${endPoint('createPad')}&padID=${padID}`)
|
||||
.expect((res) => {
|
||||
if (res.body.code !== 0) throw new Error('Unable to create new Pad');
|
||||
})
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200, done);
|
||||
});
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
describe('createAuthor', function () {
|
||||
it('Creates an author with a name set', function (done) {
|
||||
agent.get(endPoint('createAuthor'))
|
||||
it('Creates an author with a name set', async function () {
|
||||
await agent.get(endPoint('createAuthor'))
|
||||
.expect((res) => {
|
||||
if (res.body.code !== 0 || !res.body.data.authorID) {
|
||||
throw new Error('Unable to create author');
|
||||
|
@ -59,47 +58,51 @@ describe(__filename, function () {
|
|||
authorID = res.body.data.authorID; // we will be this author for the rest of the tests
|
||||
})
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200, done);
|
||||
});
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
describe('appendChatMessage', function () {
|
||||
it('Adds a chat message to the pad', function (done) {
|
||||
agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` +
|
||||
it('Gets the head of chat before the first chat msg', async function () {
|
||||
await agent.get(`${endPoint('getChatHead')}&padID=${padID}`)
|
||||
.expect((res) => {
|
||||
if (res.body.data.chatHead !== -1) throw new Error('Chat Head Length is wrong');
|
||||
if (res.body.code !== 0) throw new Error('Unable to get chat head');
|
||||
})
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('Adds a chat message to the pad', async function () {
|
||||
await agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` +
|
||||
`&authorID=${authorID}&time=${timestamp}`)
|
||||
.expect((res) => {
|
||||
if (res.body.code !== 0) throw new Error('Unable to create chat message');
|
||||
})
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200, done);
|
||||
});
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
|
||||
describe('getChatHead', function () {
|
||||
it('Gets the head of chat', function (done) {
|
||||
agent.get(`${endPoint('getChatHead')}&padID=${padID}`)
|
||||
it('Gets the head of chat', async function () {
|
||||
await agent.get(`${endPoint('getChatHead')}&padID=${padID}`)
|
||||
.expect((res) => {
|
||||
if (res.body.data.chatHead !== 0) throw new Error('Chat Head Length is wrong');
|
||||
|
||||
if (res.body.code !== 0) throw new Error('Unable to get chat head');
|
||||
})
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200, done);
|
||||
});
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
describe('getChatHistory', function () {
|
||||
it('Gets Chat History of a Pad', function (done) {
|
||||
agent.get(`${endPoint('getChatHistory')}&padID=${padID}`)
|
||||
.expect((res) => {
|
||||
if (res.body.data.messages.length !== 1) {
|
||||
throw new Error('Chat History Length is wrong');
|
||||
}
|
||||
if (res.body.code !== 0) throw new Error('Unable to get chat history');
|
||||
})
|
||||
it('Gets Chat History of a Pad', async function () {
|
||||
await agent.get(`${endPoint('getChatHistory')}&padID=${padID}`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200, done);
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
assert.equal(res.body.code, 0, 'Unable to get chat history');
|
||||
assert.equal(res.body.data.messages.length, 1, 'Chat History Length is wrong');
|
||||
assert.equal(res.body.data.messages[0].text, 'blalblalbha', 'Chat text does not match');
|
||||
assert.equal(res.body.data.messages[0].userId, authorID, 'Message author does not match');
|
||||
assert.equal(res.body.data.messages[0].time, timestamp.toString(), 'Message time does not match');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -17,16 +17,16 @@ describe(__filename, function () {
|
|||
before(async function () { agent = await common.init(); });
|
||||
|
||||
describe('Connectivity for instance-level API tests', function () {
|
||||
it('can connect', function (done) {
|
||||
agent.get('/api/')
|
||||
it('can connect', async function () {
|
||||
await agent.get('/api/')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200, done);
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStats', function () {
|
||||
it('Gets the stats of a running instance', function (done) {
|
||||
agent.get(endPoint('getStats'))
|
||||
it('Gets the stats of a running instance', async function () {
|
||||
await agent.get(endPoint('getStats'))
|
||||
.expect((res) => {
|
||||
if (res.body.code !== 0) throw new Error('getStats() failed');
|
||||
|
||||
|
@ -48,7 +48,7 @@ describe(__filename, function () {
|
|||
}
|
||||
})
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200, done);
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -17,6 +17,7 @@ let apiVersion = 1;
|
|||
const testPadId = makeid();
|
||||
const newPadId = makeid();
|
||||
const copiedPadId = makeid();
|
||||
const anotherPadId = makeid();
|
||||
let lastEdited = '';
|
||||
const text = generateLongText();
|
||||
|
||||
|
@ -502,6 +503,31 @@ describe(__filename, function () {
|
|||
.expect('Content-Type', /json/);
|
||||
assert.equal(res.body.data.revisions, revCount);
|
||||
});
|
||||
|
||||
it('creates a new Pad with empty text', async function () {
|
||||
await agent.get(`${endPoint('createPad')}&padID=${anotherPadId}&text=`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
assert.equal(res.body.code, 0, 'Unable to create new Pad');
|
||||
});
|
||||
await agent.get(`${endPoint('getText')}&padID=${anotherPadId}`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
assert.equal(res.body.code, 0, 'Unable to get pad text');
|
||||
assert.equal(res.body.data.text, '\n', 'Pad text is not empty');
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes with empty text', async function () {
|
||||
await agent.get(`${endPoint('deletePad')}&padID=${anotherPadId}`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
assert.equal(res.body.code, 0, 'Unable to delete empty Pad');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyPadWithoutHistory', function () {
|
||||
|
|
11
src/tests/backend/specs/crypto.js
Normal file
11
src/tests/backend/specs/crypto.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
'use strict';
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const {Buffer} = require('buffer');
|
||||
const crypto = require('../../../node/security/crypto');
|
||||
const nodeCrypto = require('crypto');
|
||||
const util = require('util');
|
||||
|
||||
const nodeHkdf = nodeCrypto.hkdf ? util.promisify(nodeCrypto.hkdf) : null;
|
||||
|
||||
const ab2hex = (ab) => Buffer.from(ab).toString('hex');
|
90
src/tests/backend/specs/lowerCasePadIds.js
Normal file
90
src/tests/backend/specs/lowerCasePadIds.js
Normal file
|
@ -0,0 +1,90 @@
|
|||
'use strict';
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../common');
|
||||
const padManager = require('../../../node/db/PadManager');
|
||||
const settings = require('../../../node/utils/Settings');
|
||||
|
||||
describe(__filename, function () {
|
||||
let agent;
|
||||
const cleanUpPads = async () => {
|
||||
const {padIDs} = await padManager.listAllPads();
|
||||
await Promise.all(padIDs.map(async (padId) => {
|
||||
if (await padManager.doesPadExist(padId)) {
|
||||
const pad = await padManager.getPad(padId);
|
||||
await pad.remove();
|
||||
}
|
||||
}));
|
||||
};
|
||||
let backup;
|
||||
|
||||
before(async function () {
|
||||
backup = settings.lowerCasePadIds;
|
||||
agent = await common.init();
|
||||
});
|
||||
beforeEach(async function () {
|
||||
await cleanUpPads();
|
||||
});
|
||||
afterEach(async function () {
|
||||
await cleanUpPads();
|
||||
});
|
||||
after(async function () {
|
||||
settings.lowerCasePadIds = backup;
|
||||
});
|
||||
|
||||
describe('not activated', function () {
|
||||
beforeEach(async function () {
|
||||
settings.lowerCasePadIds = false;
|
||||
});
|
||||
|
||||
|
||||
it('do nothing', async function () {
|
||||
await agent.get('/p/UPPERCASEpad')
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activated', function () {
|
||||
beforeEach(async function () {
|
||||
settings.lowerCasePadIds = true;
|
||||
});
|
||||
it('lowercase pad ids', async function () {
|
||||
await agent.get('/p/UPPERCASEpad')
|
||||
.expect(302)
|
||||
.expect('location', 'uppercasepad');
|
||||
});
|
||||
|
||||
it('keeps old pads accessible', async function () {
|
||||
Object.assign(settings, {
|
||||
lowerCasePadIds: false,
|
||||
});
|
||||
await padManager.getPad('ALREADYexistingPad', 'oldpad');
|
||||
await padManager.getPad('alreadyexistingpad', 'newpad');
|
||||
Object.assign(settings, {
|
||||
lowerCasePadIds: true,
|
||||
});
|
||||
|
||||
const oldPad = await agent.get('/p/ALREADYexistingPad').expect(200);
|
||||
const oldPadSocket = await common.connect(oldPad);
|
||||
const oldPadHandshake = await common.handshake(oldPadSocket, 'ALREADYexistingPad');
|
||||
assert.equal(oldPadHandshake.data.padId, 'ALREADYexistingPad');
|
||||
assert.equal(oldPadHandshake.data.collab_client_vars.initialAttributedText.text, 'oldpad\n');
|
||||
|
||||
const newPad = await agent.get('/p/alreadyexistingpad').expect(200);
|
||||
const newPadSocket = await common.connect(newPad);
|
||||
const newPadHandshake = await common.handshake(newPadSocket, 'alreadyexistingpad');
|
||||
assert.equal(newPadHandshake.data.padId, 'alreadyexistingpad');
|
||||
assert.equal(newPadHandshake.data.collab_client_vars.initialAttributedText.text, 'newpad\n');
|
||||
});
|
||||
|
||||
it('disallow creation of different case pad-name via socket connection', async function () {
|
||||
await padManager.getPad('maliciousattempt', 'attempt');
|
||||
|
||||
const newPad = await agent.get('/p/maliciousattempt').expect(200);
|
||||
const newPadSocket = await common.connect(newPad);
|
||||
const newPadHandshake = await common.handshake(newPadSocket, 'MaliciousAttempt');
|
||||
|
||||
assert.equal(newPadHandshake.data.collab_client_vars.initialAttributedText.text, 'attempt\n');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -181,7 +181,9 @@ const helper = {};
|
|||
helper.padOuter$.fx.off = true;
|
||||
helper.padInner$.fx.off = true;
|
||||
|
||||
return opts.id;
|
||||
// Don't return opts.id -- the server might have redirected the browser to a transformed version
|
||||
// of the requested pad ID.
|
||||
return helper.padChrome$.window.clientVars.padId;
|
||||
};
|
||||
|
||||
helper.newAdmin = async (page) => {
|
||||
|
|
|
@ -37,7 +37,7 @@ helper.edit = async (message, line) => {
|
|||
await helper.withFastCommit(async (incorp) => {
|
||||
helper.linesDiv()[line].sendkeys(message);
|
||||
incorp();
|
||||
await helper.waitForPromise(() => editsNum + 1 === helper.commits.length);
|
||||
await helper.waitForPromise(() => editsNum + 1 === helper.commits.length, 10000);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -94,7 +94,7 @@ helper.sendChatMessage = async (message) => {
|
|||
*/
|
||||
helper.showSettings = async () => {
|
||||
if (helper.isSettingsShown()) return;
|
||||
helper.settingsButton().click();
|
||||
helper.settingsButton().trigger('click');
|
||||
await helper.waitForPromise(() => helper.isSettingsShown(), 2000);
|
||||
};
|
||||
|
||||
|
@ -106,7 +106,7 @@ helper.showSettings = async () => {
|
|||
*/
|
||||
helper.hideSettings = async () => {
|
||||
if (!helper.isSettingsShown()) return;
|
||||
helper.settingsButton().click();
|
||||
helper.settingsButton().trigger('click');
|
||||
await helper.waitForPromise(() => !helper.isSettingsShown(), 2000);
|
||||
};
|
||||
|
||||
|
@ -119,7 +119,7 @@ helper.hideSettings = async () => {
|
|||
helper.enableStickyChatviaSettings = async () => {
|
||||
const stickyChat = helper.padChrome$('#options-stickychat');
|
||||
if (!helper.isSettingsShown() || stickyChat.is(':checked')) return;
|
||||
stickyChat.click();
|
||||
stickyChat.trigger('click');
|
||||
await helper.waitForPromise(() => helper.isChatboxSticky(), 2000);
|
||||
};
|
||||
|
||||
|
@ -132,7 +132,7 @@ helper.enableStickyChatviaSettings = async () => {
|
|||
helper.disableStickyChatviaSettings = async () => {
|
||||
const stickyChat = helper.padChrome$('#options-stickychat');
|
||||
if (!helper.isSettingsShown() || !stickyChat.is(':checked')) return;
|
||||
stickyChat.click();
|
||||
stickyChat.trigger('click');
|
||||
await helper.waitForPromise(() => !helper.isChatboxSticky(), 2000);
|
||||
};
|
||||
|
||||
|
@ -145,7 +145,7 @@ helper.disableStickyChatviaSettings = async () => {
|
|||
helper.enableStickyChatviaIcon = async () => {
|
||||
const stickyChat = helper.padChrome$('#titlesticky');
|
||||
if (!helper.isChatboxShown() || helper.isChatboxSticky()) return;
|
||||
stickyChat.click();
|
||||
stickyChat.trigger('click');
|
||||
await helper.waitForPromise(() => helper.isChatboxSticky(), 2000);
|
||||
};
|
||||
|
||||
|
@ -157,7 +157,7 @@ helper.enableStickyChatviaIcon = async () => {
|
|||
*/
|
||||
helper.disableStickyChatviaIcon = async () => {
|
||||
if (!helper.isChatboxShown() || !helper.isChatboxSticky()) return;
|
||||
helper.titlecross().click();
|
||||
helper.titlecross().trigger('click');
|
||||
await helper.waitForPromise(() => !helper.isChatboxSticky(), 2000);
|
||||
};
|
||||
|
||||
|
@ -175,9 +175,8 @@ helper.disableStickyChatviaIcon = async () => {
|
|||
*/
|
||||
helper.gotoTimeslider = async (revision) => {
|
||||
revision = Number.isInteger(revision) ? `#${revision}` : '';
|
||||
const iframe = $('#iframe-container iframe');
|
||||
iframe.attr('src', `${iframe.attr('src')}/timeslider${revision}`);
|
||||
|
||||
helper.padChrome$.window.location.href =
|
||||
`${helper.padChrome$.window.location.pathname}/timeslider${revision}`;
|
||||
await helper.waitForPromise(() => helper.timesliderTimerTime() &&
|
||||
!Number.isNaN(new Date(helper.timesliderTimerTime()).getTime()), 10000);
|
||||
};
|
||||
|
|
|
@ -16,7 +16,7 @@ helper.contentWindow = () => $('#iframe-container iframe')[0].contentWindow;
|
|||
helper.showChat = async () => {
|
||||
const chaticon = helper.chatIcon();
|
||||
if (!chaticon.hasClass('visible')) return;
|
||||
chaticon.click();
|
||||
chaticon.trigger('click');
|
||||
await helper.waitForPromise(() => !chaticon.hasClass('visible'), 2000);
|
||||
};
|
||||
|
||||
|
@ -27,7 +27,7 @@ helper.showChat = async () => {
|
|||
*/
|
||||
helper.hideChat = async () => {
|
||||
if (!helper.isChatboxShown() || helper.isChatboxSticky()) return;
|
||||
helper.titlecross().click();
|
||||
helper.titlecross().trigger('click');
|
||||
await helper.waitForPromise(() => !helper.isChatboxShown(), 2000);
|
||||
};
|
||||
|
||||
|
@ -80,7 +80,7 @@ helper.settingsButton =
|
|||
helper.toggleUserList = async () => {
|
||||
const isVisible = helper.userListShown();
|
||||
const button = helper.padChrome$("button[data-l10n-id='pad.toolbar.showusers.title']");
|
||||
button.click();
|
||||
button.trigger('click');
|
||||
await helper.waitForPromise(() => !isVisible);
|
||||
};
|
||||
|
||||
|
@ -104,9 +104,9 @@ helper.userListShown = () => helper.padChrome$('div#users').hasClass('popup-show
|
|||
*/
|
||||
helper.setUserName = async (name) => {
|
||||
const userElement = helper.usernameField();
|
||||
userElement.click();
|
||||
userElement.trigger('click');
|
||||
userElement.val(name);
|
||||
userElement.blur();
|
||||
userElement.trigger('blur');
|
||||
await helper.waitForPromise(() => !helper.usernameField().hasClass('editactive'));
|
||||
};
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ describe('Admin > Settings', function () {
|
|||
observer.observe(
|
||||
helper.admin$('#response')[0], {attributes: true, childList: false, subtree: false});
|
||||
});
|
||||
helper.admin$('#saveSettings').click();
|
||||
helper.admin$('#saveSettings').trigger('click');
|
||||
await p;
|
||||
};
|
||||
|
||||
|
@ -80,8 +80,8 @@ describe('Admin > Settings', function () {
|
|||
await helper.waitForPromise(async () => {
|
||||
oldStartTime = await getStartTime();
|
||||
return oldStartTime != null && oldStartTime > 0;
|
||||
}, 1000, 500);
|
||||
helper.admin$('#restartEtherpad').click();
|
||||
}, 2100, 500);
|
||||
helper.admin$('#restartEtherpad').trigger('click');
|
||||
await helper.waitForPromise(async () => {
|
||||
const startTime = await getStartTime();
|
||||
return startTime != null && startTime > oldStartTime;
|
||||
|
|
|
@ -56,7 +56,7 @@ describe('Plugins page', function () {
|
|||
|
||||
await timeout(500); // HACK! Please submit better fix..
|
||||
const $doUpdateButton = helper.admin$('.ep_align .do-update');
|
||||
$doUpdateButton.click();
|
||||
$doUpdateButton.trigger('click');
|
||||
|
||||
// ensure its showing as Updating
|
||||
await helper.waitForPromise(
|
||||
|
@ -79,7 +79,7 @@ describe('Plugins page', function () {
|
|||
// skip if we already have ep_headings2 installed..
|
||||
if (helper.admin$('.ep_headings2 .do-install').is(':visible') === false) this.skip();
|
||||
|
||||
helper.admin$('.ep_headings2 .do-install').click();
|
||||
helper.admin$('.ep_headings2 .do-install').trigger('click');
|
||||
// ensure install has attempted to be started
|
||||
await helper.waitForPromise(
|
||||
() => helper.admin$('.ep_headings2 .do-install').length !== 0, 120000);
|
||||
|
@ -96,7 +96,7 @@ describe('Plugins page', function () {
|
|||
await helper.waitForPromise(
|
||||
() => helper.admin$('.ep_headings2 .do-uninstall').length !== 0, 120000);
|
||||
|
||||
helper.admin$('.ep_headings2 .do-uninstall').click();
|
||||
helper.admin$('.ep_headings2 .do-uninstall').trigger('click');
|
||||
|
||||
// ensure its showing uninstalling
|
||||
await helper.waitForPromise(
|
||||
|
|
|
@ -25,7 +25,7 @@ describe('author of pad edition', function () {
|
|||
$lineWithUnorderedList.sendkeys('{selectall}');
|
||||
|
||||
const $insertUnorderedListButton = helper.padChrome$('.buttonicon-insertunorderedlist');
|
||||
$insertUnorderedListButton.click();
|
||||
$insertUnorderedListButton.trigger('click');
|
||||
|
||||
await helper.waitForPromise(() => (
|
||||
getLine(LINE_WITH_UNORDERED_LIST).find('ul li').length === 1 &&
|
||||
|
@ -36,7 +36,7 @@ describe('author of pad edition', function () {
|
|||
$lineWithOrderedList.sendkeys('{selectall}');
|
||||
|
||||
const $insertOrderedListButton = helper.padChrome$('.buttonicon-insertorderedlist');
|
||||
$insertOrderedListButton.click();
|
||||
$insertOrderedListButton.trigger('click');
|
||||
|
||||
await helper.waitForPromise(() => (
|
||||
getLine(LINE_WITH_ORDERED_LIST).find('ol li').length === 1 &&
|
||||
|
|
|
@ -18,7 +18,7 @@ describe('bold button', function () {
|
|||
|
||||
// get the bold button and click it
|
||||
const $boldButton = chrome$('.buttonicon-bold');
|
||||
$boldButton.click();
|
||||
$boldButton.trigger('click');
|
||||
|
||||
const $newFirstTextElement = inner$('div').first();
|
||||
|
||||
|
|
|
@ -13,10 +13,10 @@ describe('change user color', function () {
|
|||
|
||||
// click on the settings button to make settings visible
|
||||
let $userButton = chrome$('.buttonicon-showusers');
|
||||
$userButton.click();
|
||||
$userButton.trigger('click');
|
||||
|
||||
let $userSwatch = chrome$('#myswatch');
|
||||
$userSwatch.click();
|
||||
$userSwatch.trigger('click');
|
||||
|
||||
const fb = chrome$.farbtastic('#colorpicker');
|
||||
const $colorPickerSave = chrome$('#mycolorpickersave');
|
||||
|
@ -34,7 +34,7 @@ describe('change user color', function () {
|
|||
// The swatch updates as the test color is picked.
|
||||
fb.setColor(testColorHash);
|
||||
expect($colorPickerPreview.css('background-color')).to.be(testColorRGB);
|
||||
$colorPickerSave.click();
|
||||
$colorPickerSave.trigger('click');
|
||||
expect($userSwatch.css('background-color')).to.be(testColorRGB);
|
||||
|
||||
// give it a second to save the color on the server side
|
||||
|
@ -47,10 +47,10 @@ describe('change user color', function () {
|
|||
|
||||
// click on the settings button to make settings visible
|
||||
$userButton = chrome$('.buttonicon-showusers');
|
||||
$userButton.click();
|
||||
$userButton.trigger('click');
|
||||
|
||||
$userSwatch = chrome$('#myswatch');
|
||||
$userSwatch.click();
|
||||
$userSwatch.trigger('click');
|
||||
|
||||
$colorPickerPreview = chrome$('#mycolorpickerpreview');
|
||||
|
||||
|
@ -64,15 +64,15 @@ describe('change user color', function () {
|
|||
|
||||
const $colorOption = helper.padChrome$('#options-colorscheck');
|
||||
if (!$colorOption.is(':checked')) {
|
||||
$colorOption.click();
|
||||
$colorOption.trigger('click');
|
||||
}
|
||||
|
||||
// click on the settings button to make settings visible
|
||||
const $userButton = chrome$('.buttonicon-showusers');
|
||||
$userButton.click();
|
||||
$userButton.trigger('click');
|
||||
|
||||
const $userSwatch = chrome$('#myswatch');
|
||||
$userSwatch.click();
|
||||
$userSwatch.trigger('click');
|
||||
|
||||
const fb = chrome$.farbtastic('#colorpicker');
|
||||
const $colorPickerSave = chrome$('#mycolorpickersave');
|
||||
|
@ -82,11 +82,11 @@ describe('change user color', function () {
|
|||
const testColorRGB = 'rgb(171, 205, 239)';
|
||||
|
||||
fb.setColor(testColorHash);
|
||||
$colorPickerSave.click();
|
||||
$colorPickerSave.trigger('click');
|
||||
|
||||
// click on the chat button to make chat visible
|
||||
const $chatButton = chrome$('#chaticon');
|
||||
$chatButton.click();
|
||||
$chatButton.trigger('click');
|
||||
const $chatInput = chrome$('#chatinput');
|
||||
$chatInput.sendkeys('O hi'); // simulate a keypress of typing user
|
||||
$chatInput.sendkeys('{enter}');
|
||||
|
|
|
@ -10,7 +10,7 @@ describe('chat-load-messages', function () {
|
|||
it('adds a lot of messages', async function () {
|
||||
const chrome$ = helper.padChrome$;
|
||||
const chatButton = chrome$('#chaticon');
|
||||
chatButton.click();
|
||||
chatButton.trigger('click');
|
||||
const chatInput = chrome$('#chatinput');
|
||||
const chatText = chrome$('#chattext');
|
||||
|
||||
|
@ -33,7 +33,7 @@ describe('chat-load-messages', function () {
|
|||
const chrome$ = helper.padChrome$;
|
||||
helper.waitFor(() => {
|
||||
const chatButton = chrome$('#chaticon');
|
||||
chatButton.click();
|
||||
chatButton.trigger('click');
|
||||
chatText = chrome$('#chattext');
|
||||
return chatText.children('p').length === expectedCount;
|
||||
}).always(() => {
|
||||
|
@ -47,11 +47,11 @@ describe('chat-load-messages', function () {
|
|||
const expectedCount = 122;
|
||||
const chrome$ = helper.padChrome$;
|
||||
const chatButton = chrome$('#chaticon');
|
||||
chatButton.click();
|
||||
chatButton.trigger('click');
|
||||
const chatText = chrome$('#chattext');
|
||||
const loadMsgBtn = chrome$('#chatloadmessagesbutton');
|
||||
|
||||
loadMsgBtn.click();
|
||||
loadMsgBtn.trigger('click');
|
||||
helper.waitFor(() => chatText.children('p').length === expectedCount).always(() => {
|
||||
expect(chatText.children('p').length).to.be(expectedCount);
|
||||
done();
|
||||
|
@ -63,11 +63,11 @@ describe('chat-load-messages', function () {
|
|||
const expectedDisplay = 'none';
|
||||
const chrome$ = helper.padChrome$;
|
||||
const chatButton = chrome$('#chaticon');
|
||||
chatButton.click();
|
||||
chatButton.trigger('click');
|
||||
const loadMsgBtn = chrome$('#chatloadmessagesbutton');
|
||||
const loadMsgBall = chrome$('#chatloadmessagesball');
|
||||
|
||||
loadMsgBtn.click();
|
||||
loadMsgBtn.trigger('click');
|
||||
helper.waitFor(() => loadMsgBtn.css('display') === expectedDisplay &&
|
||||
loadMsgBall.css('display') === expectedDisplay).always(() => {
|
||||
expect(loadMsgBtn.css('display')).to.be(expectedDisplay);
|
||||
|
|
|
@ -32,11 +32,11 @@ describe('clear authorship colors button', function () {
|
|||
() => inner$('div span').first().attr('class').indexOf('author') !== -1);
|
||||
|
||||
// IE hates you if you don't give focus to the inner frame bevore you do a clearAuthorship
|
||||
inner$('div').first().focus();
|
||||
inner$('div').first().trigger('focus');
|
||||
|
||||
// get the clear authorship colors button and click it
|
||||
const $clearauthorshipcolorsButton = chrome$('.buttonicon-clearauthorship');
|
||||
$clearauthorshipcolorsButton.click();
|
||||
$clearauthorshipcolorsButton.trigger('click');
|
||||
|
||||
// does the first div include an author class?
|
||||
const hasAuthorClass = inner$('div').first().attr('class').indexOf('author') !== -1;
|
||||
|
@ -70,11 +70,11 @@ describe('clear authorship colors button', function () {
|
|||
() => inner$('div span').first().attr('class').indexOf('author') !== -1);
|
||||
|
||||
// IE hates you if you don't give focus to the inner frame bevore you do a clearAuthorship
|
||||
inner$('div').first().focus();
|
||||
inner$('div').first().trigger('focus');
|
||||
|
||||
// get the clear authorship colors button and click it
|
||||
const $clearauthorshipcolorsButton = chrome$('.buttonicon-clearauthorship');
|
||||
$clearauthorshipcolorsButton.click();
|
||||
$clearauthorshipcolorsButton.trigger('click');
|
||||
|
||||
// does the first div include an author class?
|
||||
let hasAuthorClass = inner$('div').first().attr('class').indexOf('author') !== -1;
|
||||
|
@ -93,7 +93,7 @@ describe('clear authorship colors button', function () {
|
|||
const $undoButton = chrome$('.buttonicon-undo');
|
||||
|
||||
// click the button
|
||||
$undoButton.click(); // shouldn't do anything
|
||||
$undoButton.trigger('click'); // shouldn't do anything
|
||||
hasAuthorClass = inner$('div').first().attr('class').indexOf('author') !== -1;
|
||||
expect(hasAuthorClass).to.be(false);
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ describe('drag and drop', function () {
|
|||
before(async function () {
|
||||
const originalHTML = helper.padInner$('body').html();
|
||||
const $undoButton = helper.padChrome$('.buttonicon-undo');
|
||||
$undoButton.click();
|
||||
$undoButton.trigger('click');
|
||||
await helper.waitForPromise(() => helper.padInner$('body').html() !== originalHTML);
|
||||
});
|
||||
|
||||
|
@ -59,7 +59,7 @@ describe('drag and drop', function () {
|
|||
before(async function () {
|
||||
const originalHTML = helper.padInner$('body').html();
|
||||
const $undoButton = helper.padChrome$('.buttonicon-undo');
|
||||
$undoButton.click();
|
||||
$undoButton.trigger('click');
|
||||
await helper.waitForPromise(() => helper.padInner$('body').html() !== originalHTML);
|
||||
});
|
||||
|
||||
|
|
|
@ -59,7 +59,7 @@ describe('embed links', function () {
|
|||
const chrome$ = helper.padChrome$;
|
||||
|
||||
// open share dropdown
|
||||
chrome$('.buttonicon-embed').click();
|
||||
chrome$('.buttonicon-embed').trigger('click');
|
||||
|
||||
// get the link of the share field + the actual pad url and compare them
|
||||
const shareLink = chrome$('#linkinput').val();
|
||||
|
@ -73,7 +73,7 @@ describe('embed links', function () {
|
|||
const chrome$ = helper.padChrome$;
|
||||
|
||||
// open share dropdown
|
||||
chrome$('.buttonicon-embed').click();
|
||||
chrome$('.buttonicon-embed').trigger('click');
|
||||
|
||||
// get the link of the share field + the actual pad url and compare them
|
||||
const embedCode = chrome$('#embedinput').val();
|
||||
|
@ -93,8 +93,8 @@ describe('embed links', function () {
|
|||
const chrome$ = helper.padChrome$;
|
||||
|
||||
// open share dropdown
|
||||
chrome$('.buttonicon-embed').click();
|
||||
chrome$('#readonlyinput').click();
|
||||
chrome$('.buttonicon-embed').trigger('click');
|
||||
chrome$('#readonlyinput').trigger('click');
|
||||
chrome$('#readonlyinput:checkbox:not(:checked)').attr('checked', 'checked');
|
||||
|
||||
// get the link of the share field + the actual pad url and compare them
|
||||
|
@ -109,9 +109,9 @@ describe('embed links', function () {
|
|||
const chrome$ = helper.padChrome$;
|
||||
|
||||
// open share dropdown
|
||||
chrome$('.buttonicon-embed').click();
|
||||
chrome$('.buttonicon-embed').trigger('click');
|
||||
// check read only checkbox, a bit hacky
|
||||
chrome$('#readonlyinput').click();
|
||||
chrome$('#readonlyinput').trigger('click');
|
||||
chrome$('#readonlyinput:checkbox:not(:checked)').attr('checked', 'checked');
|
||||
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ describe('font select', function () {
|
|||
|
||||
// click on the settings button to make settings visible
|
||||
const $settingsButton = chrome$('.buttonicon-settings');
|
||||
$settingsButton.click();
|
||||
$settingsButton.trigger('click');
|
||||
|
||||
// get the font menu and RobotoMono option
|
||||
const $viewfontmenu = chrome$('#viewfontmenu');
|
||||
|
@ -21,7 +21,7 @@ describe('font select', function () {
|
|||
// $RobotoMonooption.attr('selected','selected');
|
||||
// commenting out above will break safari test
|
||||
$viewfontmenu.val('RobotoMono');
|
||||
$viewfontmenu.change();
|
||||
$viewfontmenu.trigger('change');
|
||||
|
||||
// check if font changed to RobotoMono
|
||||
const fontFamily = inner$('body').css('font-family').toLowerCase();
|
||||
|
|
|
@ -47,13 +47,13 @@ describe('the test helper', function () {
|
|||
|
||||
// click on the settings button to make settings visible
|
||||
let $userButton = chrome$('.buttonicon-showusers');
|
||||
$userButton.click();
|
||||
$userButton.trigger('click');
|
||||
|
||||
let $usernameInput = chrome$('#myusernameedit');
|
||||
$usernameInput.click();
|
||||
$usernameInput.trigger('click');
|
||||
|
||||
$usernameInput.val('John McLear');
|
||||
$usernameInput.blur();
|
||||
$usernameInput.trigger('blur');
|
||||
|
||||
// Before refreshing, make sure the name is there
|
||||
expect($usernameInput.val()).to.be('John McLear');
|
||||
|
@ -85,7 +85,7 @@ describe('the test helper', function () {
|
|||
|
||||
// click on the settings button to make settings visible
|
||||
$userButton = chrome$('.buttonicon-showusers');
|
||||
$userButton.click();
|
||||
$userButton.trigger('click');
|
||||
|
||||
// confirm that the session was actually cleared
|
||||
$usernameInput = chrome$('#myusernameedit');
|
||||
|
|
|
@ -527,7 +527,7 @@ describe('importexport.js', function () {
|
|||
const isVisible = () => popup.hasClass('popup-show');
|
||||
if (isVisible()) return;
|
||||
const button = helper.padChrome$('button[data-l10n-id="pad.toolbar.import_export.title"]');
|
||||
button.click();
|
||||
button.trigger('click');
|
||||
await helper.waitForPromise(isVisible);
|
||||
});
|
||||
|
||||
|
@ -558,7 +558,7 @@ describe('importexport.js', function () {
|
|||
dt.items.add(new File([contents], `file.${ext}`, {type: 'text/plain'}));
|
||||
const form = helper.padChrome$('#importform');
|
||||
form.find('input[type=file]')[0].files = dt.files;
|
||||
form.find('#importsubmitinput').submit();
|
||||
form.find('#importsubmitinput').trigger('submit');
|
||||
try {
|
||||
await helper.waitForPromise(() => {
|
||||
const got = helper.linesDiv();
|
||||
|
|
|
@ -27,7 +27,7 @@ describe('indentation button', function () {
|
|||
const chrome$ = helper.padChrome$;
|
||||
|
||||
const $indentButton = chrome$('.buttonicon-indent');
|
||||
$indentButton.click();
|
||||
$indentButton.trigger('click');
|
||||
|
||||
await helper.waitForPromise(() => inner$('div').first().find('ul li').length === 1);
|
||||
});
|
||||
|
@ -38,7 +38,7 @@ describe('indentation button', function () {
|
|||
const chrome$ = helper.padChrome$;
|
||||
|
||||
const $indentButton = chrome$('.buttonicon-indent');
|
||||
$indentButton.click();
|
||||
$indentButton.trigger('click');
|
||||
|
||||
// type a bit, make a line break and type again
|
||||
const $firstTextElement = inner$('div span').first();
|
||||
|
@ -147,19 +147,19 @@ describe('indentation button', function () {
|
|||
helper.selectLines($firstLine, $secondLine);
|
||||
|
||||
const $indentButton = chrome$('.buttonicon-indent');
|
||||
$indentButton.click();
|
||||
$indentButton.trigger('click');
|
||||
|
||||
await helper.waitForPromise(() => inner$('div').first().find('ul li').length === 1);
|
||||
|
||||
// apply bold
|
||||
const $boldButton = chrome$('.buttonicon-bold');
|
||||
$boldButton.click();
|
||||
$boldButton.trigger('click');
|
||||
|
||||
await helper.waitForPromise(() => inner$('div').first().find('b').length === 1);
|
||||
|
||||
// outdent first 2 lines
|
||||
const $outdentButton = chrome$('.buttonicon-outdent');
|
||||
$outdentButton.click();
|
||||
$outdentButton.trigger('click');
|
||||
await helper.waitForPromise(() => inner$('div').first().find('ul li').length === 0);
|
||||
|
||||
// check if '*' is displayed
|
||||
|
@ -179,7 +179,7 @@ describe('indentation button', function () {
|
|||
|
||||
// get the indentation button and click it
|
||||
const $indentButton = helper.$getPadChrome().find('.buttonicon-indent');
|
||||
$indentButton.click();
|
||||
$indentButton.trigger('click');
|
||||
|
||||
let newFirstTextElement = $inner.find('div').first();
|
||||
|
||||
|
@ -196,7 +196,7 @@ describe('indentation button', function () {
|
|||
expect(isLI).to.be(true);
|
||||
|
||||
// indent again
|
||||
$indentButton.click();
|
||||
$indentButton.trigger('click');
|
||||
|
||||
newFirstTextElement = $inner.find('div').first();
|
||||
|
||||
|
@ -215,8 +215,8 @@ describe('indentation button', function () {
|
|||
|
||||
// get the unindentation button and click it twice
|
||||
const $outdentButton = helper.$getPadChrome().find('.buttonicon-outdent');
|
||||
$outdentButton.click();
|
||||
$outdentButton.click();
|
||||
$outdentButton.trigger('click');
|
||||
$outdentButton.trigger('click');
|
||||
|
||||
newFirstTextElement = $inner.find('div').first();
|
||||
|
||||
|
@ -242,8 +242,8 @@ describe('indentation button', function () {
|
|||
helper.selectText(firstTextElement[0], $inner);
|
||||
|
||||
// indent twice
|
||||
$indentButton.click();
|
||||
$indentButton.click();
|
||||
$indentButton.trigger('click');
|
||||
$indentButton.trigger('click');
|
||||
|
||||
// get the first text element out of the inner iframe
|
||||
firstTextElement = $inner.find('div').first();
|
||||
|
|
|
@ -18,7 +18,7 @@ describe('italic some text', function () {
|
|||
|
||||
// get the bold button and click it
|
||||
const $boldButton = chrome$('.buttonicon-italic');
|
||||
$boldButton.click();
|
||||
$boldButton.trigger('click');
|
||||
|
||||
// ace creates a new dom element when you press a button, just get the first text element again
|
||||
const $newFirstTextElement = inner$('div').first();
|
||||
|
|
|
@ -16,7 +16,7 @@ describe('Language select and change', function () {
|
|||
|
||||
// click on the settings button to make settings visible
|
||||
const $settingsButton = chrome$('.buttonicon-settings');
|
||||
$settingsButton.click();
|
||||
$settingsButton.trigger('click');
|
||||
|
||||
// click the language button
|
||||
const $language = chrome$('#languagemenu');
|
||||
|
@ -24,7 +24,7 @@ describe('Language select and change', function () {
|
|||
|
||||
// select german
|
||||
$languageoption.attr('selected', 'selected');
|
||||
$language.change();
|
||||
$language.trigger('change');
|
||||
|
||||
await helper.waitForPromise(
|
||||
() => chrome$('.buttonicon-bold').parent()[0].title === 'Fett (Strg-B)');
|
||||
|
@ -45,13 +45,13 @@ describe('Language select and change', function () {
|
|||
|
||||
// click on the settings button to make settings visible
|
||||
const $settingsButton = chrome$('.buttonicon-settings');
|
||||
$settingsButton.click();
|
||||
$settingsButton.trigger('click');
|
||||
|
||||
// click the language button
|
||||
const $language = chrome$('#languagemenu');
|
||||
// select english
|
||||
$language.val('en');
|
||||
$language.change();
|
||||
$language.trigger('change');
|
||||
|
||||
// get the value of the bold button
|
||||
let $boldButton = chrome$('.buttonicon-bold').parent();
|
||||
|
@ -79,7 +79,7 @@ describe('Language select and change', function () {
|
|||
|
||||
// click on the settings button to make settings visible
|
||||
const $settingsButton = chrome$('.buttonicon-settings');
|
||||
$settingsButton.click();
|
||||
$settingsButton.trigger('click');
|
||||
|
||||
// click the language button
|
||||
const $language = chrome$('#languagemenu');
|
||||
|
@ -88,7 +88,7 @@ describe('Language select and change', function () {
|
|||
// select arabic
|
||||
// $languageoption.attr('selected','selected'); // Breaks the test..
|
||||
$language.val('ar');
|
||||
$languageoption.change();
|
||||
$languageoption.trigger('change');
|
||||
|
||||
await helper.waitForPromise(() => chrome$('html')[0].dir !== 'ltr');
|
||||
|
||||
|
@ -101,7 +101,7 @@ describe('Language select and change', function () {
|
|||
|
||||
// click on the settings button to make settings visible
|
||||
const $settingsButton = chrome$('.buttonicon-settings');
|
||||
$settingsButton.click();
|
||||
$settingsButton.trigger('click');
|
||||
|
||||
// click the language button
|
||||
const $language = chrome$('#languagemenu');
|
||||
|
@ -111,7 +111,7 @@ describe('Language select and change', function () {
|
|||
// select arabic
|
||||
$languageoption.attr('selected', 'selected');
|
||||
$language.val('en');
|
||||
$languageoption.change();
|
||||
$languageoption.trigger('change');
|
||||
|
||||
await helper.waitForPromise(() => chrome$('html')[0].dir !== 'rtl');
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@ describe('author of pad edition', function () {
|
|||
|
||||
// get the clear authorship colors button and click it
|
||||
const $clearauthorshipcolorsButton = chrome$('.buttonicon-clearauthorship');
|
||||
$clearauthorshipcolorsButton.click();
|
||||
$clearauthorshipcolorsButton.trigger('click');
|
||||
|
||||
// does the first divs span include an author class?
|
||||
const hasAuthorClass = inner$('div span').first().attr('class').indexOf('author') !== -1;
|
||||
|
|
|
@ -12,7 +12,7 @@ describe('ordered_list.js', function () {
|
|||
const chrome$ = helper.padChrome$;
|
||||
|
||||
const $insertorderedlistButton = chrome$('.buttonicon-insertorderedlist');
|
||||
$insertorderedlistButton.click();
|
||||
$insertorderedlistButton.trigger('click');
|
||||
|
||||
await helper.waitForPromise(() => inner$('div').first().find('ol li').length === 1);
|
||||
});
|
||||
|
@ -115,10 +115,10 @@ describe('ordered_list.js', function () {
|
|||
const $insertorderedlistButton = chrome$('.buttonicon-insertorderedlist');
|
||||
const $firstLine = inner$('div').first();
|
||||
$firstLine.sendkeys('{selectall}');
|
||||
$insertorderedlistButton.click();
|
||||
$insertorderedlistButton.trigger('click');
|
||||
const $secondLine = inner$('div').first().next();
|
||||
$secondLine.sendkeys('{selectall}');
|
||||
$insertorderedlistButton.click();
|
||||
$insertorderedlistButton.trigger('click');
|
||||
expect($secondLine.find('ol').attr('start') === 2);
|
||||
});
|
||||
|
||||
|
@ -128,7 +128,7 @@ describe('ordered_list.js', function () {
|
|||
const chrome$ = helper.padChrome$;
|
||||
|
||||
const $insertorderedlistButton = chrome$('.buttonicon-insertorderedlist');
|
||||
$insertorderedlistButton.click();
|
||||
$insertorderedlistButton.trigger('click');
|
||||
|
||||
// type a bit, make a line break and type again
|
||||
const $firstTextElement = inner$('div span').first();
|
||||
|
@ -182,7 +182,7 @@ describe('ordered_list.js', function () {
|
|||
$firstTextElement.sendkeys('{selectall}');
|
||||
|
||||
const $insertorderedlistButton = chrome$('.buttonicon-insertorderedlist');
|
||||
$insertorderedlistButton.click();
|
||||
$insertorderedlistButton.trigger('click');
|
||||
|
||||
const e = new inner$.Event(helper.evtType);
|
||||
e.keyCode = 9; // tab
|
||||
|
@ -217,15 +217,15 @@ describe('ordered_list.js', function () {
|
|||
$firstTextElement.sendkeys('{selectall}');
|
||||
|
||||
const $insertorderedlistButton = chrome$('.buttonicon-insertorderedlist');
|
||||
$insertorderedlistButton.click();
|
||||
$insertorderedlistButton.trigger('click');
|
||||
|
||||
const $indentButton = chrome$('.buttonicon-indent');
|
||||
$indentButton.click(); // make it indented twice
|
||||
$indentButton.trigger('click'); // make it indented twice
|
||||
|
||||
expect(inner$('div').first().find('.list-number2').length === 1).to.be(true);
|
||||
|
||||
const $outdentButton = chrome$('.buttonicon-outdent');
|
||||
$outdentButton.click(); // make it deindented to 1
|
||||
$outdentButton.trigger('click'); // make it deindented to 1
|
||||
|
||||
await helper.waitForPromise(() => inner$('div').first().find('.list-number1').length === 1);
|
||||
});
|
||||
|
|
|
@ -71,16 +71,16 @@ describe('Pad modal', function () {
|
|||
|
||||
const clickOnPadInner = () => {
|
||||
const $editor = helper.padInner$('#innerdocbody');
|
||||
$editor.click();
|
||||
$editor.trigger('click');
|
||||
};
|
||||
|
||||
const clickOnPadOuter = () => {
|
||||
const $lineNumbersColumn = helper.padOuter$('#sidedivinner');
|
||||
$lineNumbersColumn.click();
|
||||
$lineNumbersColumn.trigger('click');
|
||||
};
|
||||
|
||||
const openSettingsAndWaitForModalToBeVisible = async () => {
|
||||
helper.padChrome$('.buttonicon-settings').click();
|
||||
helper.padChrome$('.buttonicon-settings').trigger('click');
|
||||
|
||||
// wait for modal to be displayed
|
||||
const modalSelector = '#settings';
|
||||
|
|
|
@ -22,8 +22,8 @@ describe('undo button then redo button', function () {
|
|||
const $undoButton = chrome$('.buttonicon-undo');
|
||||
const $redoButton = chrome$('.buttonicon-redo');
|
||||
// click the buttons
|
||||
$undoButton.click(); // removes foo
|
||||
$redoButton.click(); // resends foo
|
||||
$undoButton.trigger('click'); // removes foo
|
||||
$redoButton.trigger('click'); // resends foo
|
||||
|
||||
await helper.waitForPromise(() => inner$('div span').first().text() === newString);
|
||||
const finalValue = inner$('div').first().text();
|
||||
|
|
|
@ -13,7 +13,7 @@ describe('select formatting buttons when selection has style applied', function
|
|||
const chrome$ = helper.padChrome$;
|
||||
selectLine(line);
|
||||
const $formattingButton = chrome$(`.buttonicon-${style}`);
|
||||
$formattingButton.click();
|
||||
$formattingButton.trigger('click');
|
||||
};
|
||||
|
||||
const isButtonSelected = function (style) {
|
||||
|
@ -37,7 +37,7 @@ describe('select formatting buttons when selection has style applied', function
|
|||
const undo = async function () {
|
||||
const originalHTML = helper.padInner$('body').html();
|
||||
const $undoButton = helper.padChrome$('.buttonicon-undo');
|
||||
$undoButton.click();
|
||||
$undoButton.trigger('click');
|
||||
await helper.waitForPromise(() => helper.padInner$('body').html() !== originalHTML);
|
||||
};
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ describe('strikethrough button', function () {
|
|||
|
||||
// get the strikethrough button and click it
|
||||
const $strikethroughButton = chrome$('.buttonicon-strikethrough');
|
||||
$strikethroughButton.click();
|
||||
$strikethroughButton.trigger('click');
|
||||
|
||||
// ace creates a new dom element when you press a button, just get the first text element again
|
||||
const $newFirstTextElement = inner$('div').first();
|
||||
|
|
|
@ -22,7 +22,7 @@ xdescribe('timeslider button takes you to the timeslider of a pad', function ()
|
|||
await helper.waitForPromise(() => modifiedValue !== originalValue);
|
||||
|
||||
const $timesliderButton = chrome$('#timesliderlink');
|
||||
$timesliderButton.click(); // So click the timeslider link
|
||||
$timesliderButton.trigger('click'); // So click the timeslider link
|
||||
|
||||
await helper.waitForPromise(() => {
|
||||
const iFrameURL = chrome$.window.location.href;
|
||||
|
|
|
@ -25,7 +25,7 @@ describe('timeslider follow', function () {
|
|||
|
||||
// set to follow contents as it arrives
|
||||
helper.contentWindow().$('#options-followContents').prop('checked', true);
|
||||
helper.contentWindow().$('#playpause_button_icon').click();
|
||||
helper.contentWindow().$('#playpause_button_icon').trigger('click');
|
||||
|
||||
let newTop;
|
||||
await helper.waitForPromise(() => {
|
||||
|
@ -64,27 +64,27 @@ describe('timeslider follow', function () {
|
|||
*/
|
||||
|
||||
// line 40 changed
|
||||
helper.contentWindow().$('#leftstep').click();
|
||||
helper.contentWindow().$('#leftstep').trigger('click');
|
||||
await helper.waitForPromise(() => hasFollowedToLine(40));
|
||||
|
||||
// line 1 is the first line that changed
|
||||
helper.contentWindow().$('#leftstep').click();
|
||||
helper.contentWindow().$('#leftstep').trigger('click');
|
||||
await helper.waitForPromise(() => hasFollowedToLine(1));
|
||||
|
||||
// line 1 changed
|
||||
helper.contentWindow().$('#leftstep').click();
|
||||
helper.contentWindow().$('#leftstep').trigger('click');
|
||||
await helper.waitForPromise(() => hasFollowedToLine(1));
|
||||
|
||||
// line 1 changed
|
||||
helper.contentWindow().$('#rightstep').click();
|
||||
helper.contentWindow().$('#rightstep').trigger('click');
|
||||
await helper.waitForPromise(() => hasFollowedToLine(1));
|
||||
|
||||
// line 1 is the first line that changed
|
||||
helper.contentWindow().$('#rightstep').click();
|
||||
helper.contentWindow().$('#rightstep').trigger('click');
|
||||
await helper.waitForPromise(() => hasFollowedToLine(1));
|
||||
|
||||
// line 40 changed
|
||||
helper.contentWindow().$('#rightstep').click();
|
||||
helper.contentWindow().$('#rightstep').trigger('click');
|
||||
helper.waitForPromise(() => hasFollowedToLine(40));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -12,7 +12,7 @@ describe('timeslider', function () {
|
|||
|
||||
// Create a bunch of revisions.
|
||||
for (let i = 0; i < 99; i++) await helper.edit('a');
|
||||
chrome$('.buttonicon-savedRevision').click();
|
||||
chrome$('.buttonicon-savedRevision').trigger('click');
|
||||
await helper.waitForPromise(() => helper.padChrome$('.saved-revision').length > 0);
|
||||
// Give some time to send the SAVE_REVISION message to the server before navigating away.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
|
|
@ -20,7 +20,7 @@ describe('undo button', function () {
|
|||
// get clear authorship button as a variable
|
||||
const $undoButton = chrome$('.buttonicon-undo');
|
||||
// click the button
|
||||
$undoButton.click();
|
||||
$undoButton.trigger('click');
|
||||
|
||||
await helper.waitForPromise(() => inner$('div span').first().text() === originalValue);
|
||||
});
|
||||
|
|
|
@ -13,7 +13,7 @@ describe('unordered_list.js', function () {
|
|||
const originalText = inner$('div').first().text();
|
||||
|
||||
const $insertunorderedlistButton = chrome$('.buttonicon-insertunorderedlist');
|
||||
$insertunorderedlistButton.click();
|
||||
$insertunorderedlistButton.trigger('click');
|
||||
|
||||
await helper.waitForPromise(() => {
|
||||
const newText = inner$('div').first().text();
|
||||
|
@ -21,7 +21,7 @@ describe('unordered_list.js', function () {
|
|||
});
|
||||
|
||||
// remove indentation by bullet and ensure text string remains the same
|
||||
chrome$('.buttonicon-outdent').click();
|
||||
chrome$('.buttonicon-outdent').trigger('click');
|
||||
await helper.waitForPromise(() => inner$('div').first().text() === originalText);
|
||||
});
|
||||
});
|
||||
|
@ -38,7 +38,7 @@ describe('unordered_list.js', function () {
|
|||
const originalText = inner$('div').first().text();
|
||||
|
||||
const $insertunorderedlistButton = chrome$('.buttonicon-insertunorderedlist');
|
||||
$insertunorderedlistButton.click();
|
||||
$insertunorderedlistButton.trigger('click');
|
||||
|
||||
await helper.waitForPromise(() => {
|
||||
const newText = inner$('div').first().text();
|
||||
|
@ -46,7 +46,7 @@ describe('unordered_list.js', function () {
|
|||
});
|
||||
|
||||
// remove indentation by bullet and ensure text string remains the same
|
||||
$insertunorderedlistButton.click();
|
||||
$insertunorderedlistButton.trigger('click');
|
||||
await helper.waitForPromise(() => inner$('div').find('ul').length !== 1);
|
||||
});
|
||||
});
|
||||
|
@ -63,7 +63,7 @@ describe('unordered_list.js', function () {
|
|||
const chrome$ = helper.padChrome$;
|
||||
|
||||
const $insertorderedlistButton = chrome$('.buttonicon-insertunorderedlist');
|
||||
$insertorderedlistButton.click();
|
||||
$insertorderedlistButton.trigger('click');
|
||||
|
||||
// type a bit, make a line break and type again
|
||||
const $firstTextElement = inner$('div span').first();
|
||||
|
@ -98,7 +98,7 @@ describe('unordered_list.js', function () {
|
|||
$firstTextElement.sendkeys('{selectall}');
|
||||
|
||||
const $insertorderedlistButton = chrome$('.buttonicon-insertunorderedlist');
|
||||
$insertorderedlistButton.click();
|
||||
$insertorderedlistButton.trigger('click');
|
||||
|
||||
const e = new inner$.Event(helper.evtType);
|
||||
e.keyCode = 9; // tab
|
||||
|
@ -131,14 +131,14 @@ describe('unordered_list.js', function () {
|
|||
$firstTextElement.sendkeys('{selectall}');
|
||||
|
||||
const $insertunorderedlistButton = chrome$('.buttonicon-insertunorderedlist');
|
||||
$insertunorderedlistButton.click();
|
||||
$insertunorderedlistButton.trigger('click');
|
||||
|
||||
const $indentButton = chrome$('.buttonicon-indent');
|
||||
$indentButton.click(); // make it indented twice
|
||||
$indentButton.trigger('click'); // make it indented twice
|
||||
|
||||
expect(inner$('div').first().find('.list-bullet2').length === 1).to.be(true);
|
||||
const $outdentButton = chrome$('.buttonicon-outdent');
|
||||
$outdentButton.click(); // make it deindented to 1
|
||||
$outdentButton.trigger('click'); // make it deindented to 1
|
||||
|
||||
await helper.waitForPromise(() => inner$('div').first().find('.list-bullet1').length === 1);
|
||||
});
|
||||
|
|
|
@ -32,7 +32,7 @@ describe('Automatic pad reload on Force Reconnect message', function () {
|
|||
context('and user clicks on Cancel', function () {
|
||||
beforeEach(async function () {
|
||||
const $errorMessageModal = helper.padChrome$('#connectivity .userdup');
|
||||
$errorMessageModal.find('#cancelreconnect').click();
|
||||
$errorMessageModal.find('#cancelreconnect').trigger('click');
|
||||
await helper.waitForPromise(
|
||||
() => helper.padChrome$('#connectivity .userdup').is(':visible') === true);
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue