From c64c4a407312a930af59d964dc4fdf5c35cc9b69 Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Wed, 17 Apr 2024 21:29:15 +0200 Subject: [PATCH] Added biomejs as formatter and linter --- pnpm-lock.yaml | 91 + src/.eslintrc.cjs | 237 +- src/ep.json | 230 +- src/locales/af.json | 5 +- src/locales/ast.json | 5 +- src/locales/awa.json | 5 +- src/locales/azb.json | 8 +- src/locales/bcc.json | 6 +- src/locales/be-tarask.json | 7 +- src/locales/bg.json | 6 +- src/locales/bgn.json | 4 +- src/locales/br.json | 8 +- src/locales/bs.json | 7 +- src/locales/dsb.json | 4 +- src/locales/dty.json | 7 +- src/locales/en.json | 322 +- src/locales/et.json | 5 +- src/locales/ff.json | 4 +- src/locales/fo.json | 4 +- src/locales/fy.json | 4 +- src/locales/gl.json | 6 +- src/locales/gu.json | 7 +- src/locales/he.json | 8 +- src/locales/hi.json | 4 +- src/locales/hr.json | 6 +- src/locales/hrx.json | 4 +- src/locales/hsb.json | 4 +- src/locales/hy.json | 5 +- src/locales/ia.json | 4 +- src/locales/id.json | 7 +- src/locales/is.json | 5 +- src/locales/kab.json | 4 +- src/locales/km.json | 6 +- src/locales/kn.json | 5 +- src/locales/krc.json | 5 +- src/locales/ksh.json | 4 +- src/locales/lb.json | 7 +- src/locales/lki.json | 6 +- src/locales/lrc.json | 5 +- src/locales/lv.json | 8 +- src/locales/map-bms.json | 5 +- src/locales/mg.json | 4 +- src/locales/mk.json | 6 +- src/locales/mn.json | 6 +- src/locales/mnw.json | 5 +- src/locales/mr.json | 6 +- src/locales/ms.json | 6 +- src/locales/my.json | 5 +- src/locales/nah.json | 6 +- src/locales/nap.json | 7 +- src/locales/nds.json | 5 +- src/locales/nn.json | 4 +- src/locales/oc.json | 5 +- src/locales/olo.json | 6 +- src/locales/os.json | 4 +- src/locales/pa.json | 8 +- src/locales/pms.json | 4 +- src/locales/ps.json | 4 +- src/locales/sc.json | 4 +- src/locales/sd.json | 7 +- src/locales/sh.json | 5 +- src/locales/shn.json | 6 +- src/locales/skr-arab.json | 4 +- src/locales/sms.json | 4 +- src/locales/sq.json | 7 +- src/locales/sro.json | 5 +- src/locales/sw.json | 6 +- src/locales/ta.json | 6 +- src/locales/tcy.json | 5 +- src/locales/th.json | 7 +- src/locales/vec.json | 4 +- src/node/db/API.ts | 774 +- src/node/db/AuthorManager.ts | 326 +- src/node/db/DB.ts | 54 +- src/node/db/GroupManager.ts | 195 +- src/node/db/Pad.ts | 1613 +- src/node/db/PadManager.ts | 220 +- src/node/db/ReadOnlyManager.ts | 49 +- src/node/db/SecurityManager.ts | 211 +- src/node/db/SessionManager.ts | 339 +- src/node/db/SessionStore.ts | 199 +- src/node/eejs/index.ts | 144 +- src/node/handler/APIHandler.ts | 273 +- src/node/handler/ExportHandler.ts | 167 +- src/node/handler/ImportHandler.ts | 398 +- src/node/handler/PadMessageHandler.ts | 2112 +- src/node/handler/SocketIORouter.ts | 119 +- src/node/hooks/express.ts | 463 +- src/node/hooks/express/admin.ts | 37 +- src/node/hooks/express/adminplugins.ts | 204 +- src/node/hooks/express/adminsettings.ts | 382 +- src/node/hooks/express/apicalls.ts | 79 +- src/node/hooks/express/errorhandling.ts | 36 +- src/node/hooks/express/importexport.ts | 185 +- src/node/hooks/express/openapi.ts | 1349 +- src/node/hooks/express/padurlsanitize.ts | 61 +- src/node/hooks/express/socketio.ts | 270 +- src/node/hooks/express/specialpages.ts | 222 +- src/node/hooks/express/static.ts | 149 +- src/node/hooks/express/tests.ts | 160 +- src/node/hooks/express/webaccess.ts | 408 +- src/node/hooks/i18n.ts | 245 +- src/node/padaccess.ts | 43 +- src/node/security/OAuth2Provider.ts | 574 +- src/node/security/OAuth2User.ts | 8 +- src/node/security/OIDCAdapter.ts | 179 +- src/node/security/SecretRotator.ts | 505 +- src/node/security/crypto.ts | 7 +- src/node/server.ts | 482 +- src/node/stats.ts | 8 +- src/node/types/ArgsExpressType.ts | 8 +- src/node/types/AsyncQueueTask.ts | 8 +- src/node/types/ChangeSet.ts | 4 +- src/node/types/DeriveModel.ts | 10 +- src/node/types/ErrorCaused.ts | 21 +- src/node/types/I18nPluginDefs.ts | 8 +- src/node/types/LegacyParams.ts | 14 +- src/node/types/MapType.ts | 8 +- src/node/types/PackageInfo.ts | 35 +- src/node/types/PadSearchQuery.ts | 23 +- src/node/types/PadType.ts | 78 +- src/node/types/PartType.ts | 14 +- src/node/types/Plugin.ts | 13 +- src/node/types/PromiseWithStd.ts | 12 +- src/node/types/QueryType.ts | 8 +- src/node/types/RunCMDOptions.ts | 20 +- src/node/types/SecretRotatorType.ts | 4 +- src/node/types/SettingsUser.ts | 10 +- src/node/types/SocketAcknowledge.ts | 4 +- src/node/types/SocketClientRequest.ts | 48 +- src/node/types/SocketModule.ts | 4 +- src/node/types/SwaggerUIResource.ts | 56 +- src/node/types/UserSettingsObject.ts | 8 +- src/node/types/WebAccessTypes.ts | 16 +- src/node/utils/Abiword.ts | 148 +- src/node/utils/AbsolutePaths.ts | 146 +- src/node/utils/Cli.ts | 30 +- src/node/utils/ExportEtherpad.ts | 100 +- src/node/utils/ExportHelper.ts | 134 +- src/node/utils/ExportHtml.ts | 935 +- src/node/utils/ExportTxt.ts | 408 +- src/node/utils/ImportEtherpad.ts | 210 +- src/node/utils/ImportHtml.ts | 130 +- src/node/utils/LibreOffice.ts | 185 +- src/node/utils/Minify.js | 512 +- src/node/utils/MinifyWorker.js | 44 +- src/node/utils/NodeVersion.ts | 46 +- src/node/utils/Settings.ts | 1020 +- src/node/utils/SettingsTree.ts | 178 +- src/node/utils/Stream.ts | 266 +- src/node/utils/UpdateCheck.ts | 90 +- src/node/utils/caching_middleware.ts | 286 +- src/node/utils/checkValidRev.ts | 41 +- src/node/utils/customError.ts | 24 +- src/node/utils/padDiff.ts | 952 +- src/node/utils/path_exists.ts | 22 +- src/node/utils/promises.ts | 113 +- src/node/utils/randomstring.ts | 7 +- src/node/utils/run_cmd.ts | 230 +- src/node/utils/sanitizePathname.ts | 36 +- src/node/utils/tar.json | 198 +- src/node/utils/toolbar.ts | 493 +- src/package.json | 266 +- src/playwright.config.ts | 132 +- src/static/font/config.json | 1608 +- src/static/js/AttributeManager.js | 590 +- src/static/js/AttributeMap.js | 127 +- src/static/js/AttributePool.js | 348 +- src/static/js/Changeset.js | 3305 +- src/static/js/ChangesetUtils.js | 48 +- src/static/js/ChatMessage.js | 169 +- src/static/js/ace.js | 530 +- src/static/js/ace2_common.js | 41 +- src/static/js/ace2_inner.js | 7463 +-- src/static/js/attributes.js | 63 +- src/static/js/basic_error_handler.js | 70 +- src/static/js/broadcast.js | 892 +- src/static/js/broadcast_revisions.js | 163 +- src/static/js/broadcast_slider.js | 587 +- src/static/js/caretPosition.js | 288 +- src/static/js/changesettracker.js | 346 +- src/static/js/chat.js | 499 +- src/static/js/collab_client.js | 868 +- src/static/js/colorutils.js | 108 +- src/static/js/contentcollector.js | 1298 +- src/static/js/cssmanager.js | 82 +- src/static/js/domline.js | 473 +- src/static/js/index.js | 67 +- src/static/js/l10n.js | 27 +- src/static/js/linestylefilter.js | 453 +- src/static/js/pad.js | 1316 +- src/static/js/pad_automatic_reconnect.js | 248 +- src/static/js/pad_connectionstatus.js | 118 +- src/static/js/pad_cookie.js | 95 +- src/static/js/pad_editbar.js | 846 +- src/static/js/pad_editor.js | 363 +- src/static/js/pad_impexp.js | 292 +- src/static/js/pad_modals.js | 50 +- src/static/js/pad_savedrevs.js | 29 +- src/static/js/pad_userlist.js | 1100 +- src/static/js/pad_utils.js | 822 +- src/static/js/pluginfw/LinkInstaller.ts | 469 +- src/static/js/pluginfw/client_plugins.js | 95 +- src/static/js/pluginfw/hooks.js | 495 +- src/static/js/pluginfw/installer.ts | 367 +- src/static/js/pluginfw/plugin_defs.js | 2 +- src/static/js/pluginfw/plugins.js | 310 +- src/static/js/pluginfw/shared.js | 145 +- src/static/js/pluginfw/tsort.js | 152 +- src/static/js/rjquery.js | 4 +- src/static/js/scroll.js | 573 +- src/static/js/security.js | 4 +- src/static/js/skin_variants.js | 106 +- src/static/js/skiplist.js | 572 +- src/static/js/socketio.js | 62 +- src/static/js/timeslider.js | 225 +- src/static/js/underscore.js | 4 +- src/static/js/undomodule.js | 500 +- src/static/js/vendors/browser.js | 588 +- src/static/js/vendors/farbtastic.js | 998 +- src/static/js/vendors/gritter.js | 358 +- src/static/js/vendors/html10n.js | 2077 +- src/static/js/vendors/jquery.js | 10321 ++-- src/static/js/vendors/nice-select.js | 369 +- src/static/skins/colibris/index.js | 8 +- src/static/skins/colibris/pad.js | 12 +- src/static/skins/colibris/timeslider.js | 5 +- src/static/skins/no-skin/index.js | 8 +- src/static/skins/no-skin/pad.js | 8 +- src/static/skins/no-skin/timeslider.js | 8 +- src/tests/backend/common.ts | 437 +- src/tests/backend/fuzzImportTest.ts | 99 +- src/tests/backend/specs/ExportEtherpad.ts | 114 +- src/tests/backend/specs/ImportEtherpad.ts | 440 +- src/tests/backend/specs/Pad.ts | 282 +- src/tests/backend/specs/SecretRotator.ts | 1064 +- src/tests/backend/specs/SessionStore.ts | 501 +- src/tests/backend/specs/Stream.ts | 693 +- src/tests/backend/specs/api/api.ts | 73 +- .../backend/specs/api/characterEncoding.ts | 150 +- src/tests/backend/specs/api/chat.ts | 222 +- src/tests/backend/specs/api/importexport.ts | 518 +- .../backend/specs/api/importexportGetPost.ts | 1508 +- src/tests/backend/specs/api/instance.ts | 97 +- src/tests/backend/specs/api/pad.ts | 1447 +- .../backend/specs/api/restoreRevision.ts | 150 +- .../backend/specs/api/sessionsAndGroups.ts | 773 +- src/tests/backend/specs/caching_middleware.ts | 181 +- src/tests/backend/specs/chat.ts | 317 +- src/tests/backend/specs/contentcollector.ts | 676 +- src/tests/backend/specs/crypto.ts | 11 +- src/tests/backend/specs/export.ts | 39 +- src/tests/backend/specs/favicon.ts | 194 +- src/tests/backend/specs/health.ts | 94 +- src/tests/backend/specs/hooks.ts | 2643 +- src/tests/backend/specs/lowerCasePadIds.ts | 171 +- src/tests/backend/specs/messages.ts | 473 +- src/tests/backend/specs/pad_utils.ts | 68 +- src/tests/backend/specs/pads-with-spaces.ts | 34 +- src/tests/backend/specs/promises.ts | 165 +- src/tests/backend/specs/regression-db.ts | 46 +- src/tests/backend/specs/sanitizePathname.ts | 190 +- src/tests/backend/specs/settings.ts | 162 +- src/tests/backend/specs/socketio.ts | 950 +- src/tests/backend/specs/specialpages.ts | 52 +- src/tests/backend/specs/webaccess.ts | 1113 +- src/tests/container/loadSettings.js | 36 +- src/tests/container/specs/api/pad.js | 42 +- .../admin-spec/adminsettings.spec.ts | 101 +- .../admin-spec/admintroubleshooting.spec.ts | 63 +- .../admin-spec/adminupdateplugins.spec.ts | 125 +- src/tests/frontend-new/helper/adminhelper.ts | 58 +- src/tests/frontend-new/helper/padHelper.ts | 234 +- .../frontend-new/helper/settingsHelper.ts | 57 +- src/tests/frontend-new/helper/timeslider.ts | 13 +- src/tests/frontend-new/specs/alphabet.spec.ts | 53 +- src/tests/frontend-new/specs/bold.spec.ts | 76 +- .../specs/change_user_color.spec.ts | 201 +- .../specs/change_user_name.spec.ts | 72 +- src/tests/frontend-new/specs/chat.spec.ts | 214 +- .../specs/clear_authorship_color.spec.ts | 174 +- .../frontend-new/specs/collab_client.spec.ts | 187 +- src/tests/frontend-new/specs/delete.spec.ts | 39 +- .../frontend-new/specs/embed_value.spec.ts | 228 +- src/tests/frontend-new/specs/enter.spec.ts | 129 +- .../frontend-new/specs/font_type.spec.ts | 74 +- .../frontend-new/specs/indentation.spec.ts | 489 +- .../frontend-new/specs/inner_height.spec.ts | 116 +- src/tests/frontend-new/specs/italic.spec.ts | 133 +- src/tests/frontend-new/specs/language.spec.ts | 174 +- .../frontend-new/specs/ordered_list.spec.ts | 190 +- src/tests/frontend-new/specs/redo.spec.ts | 127 +- .../frontend-new/specs/strikethrough.spec.ts | 62 +- .../frontend-new/specs/timeslider.spec.ts | 57 +- .../specs/timeslider_follow.spec.ts | 161 +- src/tests/frontend-new/specs/undo.spec.ts | 108 +- .../frontend-new/specs/unordered_list.spec.ts | 280 +- .../specs/urls_become_clickable.spec.ts | 98 +- src/tests/frontend/cypress/cypress.config.js | 14 +- .../frontend/cypress/integration/test.js | 45 +- src/tests/frontend/easysync-helper.js | 383 +- src/tests/frontend/helper.js | 628 +- src/tests/frontend/helper/methods.js | 179 +- src/tests/frontend/helper/multipleUsers.js | 156 +- src/tests/frontend/helper/ui.js | 81 +- src/tests/frontend/lib/expect.js | 2635 +- src/tests/frontend/lib/mocha.js | 42156 +++++++++------- src/tests/frontend/lib/sendkeys.js | 846 +- src/tests/frontend/lib/underscore.js | 2502 +- src/tests/frontend/runner.js | 629 +- src/tests/frontend/specs/AttributeMap.js | 341 +- src/tests/frontend/specs/attributes.js | 717 +- .../frontend/specs/authorship_of_editions.js | 182 +- src/tests/frontend/specs/chat_hooks.js | 282 +- .../frontend/specs/chat_load_messages.js | 145 +- src/tests/frontend/specs/drag_and_drop.js | 289 +- .../frontend/specs/easysync-assembler.js | 402 +- src/tests/frontend/specs/easysync-compose.js | 95 +- src/tests/frontend/specs/easysync-follow.js | 166 +- .../frontend/specs/easysync-inverseRandom.js | 107 +- .../frontend/specs/easysync-mutations.js | 680 +- src/tests/frontend/specs/easysync-other.js | 341 +- .../frontend/specs/easysync-subAttribution.js | 102 +- src/tests/frontend/specs/helper.js | 942 +- src/tests/frontend/specs/importexport.js | 1184 +- src/tests/frontend/specs/importindents.js | 315 +- ...ultiple_authors_clear_authorship_colors.js | 68 +- src/tests/frontend/specs/pad_modal.js | 172 +- src/tests/frontend/specs/responsiveness.js | 130 +- src/tests/frontend/specs/scrollTo.js | 70 +- .../specs/select_formatting_buttons.js | 263 +- src/tests/frontend/specs/skiplist.js | 88 +- src/tests/frontend/specs/timeslider_labels.js | 96 +- .../specs/timeslider_numeric_padID.js | 52 +- .../frontend/specs/timeslider_revisions.js | 256 +- src/tests/frontend/specs/xxauto_reconnect.js | 90 +- src/tests/frontend/travis/remote_runner.js | 220 +- src/tests/ratelimit/send_changesets.js | 30 +- src/tsconfig.json | 32 +- 339 files changed, 78646 insertions(+), 66730 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d845c88a..4a625498a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,6 +278,9 @@ importers: specifier: ^0.9.2 version: 0.9.2 devDependencies: + '@biomejs/biome': + specifier: 1.7.0 + version: 1.7.0 '@playwright/test': specifier: ^1.43.1 version: 1.43.1 @@ -721,6 +724,94 @@ packages: to-fast-properties: 2.0.0 dev: true + /@biomejs/biome@1.7.0: + resolution: {integrity: sha512-mejiRhnAq6UrXtYvjWJUKdstcT58n0/FfKemFf3d2Ou0HxOdS88HQmWtQ/UgyZvOEPD572YbFTb6IheyROpqkw==} + engines: {node: '>=14.21.3'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@biomejs/cli-darwin-arm64': 1.7.0 + '@biomejs/cli-darwin-x64': 1.7.0 + '@biomejs/cli-linux-arm64': 1.7.0 + '@biomejs/cli-linux-arm64-musl': 1.7.0 + '@biomejs/cli-linux-x64': 1.7.0 + '@biomejs/cli-linux-x64-musl': 1.7.0 + '@biomejs/cli-win32-arm64': 1.7.0 + '@biomejs/cli-win32-x64': 1.7.0 + dev: true + + /@biomejs/cli-darwin-arm64@1.7.0: + resolution: {integrity: sha512-12TaeaKHU4SAZt0fQJ2bYk1jUb4foope7LmgDE5p3c0uMxd3mFkg1k7G721T+K6UHYULcSOQDsNNM8DhYi8Irg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@biomejs/cli-darwin-x64@1.7.0: + resolution: {integrity: sha512-6Qq1BSIB0cpp0cQNqO/+EiUV7FE3jMpF6w7+AgIBXp0oJxUWb2Ff0RDZdO9bfzkimXD58j0vGpNHMGnCcjDV2Q==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@biomejs/cli-linux-arm64-musl@1.7.0: + resolution: {integrity: sha512-pwIY80nU7SAxrVVZ6HD9ah1pruwh9ZqlSR0Nvbg4ZJqQa0POhiB+RJx7+/1Ml2mTZdrl8kb/YiwQpD16uwb5wg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@biomejs/cli-linux-arm64@1.7.0: + resolution: {integrity: sha512-GwSci7xBJ2j1CrdDXDUVXnUtrvypEz/xmiYPpFeVdlX5p95eXx+7FekPPbJfhGGw5WKSsKZ+V8AAlbN+kUwJWw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@biomejs/cli-linux-x64-musl@1.7.0: + resolution: {integrity: sha512-KzCA0mW4LSbCd7XZWaEJvTOTTBjfJoVEXkfq1fsXxww1HB+ww5PGMbhbIcbYCsj2CTJUifeD5hOkyuBVppU1xQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@biomejs/cli-linux-x64@1.7.0: + resolution: {integrity: sha512-1y+odKQsyHcw0JCGRuqhbx7Y6jxOVSh4lGIVDdJxW1b55yD22DY1kcMEfhUte6f95OIc2uqfkwtiI6xQAiZJdw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@biomejs/cli-win32-arm64@1.7.0: + resolution: {integrity: sha512-AvLDUYZBpOUFgS/mni4VruIoVV3uSGbKSkZQBPXsHgL0w4KttLll3NBrVanmWxOHsom6C6ocHLyfAY8HUc8TXg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@biomejs/cli-win32-x64@1.7.0: + resolution: {integrity: sha512-Pylm00BAAuLVb40IH9PC17432BTsY8K4pSUvhvgR1eaalnMaD6ug9SYJTTzKDbT6r24MPAGCTiSZERyhGkGzFQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@docsearch/css@3.6.0: resolution: {integrity: sha512-+sbxb71sWre+PwDK7X2T8+bhS6clcVMLwBPznX45Qu6opJcgRjAp7gYSDzVFp187J+feSj5dNBN1mJoi6ckkUQ==} dev: true diff --git a/src/.eslintrc.cjs b/src/.eslintrc.cjs index 03d432ede..db21428d1 100644 --- a/src/.eslintrc.cjs +++ b/src/.eslintrc.cjs @@ -1,139 +1,108 @@ -'use strict'; +"use strict"; // This is a workaround for https://github.com/eslint/eslint/issues/3458 -require('eslint-config-etherpad/patch/modern-module-resolution'); +require("eslint-config-etherpad/patch/modern-module-resolution"); module.exports = { - ignorePatterns: [ - '/static/js/vendors/browser.js', - '/static/js/vendors/farbtastic.js', - '/static/js/vendors/gritter.js', - '/static/js/vendors/html10n.js', - '/static/js/vendors/jquery.js', - '/static/js/vendors/nice-select.js', - '/tests/frontend/lib/', - ], - overrides: [ - { - files: [ - '**/.eslintrc.*', - ], - extends: 'etherpad/node', - }, - { - files: [ - '**/*', - ], - excludedFiles: [ - '**/.eslintrc.*', - 'tests/frontend/**/*', - ], - extends: 'etherpad/node', - }, - { - files: [ - 'static/**/*', - 'tests/frontend/helper.js', - 'tests/frontend/helper/**/*', - ], - excludedFiles: [ - '**/.eslintrc.*', - ], - extends: 'etherpad/browser', - env: { - 'shared-node-browser': true, - }, - overrides: [ - { - files: [ - 'tests/frontend/helper/**/*', - ], - globals: { - helper: 'readonly', - }, - }, - ], - }, - { - files: [ - 'tests/**/*', - ], - excludedFiles: [ - '**/.eslintrc.*', - 'tests/frontend/cypress/**/*', - 'tests/frontend/helper.js', - 'tests/frontend/helper/**/*', - 'tests/frontend/travis/**/*', - 'tests/ratelimit/**/*', - ], - extends: 'etherpad/tests', - rules: { - 'mocha/no-exports': 'off', - 'mocha/no-top-level-hooks': 'off', - }, - }, - { - files: [ - 'tests/backend/**/*', - ], - excludedFiles: [ - '**/.eslintrc.*', - ], - extends: 'etherpad/tests/backend', - overrides: [ - { - files: [ - 'tests/backend/**/*', - ], - excludedFiles: [ - 'tests/backend/specs/**/*', - ], - rules: { - 'mocha/no-exports': 'off', - 'mocha/no-top-level-hooks': 'off', - }, - }, - ], - }, - { - files: [ - 'tests/frontend/**/*', - ], - excludedFiles: [ - '**/.eslintrc.*', - 'tests/frontend/cypress/**/*', - 'tests/frontend/helper.js', - 'tests/frontend/helper/**/*', - 'tests/frontend/travis/**/*', - ], - extends: 'etherpad/tests/frontend', - overrides: [ - { - files: [ - 'tests/frontend/**/*', - ], - excludedFiles: [ - 'tests/frontend/specs/**/*', - ], - rules: { - 'mocha/no-exports': 'off', - 'mocha/no-top-level-hooks': 'off', - }, - }, - ], - }, - { - files: [ - 'tests/frontend/cypress/**/*', - ], - extends: 'etherpad/tests/cypress', - }, - { - files: [ - 'tests/frontend/travis/**/*', - ], - extends: 'etherpad/node', - }, - ], - root: true, + ignorePatterns: [ + "/static/js/vendors/browser.js", + "/static/js/vendors/farbtastic.js", + "/static/js/vendors/gritter.js", + "/static/js/vendors/html10n.js", + "/static/js/vendors/jquery.js", + "/static/js/vendors/nice-select.js", + "/tests/frontend/lib/", + ], + overrides: [ + { + files: ["**/.eslintrc.*"], + extends: "etherpad/node", + }, + { + files: ["**/*"], + excludedFiles: ["**/.eslintrc.*", "tests/frontend/**/*"], + extends: "etherpad/node", + }, + { + files: [ + "static/**/*", + "tests/frontend/helper.js", + "tests/frontend/helper/**/*", + ], + excludedFiles: ["**/.eslintrc.*"], + extends: "etherpad/browser", + env: { + "shared-node-browser": true, + }, + overrides: [ + { + files: ["tests/frontend/helper/**/*"], + globals: { + helper: "readonly", + }, + }, + ], + }, + { + files: ["tests/**/*"], + excludedFiles: [ + "**/.eslintrc.*", + "tests/frontend/cypress/**/*", + "tests/frontend/helper.js", + "tests/frontend/helper/**/*", + "tests/frontend/travis/**/*", + "tests/ratelimit/**/*", + ], + extends: "etherpad/tests", + rules: { + "mocha/no-exports": "off", + "mocha/no-top-level-hooks": "off", + }, + }, + { + files: ["tests/backend/**/*"], + excludedFiles: ["**/.eslintrc.*"], + extends: "etherpad/tests/backend", + overrides: [ + { + files: ["tests/backend/**/*"], + excludedFiles: ["tests/backend/specs/**/*"], + rules: { + "mocha/no-exports": "off", + "mocha/no-top-level-hooks": "off", + }, + }, + ], + }, + { + files: ["tests/frontend/**/*"], + excludedFiles: [ + "**/.eslintrc.*", + "tests/frontend/cypress/**/*", + "tests/frontend/helper.js", + "tests/frontend/helper/**/*", + "tests/frontend/travis/**/*", + ], + extends: "etherpad/tests/frontend", + overrides: [ + { + files: ["tests/frontend/**/*"], + excludedFiles: ["tests/frontend/specs/**/*"], + rules: { + "mocha/no-exports": "off", + "mocha/no-top-level-hooks": "off", + }, + }, + ], + }, + { + files: ["tests/frontend/cypress/**/*"], + extends: "etherpad/tests/cypress", + }, + { + files: ["tests/frontend/travis/**/*"], + extends: "etherpad/node", + }, + ], + root: true, }; diff --git a/src/ep.json b/src/ep.json index 95cb4135e..090bca081 100644 --- a/src/ep.json +++ b/src/ep.json @@ -1,117 +1,117 @@ { - "parts": [ - { - "name": "DB", - "hooks": { - "shutdown": "ep_etherpad-lite/node/db/DB" - } - }, - { - "name": "Minify", - "hooks": { - "shutdown": "ep_etherpad-lite/node/utils/Minify" - } - }, - { - "name": "express", - "hooks": { - "createServer": "ep_etherpad-lite/node/hooks/express", - "restartServer": "ep_etherpad-lite/node/hooks/express", - "shutdown": "ep_etherpad-lite/node/hooks/express" - } - }, - { - "name": "static", - "hooks": { - "expressPreSession": "ep_etherpad-lite/node/hooks/express/static" - } - }, - { - "name": "stats", - "hooks": { - "shutdown": "ep_etherpad-lite/node/stats" - } - }, - { - "name": "i18n", - "hooks": { - "expressPreSession": "ep_etherpad-lite/node/hooks/i18n" - } - }, - { - "name": "specialpages", - "hooks": { - "expressCreateServer": "ep_etherpad-lite/node/hooks/express/specialpages", - "expressPreSession": "ep_etherpad-lite/node/hooks/express/specialpages" - } - }, - { - "name": "oauth2", - "hooks": { - "expressCreateServer": "ep_etherpad-lite/node/security/OAuth2Provider" - } - }, - { - "name": "padurlsanitize", - "hooks": { - "expressCreateServer": "ep_etherpad-lite/node/hooks/express/padurlsanitize" - } - }, - { - "name": "apicalls", - "hooks": { - "expressPreSession": "ep_etherpad-lite/node/hooks/express/apicalls" - } - }, - { - "name": "importexport", - "hooks": { - "expressCreateServer": "ep_etherpad-lite/node/hooks/express/importexport" - } - }, - { - "name": "errorhandling", - "hooks": { - "expressCreateServer": "ep_etherpad-lite/node/hooks/express/errorhandling" - } - }, - { - "name": "socketio", - "hooks": { - "expressCloseServer": "ep_etherpad-lite/node/hooks/express/socketio", - "expressCreateServer": "ep_etherpad-lite/node/hooks/express/socketio", - "socketio": "ep_etherpad-lite/node/handler/PadMessageHandler" - } - }, - { - "name": "tests", - "hooks": { - "expressPreSession": "ep_etherpad-lite/node/hooks/express/tests" - } - }, - { - "name": "admin", - "hooks": { - "expressCreateServer": "ep_etherpad-lite/node/hooks/express/admin" - } - }, - { - "name": "adminplugins", - "hooks": { - "socketio": "ep_etherpad-lite/node/hooks/express/adminplugins" - } - }, - { - "name": "adminsettings", - "hooks": { - "socketio": "ep_etherpad-lite/node/hooks/express/adminsettings" - } - }, - { - "name": "openapi", - "hooks": { - "expressPreSession": "ep_etherpad-lite/node/hooks/express/openapi" - } - } - ] + "parts": [ + { + "name": "DB", + "hooks": { + "shutdown": "ep_etherpad-lite/node/db/DB" + } + }, + { + "name": "Minify", + "hooks": { + "shutdown": "ep_etherpad-lite/node/utils/Minify" + } + }, + { + "name": "express", + "hooks": { + "createServer": "ep_etherpad-lite/node/hooks/express", + "restartServer": "ep_etherpad-lite/node/hooks/express", + "shutdown": "ep_etherpad-lite/node/hooks/express" + } + }, + { + "name": "static", + "hooks": { + "expressPreSession": "ep_etherpad-lite/node/hooks/express/static" + } + }, + { + "name": "stats", + "hooks": { + "shutdown": "ep_etherpad-lite/node/stats" + } + }, + { + "name": "i18n", + "hooks": { + "expressPreSession": "ep_etherpad-lite/node/hooks/i18n" + } + }, + { + "name": "specialpages", + "hooks": { + "expressCreateServer": "ep_etherpad-lite/node/hooks/express/specialpages", + "expressPreSession": "ep_etherpad-lite/node/hooks/express/specialpages" + } + }, + { + "name": "oauth2", + "hooks": { + "expressCreateServer": "ep_etherpad-lite/node/security/OAuth2Provider" + } + }, + { + "name": "padurlsanitize", + "hooks": { + "expressCreateServer": "ep_etherpad-lite/node/hooks/express/padurlsanitize" + } + }, + { + "name": "apicalls", + "hooks": { + "expressPreSession": "ep_etherpad-lite/node/hooks/express/apicalls" + } + }, + { + "name": "importexport", + "hooks": { + "expressCreateServer": "ep_etherpad-lite/node/hooks/express/importexport" + } + }, + { + "name": "errorhandling", + "hooks": { + "expressCreateServer": "ep_etherpad-lite/node/hooks/express/errorhandling" + } + }, + { + "name": "socketio", + "hooks": { + "expressCloseServer": "ep_etherpad-lite/node/hooks/express/socketio", + "expressCreateServer": "ep_etherpad-lite/node/hooks/express/socketio", + "socketio": "ep_etherpad-lite/node/handler/PadMessageHandler" + } + }, + { + "name": "tests", + "hooks": { + "expressPreSession": "ep_etherpad-lite/node/hooks/express/tests" + } + }, + { + "name": "admin", + "hooks": { + "expressCreateServer": "ep_etherpad-lite/node/hooks/express/admin" + } + }, + { + "name": "adminplugins", + "hooks": { + "socketio": "ep_etherpad-lite/node/hooks/express/adminplugins" + } + }, + { + "name": "adminsettings", + "hooks": { + "socketio": "ep_etherpad-lite/node/hooks/express/adminsettings" + } + }, + { + "name": "openapi", + "hooks": { + "expressPreSession": "ep_etherpad-lite/node/hooks/express/openapi" + } + } + ] } diff --git a/src/locales/af.json b/src/locales/af.json index 5b41aee10..4a8378e0b 100644 --- a/src/locales/af.json +++ b/src/locales/af.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "Fwolff", - "Naudefj" - ] + "authors": ["Fwolff", "Naudefj"] }, "index.newPad": "Nuwe pad", "index.createOpenPad": "of skep/open 'n pad met die naam:", diff --git a/src/locales/ast.json b/src/locales/ast.json index b46964bf4..052d320da 100644 --- a/src/locales/ast.json +++ b/src/locales/ast.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "Xuacu", - "YoaR" - ] + "authors": ["Xuacu", "YoaR"] }, "index.newPad": "Nuevu bloc", "index.createOpenPad": "o crear/abrir un bloc col nome:", diff --git a/src/locales/awa.json b/src/locales/awa.json index 947cde330..ce79873f2 100644 --- a/src/locales/awa.json +++ b/src/locales/awa.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "1AnuraagPandey", - "बडा काजी" - ] + "authors": ["1AnuraagPandey", "बडा काजी"] }, "index.newPad": "नयाँ प्याड", "pad.toolbar.bold.title": "मोट (Ctrl-B)", diff --git a/src/locales/azb.json b/src/locales/azb.json index 812d38f66..2e67a265c 100644 --- a/src/locales/azb.json +++ b/src/locales/azb.json @@ -1,12 +1,6 @@ { "@metadata": { - "authors": [ - "Alp Er Tunqa", - "Amir a57", - "Ilğım", - "Koroğlu", - "Mousa" - ] + "authors": ["Alp Er Tunqa", "Amir a57", "Ilğım", "Koroğlu", "Mousa"] }, "index.newPad": "یئنی یادداشت دفترچه سی", "index.createOpenPad": "یا دا ایجاد /بیر پد آدلا برابر آچماق:", diff --git a/src/locales/bcc.json b/src/locales/bcc.json index d56dbea35..eb231a4e3 100644 --- a/src/locales/bcc.json +++ b/src/locales/bcc.json @@ -1,10 +1,6 @@ { "@metadata": { - "authors": [ - "Baloch Afghanistan", - "Moshtank", - "Sultanselim baloch" - ] + "authors": ["Baloch Afghanistan", "Moshtank", "Sultanselim baloch"] }, "admin.page-title": "کارمسترءِ کُرسی - اترپَد", "admin_plugins": "گݔشانکانءِ کار ءُ بار", diff --git a/src/locales/be-tarask.json b/src/locales/be-tarask.json index b785fb9cc..519251c67 100644 --- a/src/locales/be-tarask.json +++ b/src/locales/be-tarask.json @@ -1,11 +1,6 @@ { "@metadata": { - "authors": [ - "Jim-by", - "Red Winged Duck", - "Renessaince", - "Wizardist" - ] + "authors": ["Jim-by", "Red Winged Duck", "Renessaince", "Wizardist"] }, "admin.page-title": "Адміністрацыйная панэль — Etherpad", "admin_plugins": "Кіраўнік плагінаў", diff --git a/src/locales/bg.json b/src/locales/bg.json index f643ecfb8..dd22ff89c 100644 --- a/src/locales/bg.json +++ b/src/locales/bg.json @@ -1,10 +1,6 @@ { "@metadata": { - "authors": [ - "StanProg", - "Vlad5250", - "Vodnokon4e" - ] + "authors": ["StanProg", "Vlad5250", "Vodnokon4e"] }, "index.newPad": "Нов пад", "index.createOpenPad": "или създаване/отваряне на пад с име:", diff --git a/src/locales/bgn.json b/src/locales/bgn.json index 0bb468cf1..eefdd3b1f 100644 --- a/src/locales/bgn.json +++ b/src/locales/bgn.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Baloch Afghanistan" - ] + "authors": ["Baloch Afghanistan"] }, "index.newPad": "یاداشتی نوکین کتابچه", "index.createOpenPad": "یا جوڑ\t کورتین/پاچ کورتین یک کتابچه ئی یاداشتی بی نام:", diff --git a/src/locales/br.json b/src/locales/br.json index d3c33202d..ab0c4dbe1 100644 --- a/src/locales/br.json +++ b/src/locales/br.json @@ -1,12 +1,6 @@ { "@metadata": { - "authors": [ - "Fohanno", - "Fulup", - "Gwenn-Ael", - "Huñvreüs", - "Y-M D" - ] + "authors": ["Fohanno", "Fulup", "Gwenn-Ael", "Huñvreüs", "Y-M D"] }, "index.newPad": "Pad nevez", "index.createOpenPad": "pe krouiñ/digeriñ ur Pad gant an anv :", diff --git a/src/locales/bs.json b/src/locales/bs.json index 55f9fb966..5197acd4c 100644 --- a/src/locales/bs.json +++ b/src/locales/bs.json @@ -1,11 +1,6 @@ { "@metadata": { - "authors": [ - "Edinwiki", - "Semina x", - "Srdjan m", - "Srđan" - ] + "authors": ["Edinwiki", "Semina x", "Srdjan m", "Srđan"] }, "index.newPad": "Novi Pad", "index.createOpenPad": "ili napravite/otvorite Pad sa imenom:", diff --git a/src/locales/dsb.json b/src/locales/dsb.json index cedac2f88..7930257c7 100644 --- a/src/locales/dsb.json +++ b/src/locales/dsb.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Michawiki" - ] + "authors": ["Michawiki"] }, "admin.page-title": "Administratorowa delka – Etherpad", "admin_plugins": "Zastojnik tykacow", diff --git a/src/locales/dty.json b/src/locales/dty.json index b0dd53568..259a65b76 100644 --- a/src/locales/dty.json +++ b/src/locales/dty.json @@ -1,11 +1,6 @@ { "@metadata": { - "authors": [ - "Nirajan pant", - "बडा काजी", - "रमेश सिंह बोहरा", - "राम प्रसाद जोशी" - ] + "authors": ["Nirajan pant", "बडा काजी", "रमेश सिंह बोहरा", "राम प्रसाद जोशी"] }, "index.newPad": "नौलो प्याड", "index.createOpenPad": "नाउँ सहितको नौलो प्याड सिर्जना गद्य्या / खोल्ल्या :", diff --git a/src/locales/en.json b/src/locales/en.json index 5737fab00..11800f628 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,187 +1,187 @@ { - "admin.page-title": "Admin Dashboard - Etherpad", - "admin_plugins": "Plugin manager", - "admin_plugins.available": "Available plugins", - "admin_plugins.available_not-found": "No plugins found.", - "admin_plugins.available_fetching": "Fetching…", - "admin_plugins.available_install.value": "Install", - "admin_plugins.available_search.placeholder": "Search for plugins to install", - "admin_plugins.description": "Description", - "admin_plugins.installed": "Installed plugins", - "admin_plugins.installed_fetching": "Fetching installed plugins…", - "admin_plugins.installed_nothing": "You haven't installed any plugins yet.", - "admin_plugins.installed_uninstall.value": "Uninstall", - "admin_plugins.last-update": "Last update", - "admin_plugins.name": "Name", - "admin_plugins.page-title": "Plugin manager - Etherpad", - "admin_plugins.version": "Version", - "admin_plugins_info": "Troubleshooting information", - "admin_plugins_info.hooks": "Installed hooks", - "admin_plugins_info.hooks_client": "Client-side hooks", - "admin_plugins_info.hooks_server": "Server-side hooks", - "admin_plugins_info.parts": "Installed parts", - "admin_plugins_info.plugins": "Installed plugins", - "admin_plugins_info.page-title": "Plugin information - Etherpad", - "admin_plugins_info.version": "Etherpad version", - "admin_plugins_info.version_latest": "Latest available version", - "admin_plugins_info.version_number": "Version number", - "admin_settings": "Settings", - "admin_settings.current": "Current configuration", - "admin_settings.current_example-devel": "Example development settings template", - "admin_settings.current_example-prod": "Example production settings template", - "admin_settings.current_restart.value": "Restart Etherpad", - "admin_settings.current_save.value": "Save Settings", - "admin_settings.page-title": "Settings - Etherpad", + "admin.page-title": "Admin Dashboard - Etherpad", + "admin_plugins": "Plugin manager", + "admin_plugins.available": "Available plugins", + "admin_plugins.available_not-found": "No plugins found.", + "admin_plugins.available_fetching": "Fetching…", + "admin_plugins.available_install.value": "Install", + "admin_plugins.available_search.placeholder": "Search for plugins to install", + "admin_plugins.description": "Description", + "admin_plugins.installed": "Installed plugins", + "admin_plugins.installed_fetching": "Fetching installed plugins…", + "admin_plugins.installed_nothing": "You haven't installed any plugins yet.", + "admin_plugins.installed_uninstall.value": "Uninstall", + "admin_plugins.last-update": "Last update", + "admin_plugins.name": "Name", + "admin_plugins.page-title": "Plugin manager - Etherpad", + "admin_plugins.version": "Version", + "admin_plugins_info": "Troubleshooting information", + "admin_plugins_info.hooks": "Installed hooks", + "admin_plugins_info.hooks_client": "Client-side hooks", + "admin_plugins_info.hooks_server": "Server-side hooks", + "admin_plugins_info.parts": "Installed parts", + "admin_plugins_info.plugins": "Installed plugins", + "admin_plugins_info.page-title": "Plugin information - Etherpad", + "admin_plugins_info.version": "Etherpad version", + "admin_plugins_info.version_latest": "Latest available version", + "admin_plugins_info.version_number": "Version number", + "admin_settings": "Settings", + "admin_settings.current": "Current configuration", + "admin_settings.current_example-devel": "Example development settings template", + "admin_settings.current_example-prod": "Example production settings template", + "admin_settings.current_restart.value": "Restart Etherpad", + "admin_settings.current_save.value": "Save Settings", + "admin_settings.page-title": "Settings - Etherpad", - "index.newPad": "New Pad", - "index.createOpenPad": "or create/open a Pad with the name:", - "index.openPad": "open an existing Pad with the name:", + "index.newPad": "New Pad", + "index.createOpenPad": "or create/open a Pad with the name:", + "index.openPad": "open an existing Pad with the name:", - "pad.toolbar.bold.title": "Bold (Ctrl+B)", - "pad.toolbar.italic.title": "Italic (Ctrl+I)", - "pad.toolbar.underline.title": "Underline (Ctrl+U)", - "pad.toolbar.strikethrough.title": "Strikethrough (Ctrl+5)", - "pad.toolbar.ol.title": "Ordered list (Ctrl+Shift+N)", - "pad.toolbar.ul.title": "Unordered List (Ctrl+Shift+L)", - "pad.toolbar.indent.title": "Indent (TAB)", - "pad.toolbar.unindent.title": "Outdent (Shift+TAB)", - "pad.toolbar.undo.title": "Undo (Ctrl+Z)", - "pad.toolbar.redo.title": "Redo (Ctrl+Y)", - "pad.toolbar.clearAuthorship.title": "Clear Authorship Colors (Ctrl+Shift+C)", - "pad.toolbar.import_export.title": "Import/Export from/to different file formats", - "pad.toolbar.timeslider.title": "Timeslider", - "pad.toolbar.savedRevision.title": "Save Revision", - "pad.toolbar.settings.title": "Settings", - "pad.toolbar.embed.title": "Share and Embed this pad", - "pad.toolbar.showusers.title": "Show the users on this pad", + "pad.toolbar.bold.title": "Bold (Ctrl+B)", + "pad.toolbar.italic.title": "Italic (Ctrl+I)", + "pad.toolbar.underline.title": "Underline (Ctrl+U)", + "pad.toolbar.strikethrough.title": "Strikethrough (Ctrl+5)", + "pad.toolbar.ol.title": "Ordered list (Ctrl+Shift+N)", + "pad.toolbar.ul.title": "Unordered List (Ctrl+Shift+L)", + "pad.toolbar.indent.title": "Indent (TAB)", + "pad.toolbar.unindent.title": "Outdent (Shift+TAB)", + "pad.toolbar.undo.title": "Undo (Ctrl+Z)", + "pad.toolbar.redo.title": "Redo (Ctrl+Y)", + "pad.toolbar.clearAuthorship.title": "Clear Authorship Colors (Ctrl+Shift+C)", + "pad.toolbar.import_export.title": "Import/Export from/to different file formats", + "pad.toolbar.timeslider.title": "Timeslider", + "pad.toolbar.savedRevision.title": "Save Revision", + "pad.toolbar.settings.title": "Settings", + "pad.toolbar.embed.title": "Share and Embed this pad", + "pad.toolbar.showusers.title": "Show the users on this pad", - "pad.colorpicker.save": "Save", - "pad.colorpicker.cancel": "Cancel", + "pad.colorpicker.save": "Save", + "pad.colorpicker.cancel": "Cancel", - "pad.loading": "Loading...", - "pad.noCookie": "Cookie could not be found. Please allow cookies in your browser! Your session and settings will not be saved between visits. This may be due to Etherpad being included in an iFrame in some Browsers. Please ensure Etherpad is on the same subdomain/domain as the parent iFrame", - "pad.permissionDenied": "You do not have permission to access this pad", + "pad.loading": "Loading...", + "pad.noCookie": "Cookie could not be found. Please allow cookies in your browser! Your session and settings will not be saved between visits. This may be due to Etherpad being included in an iFrame in some Browsers. Please ensure Etherpad is on the same subdomain/domain as the parent iFrame", + "pad.permissionDenied": "You do not have permission to access this pad", - "pad.settings.padSettings": "Pad Settings", - "pad.settings.myView": "My View", - "pad.settings.stickychat": "Chat always on screen", - "pad.settings.chatandusers": "Show Chat and Users", - "pad.settings.colorcheck": "Authorship colors", - "pad.settings.linenocheck": "Line numbers", - "pad.settings.rtlcheck": "Read content from right to left?", - "pad.settings.fontType": "Font type:", - "pad.settings.fontType.normal": "Normal", - "pad.settings.language": "Language:", - "pad.settings.about": "About", - "pad.settings.poweredBy": "Powered by", + "pad.settings.padSettings": "Pad Settings", + "pad.settings.myView": "My View", + "pad.settings.stickychat": "Chat always on screen", + "pad.settings.chatandusers": "Show Chat and Users", + "pad.settings.colorcheck": "Authorship colors", + "pad.settings.linenocheck": "Line numbers", + "pad.settings.rtlcheck": "Read content from right to left?", + "pad.settings.fontType": "Font type:", + "pad.settings.fontType.normal": "Normal", + "pad.settings.language": "Language:", + "pad.settings.about": "About", + "pad.settings.poweredBy": "Powered by", - "pad.importExport.import_export": "Import/Export", - "pad.importExport.import": "Upload any text file or document", - "pad.importExport.importSuccessful": "Successful!", - "pad.importExport.export": "Export current pad as:", - "pad.importExport.exportetherpad": "Etherpad", - "pad.importExport.exporthtml": "HTML", - "pad.importExport.exportplain": "Plain text", - "pad.importExport.exportword": "Microsoft Word", - "pad.importExport.exportpdf": "PDF", - "pad.importExport.exportopen": "ODF (Open Document Format)", - "pad.importExport.abiword.innerHTML": "You only can import from plain text or HTML formats. For more advanced import features please install AbiWord or LibreOffice.", + "pad.importExport.import_export": "Import/Export", + "pad.importExport.import": "Upload any text file or document", + "pad.importExport.importSuccessful": "Successful!", + "pad.importExport.export": "Export current pad as:", + "pad.importExport.exportetherpad": "Etherpad", + "pad.importExport.exporthtml": "HTML", + "pad.importExport.exportplain": "Plain text", + "pad.importExport.exportword": "Microsoft Word", + "pad.importExport.exportpdf": "PDF", + "pad.importExport.exportopen": "ODF (Open Document Format)", + "pad.importExport.abiword.innerHTML": "You only can import from plain text or HTML formats. For more advanced import features please install AbiWord or LibreOffice.", - "pad.modals.connected": "Connected.", - "pad.modals.reconnecting": "Reconnecting to your pad…", - "pad.modals.forcereconnect": "Force reconnect", - "pad.modals.reconnecttimer": "Trying to reconnect in", - "pad.modals.cancel": "Cancel", + "pad.modals.connected": "Connected.", + "pad.modals.reconnecting": "Reconnecting to your pad…", + "pad.modals.forcereconnect": "Force reconnect", + "pad.modals.reconnecttimer": "Trying to reconnect in", + "pad.modals.cancel": "Cancel", - "pad.modals.userdup": "Opened in another window", - "pad.modals.userdup.explanation": "This pad seems to be opened in more than one browser window on this computer.", - "pad.modals.userdup.advice": "Reconnect to use this window instead.", + "pad.modals.userdup": "Opened in another window", + "pad.modals.userdup.explanation": "This pad seems to be opened in more than one browser window on this computer.", + "pad.modals.userdup.advice": "Reconnect to use this window instead.", - "pad.modals.unauth": "Not authorized", - "pad.modals.unauth.explanation": "Your permissions have changed while viewing this page. Try to reconnect.", + "pad.modals.unauth": "Not authorized", + "pad.modals.unauth.explanation": "Your permissions have changed while viewing this page. Try to reconnect.", - "pad.modals.looping.explanation": "There are communication problems with the synchronization server.", - "pad.modals.looping.cause": "Perhaps you connected through an incompatible firewall or proxy.", + "pad.modals.looping.explanation": "There are communication problems with the synchronization server.", + "pad.modals.looping.cause": "Perhaps you connected through an incompatible firewall or proxy.", - "pad.modals.initsocketfail": "Server is unreachable.", - "pad.modals.initsocketfail.explanation": "Couldn't connect to the synchronization server.", - "pad.modals.initsocketfail.cause": "This is probably due to a problem with your browser or your internet connection.", + "pad.modals.initsocketfail": "Server is unreachable.", + "pad.modals.initsocketfail.explanation": "Couldn't connect to the synchronization server.", + "pad.modals.initsocketfail.cause": "This is probably due to a problem with your browser or your internet connection.", - "pad.modals.slowcommit.explanation": "The server is not responding.", - "pad.modals.slowcommit.cause": "This could be due to problems with network connectivity.", + "pad.modals.slowcommit.explanation": "The server is not responding.", + "pad.modals.slowcommit.cause": "This could be due to problems with network connectivity.", - "pad.modals.badChangeset.explanation": "An edit you have made was classified illegal by the synchronization server.", - "pad.modals.badChangeset.cause": "This could be due to a wrong server configuration or some other unexpected behavior. Please contact the service administrator, if you feel this is an error. Try to reconnect in order to continue editing.", + "pad.modals.badChangeset.explanation": "An edit you have made was classified illegal by the synchronization server.", + "pad.modals.badChangeset.cause": "This could be due to a wrong server configuration or some other unexpected behavior. Please contact the service administrator, if you feel this is an error. Try to reconnect in order to continue editing.", - "pad.modals.corruptPad.explanation": "The pad you are trying to access is corrupt.", - "pad.modals.corruptPad.cause": "This may be due to a wrong server configuration or some other unexpected behavior. Please contact the service administrator.", + "pad.modals.corruptPad.explanation": "The pad you are trying to access is corrupt.", + "pad.modals.corruptPad.cause": "This may be due to a wrong server configuration or some other unexpected behavior. Please contact the service administrator.", - "pad.modals.deleted": "Deleted.", - "pad.modals.deleted.explanation": "This pad has been removed.", + "pad.modals.deleted": "Deleted.", + "pad.modals.deleted.explanation": "This pad has been removed.", - "pad.modals.rateLimited": "Rate Limited.", - "pad.modals.rateLimited.explanation": "You sent too many messages to this pad so it disconnected you.", + "pad.modals.rateLimited": "Rate Limited.", + "pad.modals.rateLimited.explanation": "You sent too many messages to this pad so it disconnected you.", - "pad.modals.rejected.explanation": "The server rejected a message that was sent by your browser.", - "pad.modals.rejected.cause": "The server may have been updated while you were viewing the pad, or maybe there is a bug in Etherpad. Try reloading the page.", + "pad.modals.rejected.explanation": "The server rejected a message that was sent by your browser.", + "pad.modals.rejected.cause": "The server may have been updated while you were viewing the pad, or maybe there is a bug in Etherpad. Try reloading the page.", - "pad.modals.disconnected": "You have been disconnected.", - "pad.modals.disconnected.explanation": "The connection to the server was lost", - "pad.modals.disconnected.cause": "The server may be unavailable. Please notify the service administrator if this continues to happen.", + "pad.modals.disconnected": "You have been disconnected.", + "pad.modals.disconnected.explanation": "The connection to the server was lost", + "pad.modals.disconnected.cause": "The server may be unavailable. Please notify the service administrator if this continues to happen.", - "pad.share": "Share this pad", - "pad.share.readonly": "Read only", - "pad.share.link": "Link", - "pad.share.emebdcode": "Embed URL", - "pad.chat": "Chat", - "pad.chat.title": "Open the chat for this pad.", - "pad.chat.loadmessages": "Load more messages", - "pad.chat.stick.title": "Stick chat to screen", - "pad.chat.writeMessage.placeholder": "Write your message here", + "pad.share": "Share this pad", + "pad.share.readonly": "Read only", + "pad.share.link": "Link", + "pad.share.emebdcode": "Embed URL", + "pad.chat": "Chat", + "pad.chat.title": "Open the chat for this pad.", + "pad.chat.loadmessages": "Load more messages", + "pad.chat.stick.title": "Stick chat to screen", + "pad.chat.writeMessage.placeholder": "Write your message here", - "timeslider.followContents": "Follow pad content updates", - "timeslider.pageTitle": "{{appTitle}} Timeslider", - "timeslider.toolbar.returnbutton": "Return to pad", - "timeslider.toolbar.authors": "Authors:", - "timeslider.toolbar.authorsList": "No Authors", - "timeslider.toolbar.exportlink.title": "Export", - "timeslider.exportCurrent": "Export current version as:", - "timeslider.version": "Version {{version}}", - "timeslider.saved": "Saved {{month}} {{day}}, {{year}}", + "timeslider.followContents": "Follow pad content updates", + "timeslider.pageTitle": "{{appTitle}} Timeslider", + "timeslider.toolbar.returnbutton": "Return to pad", + "timeslider.toolbar.authors": "Authors:", + "timeslider.toolbar.authorsList": "No Authors", + "timeslider.toolbar.exportlink.title": "Export", + "timeslider.exportCurrent": "Export current version as:", + "timeslider.version": "Version {{version}}", + "timeslider.saved": "Saved {{month}} {{day}}, {{year}}", - "timeslider.playPause": "Playback / Pause Pad Contents", - "timeslider.backRevision":"Go back a revision in this Pad", - "timeslider.forwardRevision":"Go forward a revision in this Pad", + "timeslider.playPause": "Playback / Pause Pad Contents", + "timeslider.backRevision": "Go back a revision in this Pad", + "timeslider.forwardRevision": "Go forward a revision in this Pad", - "timeslider.dateformat": "{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}", - "timeslider.month.january": "January", - "timeslider.month.february": "February", - "timeslider.month.march": "March", - "timeslider.month.april": "April", - "timeslider.month.may": "May", - "timeslider.month.june": "June", - "timeslider.month.july": "July", - "timeslider.month.august": "August", - "timeslider.month.september": "September", - "timeslider.month.october": "October", - "timeslider.month.november": "November", - "timeslider.month.december": "December", + "timeslider.dateformat": "{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}", + "timeslider.month.january": "January", + "timeslider.month.february": "February", + "timeslider.month.march": "March", + "timeslider.month.april": "April", + "timeslider.month.may": "May", + "timeslider.month.june": "June", + "timeslider.month.july": "July", + "timeslider.month.august": "August", + "timeslider.month.september": "September", + "timeslider.month.october": "October", + "timeslider.month.november": "November", + "timeslider.month.december": "December", - "timeslider.unnamedauthors": "{{num}} unnamed {[plural(num) one: author, other: authors ]}", - "pad.savedrevs.marked": "This revision is now marked as a saved revision", - "pad.savedrevs.timeslider": "You can see saved revisions by visiting the timeslider", - "pad.userlist.entername": "Enter your name", - "pad.userlist.unnamed": "unnamed", - "pad.editbar.clearcolors": "Clear authorship colors on entire document? This cannot be undone", + "timeslider.unnamedauthors": "{{num}} unnamed {[plural(num) one: author, other: authors ]}", + "pad.savedrevs.marked": "This revision is now marked as a saved revision", + "pad.savedrevs.timeslider": "You can see saved revisions by visiting the timeslider", + "pad.userlist.entername": "Enter your name", + "pad.userlist.unnamed": "unnamed", + "pad.editbar.clearcolors": "Clear authorship colors on entire document? This cannot be undone", - "pad.impexp.importbutton": "Import Now", - "pad.impexp.importing": "Importing...", - "pad.impexp.confirmimport": "Importing a file will overwrite the current text of the pad. Are you sure you want to proceed?", - "pad.impexp.convertFailed": "We were not able to import this file. Please use a different document format or copy paste manually", - "pad.impexp.padHasData": "We were not able to import this file because this Pad has already had changes, please import to a new pad", - "pad.impexp.uploadFailed": "The upload failed, please try again", - "pad.impexp.importfailed": "Import failed", - "pad.impexp.copypaste": "Please copy paste", - "pad.impexp.exportdisabled": "Exporting as {{type}} format is disabled. Please contact your system administrator for details.", - "pad.impexp.maxFileSize": "File too big. Contact your site administrator to increase the allowed file size for import" + "pad.impexp.importbutton": "Import Now", + "pad.impexp.importing": "Importing...", + "pad.impexp.confirmimport": "Importing a file will overwrite the current text of the pad. Are you sure you want to proceed?", + "pad.impexp.convertFailed": "We were not able to import this file. Please use a different document format or copy paste manually", + "pad.impexp.padHasData": "We were not able to import this file because this Pad has already had changes, please import to a new pad", + "pad.impexp.uploadFailed": "The upload failed, please try again", + "pad.impexp.importfailed": "Import failed", + "pad.impexp.copypaste": "Please copy paste", + "pad.impexp.exportdisabled": "Exporting as {{type}} format is disabled. Please contact your system administrator for details.", + "pad.impexp.maxFileSize": "File too big. Contact your site administrator to increase the allowed file size for import" } diff --git a/src/locales/et.json b/src/locales/et.json index 8ec7900a3..997912b08 100644 --- a/src/locales/et.json +++ b/src/locales/et.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "Kristian.kankainen", - "Tiblu" - ] + "authors": ["Kristian.kankainen", "Tiblu"] }, "index.newPad": "Uus klade", "index.createOpenPad": "loo või rööptoimeta kladet nimega:", diff --git a/src/locales/ff.json b/src/locales/ff.json index dbb6c3b02..b5c10b8c0 100644 --- a/src/locales/ff.json +++ b/src/locales/ff.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Ibrahima Malal Sarr" - ] + "authors": ["Ibrahima Malal Sarr"] }, "admin.page-title": "Tiimtorde Jiiloowo - Etherpad", "admin_plugins": "Toppitorde Ceŋe", diff --git a/src/locales/fo.json b/src/locales/fo.json index c98ade6c4..48b6f5dff 100644 --- a/src/locales/fo.json +++ b/src/locales/fo.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "EileenSanda" - ] + "authors": ["EileenSanda"] }, "index.newPad": "Nýggjur teldil", "pad.toolbar.bold.title": "Við feitum (Ctrl-B)", diff --git a/src/locales/fy.json b/src/locales/fy.json index 093b40e32..a202a347c 100644 --- a/src/locales/fy.json +++ b/src/locales/fy.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Robin van der Vliet" - ] + "authors": ["Robin van der Vliet"] }, "pad.toolbar.bold.title": "Fet (Ctrl+B)", "pad.toolbar.italic.title": "Kursyf (Ctrl+I)", diff --git a/src/locales/gl.json b/src/locales/gl.json index 352737d39..18c58be90 100644 --- a/src/locales/gl.json +++ b/src/locales/gl.json @@ -1,10 +1,6 @@ { "@metadata": { - "authors": [ - "Elisardojm", - "Ghose", - "Toliño" - ] + "authors": ["Elisardojm", "Ghose", "Toliño"] }, "admin.page-title": "Panel de administración - Etherpad", "admin_plugins": "Xestor de complementos", diff --git a/src/locales/gu.json b/src/locales/gu.json index d2f7c4fa1..6de719f13 100644 --- a/src/locales/gu.json +++ b/src/locales/gu.json @@ -1,11 +1,6 @@ { "@metadata": { - "authors": [ - "Bhatakati aatma", - "Dsvyas", - "Harsh4101991", - "KartikMistry" - ] + "authors": ["Bhatakati aatma", "Dsvyas", "Harsh4101991", "KartikMistry"] }, "index.newPad": "નવું પેડ", "pad.toolbar.bold.title": "બોલ્ડ", diff --git a/src/locales/he.json b/src/locales/he.json index 458ad0ace..5be75a3b5 100644 --- a/src/locales/he.json +++ b/src/locales/he.json @@ -1,12 +1,6 @@ { "@metadata": { - "authors": [ - "Amire80", - "Ofrahod", - "Steeve815", - "YaronSh", - "תומר ט" - ] + "authors": ["Amire80", "Ofrahod", "Steeve815", "YaronSh", "תומר ט"] }, "admin.page-title": "לוח ניהול - Etherpad", "admin_plugins": "מנהל תוספים", diff --git a/src/locales/hi.json b/src/locales/hi.json index b238694a5..56589b9b6 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Sfic" - ] + "authors": ["Sfic"] }, "pad.toolbar.bold.title": "गहरा (Ctrl+B)", "pad.toolbar.italic.title": "तिरछा (Ctrl+I)", diff --git a/src/locales/hr.json b/src/locales/hr.json index 77b10674a..d99f29d37 100644 --- a/src/locales/hr.json +++ b/src/locales/hr.json @@ -1,10 +1,6 @@ { "@metadata": { - "authors": [ - "Bugoslav", - "Hmxhmx", - "Ponor" - ] + "authors": ["Bugoslav", "Hmxhmx", "Ponor"] }, "index.newPad": "Novi blokić", "index.createOpenPad": "ili stvori/otvori blokić s imenom:", diff --git a/src/locales/hrx.json b/src/locales/hrx.json index 397e41c9e..c4baf381e 100644 --- a/src/locales/hrx.json +++ b/src/locales/hrx.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Paul Beppler" - ] + "authors": ["Paul Beppler"] }, "index.newPad": "Neies Pad", "index.createOpenPad": "Pad mit follichendem Noome uffmache:", diff --git a/src/locales/hsb.json b/src/locales/hsb.json index 62f70740f..cefac571c 100644 --- a/src/locales/hsb.json +++ b/src/locales/hsb.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Michawiki" - ] + "authors": ["Michawiki"] }, "admin.page-title": "Administratorowa deska – Etherpad", "admin_plugins": "Zrjadowak tykačow", diff --git a/src/locales/hy.json b/src/locales/hy.json index a842a6c24..289bd0e53 100644 --- a/src/locales/hy.json +++ b/src/locales/hy.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "Armenoid", - "Kareyac" - ] + "authors": ["Armenoid", "Kareyac"] }, "admin_plugins.available_install.value": "Տեղադրել", "admin_plugins.description": "Նկարագրություն", diff --git a/src/locales/ia.json b/src/locales/ia.json index 6d0c4f283..88d887528 100644 --- a/src/locales/ia.json +++ b/src/locales/ia.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "McDutchie" - ] + "authors": ["McDutchie"] }, "admin.page-title": "Pannello administrative – Etherpad", "admin_plugins": "Gestor de plug-ins", diff --git a/src/locales/id.json b/src/locales/id.json index 49b8f34fa..fdd941c9e 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -1,11 +1,6 @@ { "@metadata": { - "authors": [ - "Bennylin", - "IvanLanin", - "Marwan Mohamad", - "Veracious" - ] + "authors": ["Bennylin", "IvanLanin", "Marwan Mohamad", "Veracious"] }, "admin.page-title": "Dasbor Pengurus - Etherpad", "admin_plugins": "Manajer plugin", diff --git a/src/locales/is.json b/src/locales/is.json index 0ff9ec5d6..f0aee3469 100644 --- a/src/locales/is.json +++ b/src/locales/is.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "Sveinki", - "Sveinn í Felli" - ] + "authors": ["Sveinki", "Sveinn í Felli"] }, "admin.page-title": "Stjórnborð fyrir stjórnendur - Etherpad", "admin_plugins": "Stýring viðbóta", diff --git a/src/locales/kab.json b/src/locales/kab.json index 77f7fa1ad..3f029a776 100644 --- a/src/locales/kab.json +++ b/src/locales/kab.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Belkacem77" - ] + "authors": ["Belkacem77"] }, "index.newPad": "Apad amaynut", "index.createOpenPad": "neɣ rnu/ldi apad s yisem:", diff --git a/src/locales/km.json b/src/locales/km.json index bbb255f8c..dc81094c6 100644 --- a/src/locales/km.json +++ b/src/locales/km.json @@ -1,10 +1,6 @@ { "@metadata": { - "authors": [ - "Pichnat Thong", - "Sovichet", - "វ័ណថារិទ្ធ" - ] + "authors": ["Pichnat Thong", "Sovichet", "វ័ណថារិទ្ធ"] }, "index.newPad": "ផេតថ្មី", "index.createOpenPad": "ឬបង្កើត/បើកផេតដែលមានឈ្មោះ៖", diff --git a/src/locales/kn.json b/src/locales/kn.json index f1f109c07..e334a0e52 100644 --- a/src/locales/kn.json +++ b/src/locales/kn.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "Nayvik", - "ಮಲ್ನಾಡಾಚ್ ಕೊಂಕ್ಣೊ" - ] + "authors": ["Nayvik", "ಮಲ್ನಾಡಾಚ್ ಕೊಂಕ್ಣೊ"] }, "admin_plugins.available": "ಲಭ್ಯವಿರುವ ಪ್ಲಗಿನ್‌ಗಳು", "admin_plugins.available_not-found": "ಯಾವುದೇ ಪ್ಲಗಿನ್‌ಗಳು ಸಿಗಲಿಲ್ಲ", diff --git a/src/locales/krc.json b/src/locales/krc.json index 76e9bb97a..99a041485 100644 --- a/src/locales/krc.json +++ b/src/locales/krc.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "Ernác", - "Къарачайлы" - ] + "authors": ["Ernác", "Къарачайлы"] }, "admin.page-title": "Администраторну панели — Etherpad", "admin_plugins": "Плагин менеджер", diff --git a/src/locales/ksh.json b/src/locales/ksh.json index 8015e4f11..90d4ec6a5 100644 --- a/src/locales/ksh.json +++ b/src/locales/ksh.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Purodha" - ] + "authors": ["Purodha"] }, "index.newPad": "Neu Pädd", "index.createOpenPad": "udder maach e Pädd op med däm Nahme:", diff --git a/src/locales/lb.json b/src/locales/lb.json index 8bb3beb77..4be590350 100644 --- a/src/locales/lb.json +++ b/src/locales/lb.json @@ -1,11 +1,6 @@ { "@metadata": { - "authors": [ - "Gromper", - "Robby", - "Soued031", - "Volvox" - ] + "authors": ["Gromper", "Robby", "Soued031", "Volvox"] }, "admin_plugins.available_install.value": "Installéieren", "admin_plugins.description": "Beschreiwung", diff --git a/src/locales/lki.json b/src/locales/lki.json index 556368a20..6dc87675a 100644 --- a/src/locales/lki.json +++ b/src/locales/lki.json @@ -1,10 +1,6 @@ { "@metadata": { - "authors": [ - "Arash71", - "Hosseinblue", - "Lakzon" - ] + "authors": ["Arash71", "Hosseinblue", "Lakzon"] }, "index.newPad": "تازۀpad", "index.createOpenPad": ":وە نۆم Pad یا سازین/واز کردن یإگلە", diff --git a/src/locales/lrc.json b/src/locales/lrc.json index ec343a553..5e603d727 100644 --- a/src/locales/lrc.json +++ b/src/locales/lrc.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "Lorestani", - "Mogoeilor" - ] + "authors": ["Lorestani", "Mogoeilor"] }, "index.newPad": "دٱفتٱرچٱ تازٱ", "pad.toolbar.bold.title": "تۊپور", diff --git a/src/locales/lv.json b/src/locales/lv.json index 29d07b19b..3608af29e 100644 --- a/src/locales/lv.json +++ b/src/locales/lv.json @@ -1,12 +1,6 @@ { "@metadata": { - "authors": [ - "Admresdeserv.", - "Jmg.cmdi", - "Oskars", - "Papuass", - "Silraks" - ] + "authors": ["Admresdeserv.", "Jmg.cmdi", "Oskars", "Papuass", "Silraks"] }, "index.newPad": "Tiek izmantots kā pogas teksts. Blociņš, Etherpad kontekstā, ir piezīmju blociņš, uz kura rakstīt.", "index.createOpenPad": "vai izveidojiet/atveriet Blociņu ar nosaukumu:", diff --git a/src/locales/map-bms.json b/src/locales/map-bms.json index d920cc5cb..ed5b39cc3 100644 --- a/src/locales/map-bms.json +++ b/src/locales/map-bms.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "Empu", - "StefanusRA" - ] + "authors": ["Empu", "StefanusRA"] }, "index.newPad": "Pad Anyar", "index.createOpenPad": "utawa gawe/bukak Pad nganggo jeneng:", diff --git a/src/locales/mg.json b/src/locales/mg.json index 79a5296d5..68cbfd927 100644 --- a/src/locales/mg.json +++ b/src/locales/mg.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Jagwar" - ] + "authors": ["Jagwar"] }, "index.newPad": "Pad vaovao", "index.createOpenPad": "na hamorona/hanokatra Pad manana anarana:", diff --git a/src/locales/mk.json b/src/locales/mk.json index 68ba2f1cd..9bc46ec9a 100644 --- a/src/locales/mk.json +++ b/src/locales/mk.json @@ -1,10 +1,6 @@ { "@metadata": { - "authors": [ - "Bjankuloski06", - "Brest", - "Vlad5250" - ] + "authors": ["Bjankuloski06", "Brest", "Vlad5250"] }, "admin.page-title": "Администраторска управувачница — Etherpad", "admin_plugins": "Раководител со приклучоци", diff --git a/src/locales/mn.json b/src/locales/mn.json index aa024466c..633ece95e 100644 --- a/src/locales/mn.json +++ b/src/locales/mn.json @@ -1,10 +1,6 @@ { "@metadata": { - "authors": [ - "MongolWiki", - "Munkhzaya.E", - "Wisdom" - ] + "authors": ["MongolWiki", "Munkhzaya.E", "Wisdom"] }, "pad.toolbar.bold.title": "Болд тескт (Ctrl-B)", "pad.toolbar.italic.title": "Налуу тескт (Ctrl-I)", diff --git a/src/locales/mnw.json b/src/locales/mnw.json index db7dfa56e..6f11ede04 100644 --- a/src/locales/mnw.json +++ b/src/locales/mnw.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "Aue Nai", - "咽頭べさ" - ] + "authors": ["Aue Nai", "咽頭べさ"] }, "admin_plugins.description": "တၚ်ထမံက်ထ္ၜး", "index.newPad": "တၞးတၟိ", diff --git a/src/locales/mr.json b/src/locales/mr.json index a9ecc6baf..050b663a4 100644 --- a/src/locales/mr.json +++ b/src/locales/mr.json @@ -1,10 +1,6 @@ { "@metadata": { - "authors": [ - "Ganeshgiram", - "V.narsikar", - "Ydyashad" - ] + "authors": ["Ganeshgiram", "V.narsikar", "Ydyashad"] }, "index.newPad": "नव पान", "pad.toolbar.bold.title": "ठळक (Ctrl-B)", diff --git a/src/locales/ms.json b/src/locales/ms.json index a5d8d1deb..daa0c50b5 100644 --- a/src/locales/ms.json +++ b/src/locales/ms.json @@ -1,10 +1,6 @@ { "@metadata": { - "authors": [ - "Anakmalaysia", - "Hakimi97", - "Jeluang Terluang" - ] + "authors": ["Anakmalaysia", "Hakimi97", "Jeluang Terluang"] }, "admin.page-title": "Papan muka Penyelia - Etherpad", "index.newPad": "Pad baru", diff --git a/src/locales/my.json b/src/locales/my.json index d427bbcb5..0c40047ae 100644 --- a/src/locales/my.json +++ b/src/locales/my.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "Andibecker", - "Dr Lotus Black" - ] + "authors": ["Andibecker", "Dr Lotus Black"] }, "admin.page-title": "စီမံခန့်ခွဲသူဒိုင်ခွက် - Etherpad", "admin_plugins": "ပလပ်အင်မန်နေဂျာ", diff --git a/src/locales/nah.json b/src/locales/nah.json index a5c40db10..5c6ace326 100644 --- a/src/locales/nah.json +++ b/src/locales/nah.json @@ -1,10 +1,6 @@ { "@metadata": { - "authors": [ - "Akapochtli", - "Languaeditor", - "Taresi" - ] + "authors": ["Akapochtli", "Languaeditor", "Taresi"] }, "index.newPad": "Yancuic Pad", "index.createOpenPad": "auh xicchīhua/xictlapo cē Pad in ītōcā:", diff --git a/src/locales/nap.json b/src/locales/nap.json index 70f615b38..0bc341310 100644 --- a/src/locales/nap.json +++ b/src/locales/nap.json @@ -1,11 +1,6 @@ { "@metadata": { - "authors": [ - "C.R.", - "Chelin", - "Finizio", - "Ruthven" - ] + "authors": ["C.R.", "Chelin", "Finizio", "Ruthven"] }, "admin_plugins.name": "Nomme", "index.newPad": "Nuovo Pad", diff --git a/src/locales/nds.json b/src/locales/nds.json index 75cdaabec..64d8dbc43 100644 --- a/src/locales/nds.json +++ b/src/locales/nds.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "Gthoele", - "Joachim Mos" - ] + "authors": ["Gthoele", "Joachim Mos"] }, "index.newPad": "Nee'et Pad", "index.createOpenPad": "oder Pad mit düssen Naam apen maken:", diff --git a/src/locales/nn.json b/src/locales/nn.json index ee7720279..4404828bf 100644 --- a/src/locales/nn.json +++ b/src/locales/nn.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Unhammer" - ] + "authors": ["Unhammer"] }, "index.newPad": "Ny blokk", "index.createOpenPad": "eller opprett/opna ei blokk med namnet:", diff --git a/src/locales/oc.json b/src/locales/oc.json index 84a015ae4..faf2d4214 100644 --- a/src/locales/oc.json +++ b/src/locales/oc.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "Cedric31", - "Quentí" - ] + "authors": ["Cedric31", "Quentí"] }, "admin.page-title": "Panèl d’administracion - Etherpad", "admin_plugins": "Gestion de las extensions", diff --git a/src/locales/olo.json b/src/locales/olo.json index 2121db40b..aca128798 100644 --- a/src/locales/olo.json +++ b/src/locales/olo.json @@ -1,10 +1,6 @@ { "@metadata": { - "authors": [ - "Denö", - "Ilja.mos", - "Mashoi7" - ] + "authors": ["Denö", "Ilja.mos", "Mashoi7"] }, "pad.toolbar.underline.title": "Alleviivua (Ctrl+U)", "pad.toolbar.settings.title": "Azetukset", diff --git a/src/locales/os.json b/src/locales/os.json index 2f023b27a..f7aceca9c 100644 --- a/src/locales/os.json +++ b/src/locales/os.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Bouron" - ] + "authors": ["Bouron"] }, "index.newPad": "Ног", "index.createOpenPad": "кӕнӕ сараз/бакӕн ног документ ахӕм номимӕ:", diff --git a/src/locales/pa.json b/src/locales/pa.json index 071498f98..95d549ca4 100644 --- a/src/locales/pa.json +++ b/src/locales/pa.json @@ -1,12 +1,6 @@ { "@metadata": { - "authors": [ - "Aalam", - "Babanwalia", - "Tow", - "ਗੁਰਪ੍ਰੀਤ ਹੁੰਦਲ", - "ਪ੍ਰਚਾਰਕ" - ] + "authors": ["Aalam", "Babanwalia", "Tow", "ਗੁਰਪ੍ਰੀਤ ਹੁੰਦਲ", "ਪ੍ਰਚਾਰਕ"] }, "index.newPad": "ਨਵਾਂ ਪੈਡ", "index.createOpenPad": "ਜਾਂ ਨਾਂ ਨਾਲ ਨਵਾਂ ਪੈਡ ਬਣਾਓ/ਖੋਲ੍ਹੋ:", diff --git a/src/locales/pms.json b/src/locales/pms.json index 2d25ea928..445590e0a 100644 --- a/src/locales/pms.json +++ b/src/locales/pms.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Borichèt" - ] + "authors": ["Borichèt"] }, "admin.page-title": "Cruscòt d'aministrator - Etherpad", "admin_plugins": "Mansé dj'anstalassion", diff --git a/src/locales/ps.json b/src/locales/ps.json index 7413c41e2..b7b10df80 100644 --- a/src/locales/ps.json +++ b/src/locales/ps.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Ahmed-Najib-Biabani-Ibrahimkhel" - ] + "authors": ["Ahmed-Najib-Biabani-Ibrahimkhel"] }, "index.newPad": "نوې ليکچه", "index.createOpenPad": "يا په همدې نوم يوه نوې ليکچه جوړول/پرانيستل:", diff --git a/src/locales/sc.json b/src/locales/sc.json index 0ae544a7b..fa3e91b8d 100644 --- a/src/locales/sc.json +++ b/src/locales/sc.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Adr mm" - ] + "authors": ["Adr mm"] }, "admin.page-title": "Pannellu de amministratzione - Etherpad", "admin_plugins": "Gestore de connetores", diff --git a/src/locales/sd.json b/src/locales/sd.json index a167196f9..78e1ba0ad 100644 --- a/src/locales/sd.json +++ b/src/locales/sd.json @@ -1,11 +1,6 @@ { "@metadata": { - "authors": [ - "BaRaN6161 TURK", - "Kaleem Bhatti", - "Mehtab ahmed", - "Tweety" - ] + "authors": ["BaRaN6161 TURK", "Kaleem Bhatti", "Mehtab ahmed", "Tweety"] }, "admin_settings": "ترتيبون", "index.newPad": "نئين پٽي", diff --git a/src/locales/sh.json b/src/locales/sh.json index f5c045c55..c6eac49c1 100644 --- a/src/locales/sh.json +++ b/src/locales/sh.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "Conquistador", - "Vlad5250" - ] + "authors": ["Conquistador", "Vlad5250"] }, "admin_plugins.available_not-found": "Nijedan plugin nije pronađen.", "admin_plugins.description": "Opis", diff --git a/src/locales/shn.json b/src/locales/shn.json index d03f9e6c7..ed765cebd 100644 --- a/src/locales/shn.json +++ b/src/locales/shn.json @@ -1,10 +1,6 @@ { "@metadata": { - "authors": [ - "Ninjastrikers", - "Saimawnkham", - "Saosukham" - ] + "authors": ["Ninjastrikers", "Saimawnkham", "Saosukham"] }, "index.newPad": "ၽႅတ်ႉမႂ်ႇ", "index.createOpenPad": "ဢမ်ႇၼၼ် ၶူင်မႂ်ႇ/ပိုတ်ႇၽႅတ်ႉၵိုၵ်းၸိုဝ်ႈ", diff --git a/src/locales/skr-arab.json b/src/locales/skr-arab.json index 67abc7aa8..0aa7be7f0 100644 --- a/src/locales/skr-arab.json +++ b/src/locales/skr-arab.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Saraiki" - ] + "authors": ["Saraiki"] }, "admin_plugins": "پلگ ان منیجر", "admin_plugins.available": "دستیاب پلگ ان", diff --git a/src/locales/sms.json b/src/locales/sms.json index 3ea65a3cb..ba21342c7 100644 --- a/src/locales/sms.json +++ b/src/locales/sms.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Yupik" - ] + "authors": ["Yupik"] }, "admin_plugins.description": "Deskriptt", "admin_plugins.name": "Nõmm", diff --git a/src/locales/sq.json b/src/locales/sq.json index b6c2fa285..fed2fd3a3 100644 --- a/src/locales/sq.json +++ b/src/locales/sq.json @@ -1,11 +1,6 @@ { "@metadata": { - "authors": [ - "Besnik b", - "Eraldkerciku", - "Kosovastar", - "Liridon" - ] + "authors": ["Besnik b", "Eraldkerciku", "Kosovastar", "Liridon"] }, "admin.page-title": "Pult Përgjegjësi - Etherpad", "admin_plugins": "Përgjegjës shtojcash", diff --git a/src/locales/sro.json b/src/locales/sro.json index 03578c299..d929213ed 100644 --- a/src/locales/sro.json +++ b/src/locales/sro.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "Adr mm", - "F Samaritani" - ] + "authors": ["Adr mm", "F Samaritani"] }, "admin.page-title": "Pannellu amministrativu - Etherpad", "admin_plugins": "Gestore de connetores", diff --git a/src/locales/sw.json b/src/locales/sw.json index 33d24add2..4eddb13da 100644 --- a/src/locales/sw.json +++ b/src/locales/sw.json @@ -1,10 +1,6 @@ { "@metadata": { - "authors": [ - "Andibecker", - "Edwingudfriend", - "Muddyb" - ] + "authors": ["Andibecker", "Edwingudfriend", "Muddyb"] }, "admin.page-title": "Dashibodi ya Usimamizi - Etherpad", "admin_plugins": "Meneja wa programu-jalizi", diff --git a/src/locales/ta.json b/src/locales/ta.json index d36a8d22e..71a0130e9 100644 --- a/src/locales/ta.json +++ b/src/locales/ta.json @@ -1,10 +1,6 @@ { "@metadata": { - "authors": [ - "Balajijagadesh", - "ElangoRamanujam", - "Sank" - ] + "authors": ["Balajijagadesh", "ElangoRamanujam", "Sank"] }, "index.newPad": "புதிய அட்டை", "index.createOpenPad": "அல்லது பெயருடன் ஒரு அட்டையை உருவாக்கு/திற", diff --git a/src/locales/tcy.json b/src/locales/tcy.json index 0e56ec596..11054ab54 100644 --- a/src/locales/tcy.json +++ b/src/locales/tcy.json @@ -1,9 +1,6 @@ { "@metadata": { - "authors": [ - "BHARATHESHA ALASANDEMAJALU", - "VASANTH S.N." - ] + "authors": ["BHARATHESHA ALASANDEMAJALU", "VASANTH S.N."] }, "index.newPad": "ಪೊಸ ಪ್ಯಾಡ್", "index.createOpenPad": "ಅತಂಡ ಈ ಪುದರ್ತ ಪ್ಯಾಡನ್ನು ಉಂಡು ಮನ್ಪು/ತೋಜಾಲ:", diff --git a/src/locales/th.json b/src/locales/th.json index 7de1e4fdc..3f53b1fe2 100644 --- a/src/locales/th.json +++ b/src/locales/th.json @@ -1,11 +1,6 @@ { "@metadata": { - "authors": [ - "Aefgh39622", - "Andibecker", - "Patsagorn Y.", - "Trisorn Triboon" - ] + "authors": ["Aefgh39622", "Andibecker", "Patsagorn Y.", "Trisorn Triboon"] }, "admin.page-title": "แดชบอร์ดผู้ดูแลระบบ - Etherpad", "admin_plugins": "ตัวจัดการปลั๊กอิน", diff --git a/src/locales/vec.json b/src/locales/vec.json index 97892523e..3e146a9f9 100644 --- a/src/locales/vec.json +++ b/src/locales/vec.json @@ -1,8 +1,6 @@ { "@metadata": { - "authors": [ - "Fierodelveneto" - ] + "authors": ["Fierodelveneto"] }, "index.newPad": "Novo Pad", "index.createOpenPad": "O creare o verxare on Pad co'l nome:", diff --git a/src/node/db/API.ts b/src/node/db/API.ts index 9ce84fc51..21258653e 100644 --- a/src/node/db/API.ts +++ b/src/node/db/API.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * This module provides all API functions */ @@ -19,21 +19,21 @@ * limitations under the License. */ -const Changeset = require('../../static/js/Changeset'); -const ChatMessage = require('../../static/js/ChatMessage'); -const CustomError = require('../utils/customError'); -const padManager = require('./PadManager'); -const padMessageHandler = require('../handler/PadMessageHandler'); -const readOnlyManager = require('./ReadOnlyManager'); -const groupManager = require('./GroupManager'); -const authorManager = require('./AuthorManager'); -const sessionManager = require('./SessionManager'); -const exportHtml = require('../utils/ExportHtml'); -const exportTxt = require('../utils/ExportTxt'); -const importHtml = require('../utils/ImportHtml'); -const cleanText = require('./Pad').cleanText; -const PadDiff = require('../utils/padDiff'); -const {checkValidRev, isInt} = require('../utils/checkValidRev'); +const Changeset = require("../../static/js/Changeset"); +const ChatMessage = require("../../static/js/ChatMessage"); +const CustomError = require("../utils/customError"); +const padManager = require("./PadManager"); +const padMessageHandler = require("../handler/PadMessageHandler"); +const readOnlyManager = require("./ReadOnlyManager"); +const groupManager = require("./GroupManager"); +const authorManager = require("./AuthorManager"); +const sessionManager = require("./SessionManager"); +const exportHtml = require("../utils/ExportHtml"); +const exportTxt = require("../utils/ExportTxt"); +const importHtml = require("../utils/ImportHtml"); +const cleanText = require("./Pad").cleanText; +const PadDiff = require("../utils/padDiff"); +const { checkValidRev, isInt } = require("../utils/checkValidRev"); /* ******************** * GROUP FUNCTIONS **** @@ -105,8 +105,8 @@ Example returns: */ exports.getAttributePool = async (padID: string) => { - const pad = await getPadSafe(padID, true); - return {pool: pad.pool}; + const pad = await getPadSafe(padID, true); + return { pool: pad.pool }; }; /** @@ -123,28 +123,31 @@ Example returns: */ exports.getRevisionChangeset = async (padID: string, rev: string) => { - // try to parse the revision number - if (rev !== undefined) { - rev = checkValidRev(rev); - } + // try to parse the revision number + if (rev !== undefined) { + rev = checkValidRev(rev); + } - // get the pad - const pad = await getPadSafe(padID, true); - const head = pad.getHeadRevisionNumber(); + // get the pad + const pad = await getPadSafe(padID, true); + const head = pad.getHeadRevisionNumber(); - // the client asked for a special revision - if (rev !== undefined) { - // check if this is a valid revision - if (rev > head) { - throw new CustomError('rev is higher than the head revision of the pad', 'apierror'); - } + // the client asked for a special revision + if (rev !== undefined) { + // check if this is a valid revision + if (rev > head) { + throw new CustomError( + "rev is higher than the head revision of the pad", + "apierror", + ); + } - // get the changeset for this revision - return await pad.getRevisionChangeset(rev); - } + // get the changeset for this revision + return await pad.getRevisionChangeset(rev); + } - // the client wants the latest changeset, lets return it to him - return await pad.getRevisionChangeset(head); + // the client wants the latest changeset, lets return it to him + return await pad.getRevisionChangeset(head); }; /** @@ -156,32 +159,35 @@ Example returns: {code: 1, message:"padID does not exist", data: null} */ exports.getText = async (padID: string, rev: string) => { - // try to parse the revision number - if (rev !== undefined) { - rev = checkValidRev(rev); - } + // try to parse the revision number + if (rev !== undefined) { + rev = checkValidRev(rev); + } - // get the pad - const pad = await getPadSafe(padID, true); - const head = pad.getHeadRevisionNumber(); + // get the pad + const pad = await getPadSafe(padID, true); + const head = pad.getHeadRevisionNumber(); - // the client asked for a special revision - if (rev !== undefined) { - // check if this is a valid revision - if (rev > head) { - throw new CustomError('rev is higher than the head revision of the pad', 'apierror'); - } + // the client asked for a special revision + if (rev !== undefined) { + // check if this is a valid revision + if (rev > head) { + throw new CustomError( + "rev is higher than the head revision of the pad", + "apierror", + ); + } - // get the text of this revision - // getInternalRevisionAText() returns an atext object, but we only want the .text inside it. - // Details at https://github.com/ether/etherpad-lite/issues/5073 - const {text} = await pad.getInternalRevisionAText(rev); - return {text}; - } + // get the text of this revision + // getInternalRevisionAText() returns an atext object, but we only want the .text inside it. + // Details at https://github.com/ether/etherpad-lite/issues/5073 + const { text } = await pad.getInternalRevisionAText(rev); + return { text }; + } - // the client wants the latest text, lets return it to him - const text = exportTxt.getTXTFromAtext(pad, pad.atext); - return {text}; + // the client wants the latest text, lets return it to him + const text = exportTxt.getTXTFromAtext(pad, pad.atext); + return { text }; }; /** @@ -200,17 +206,21 @@ Example returns: * @param {String} authorId the id of the author, defaulting to empty string * @returns {Promise} */ -exports.setText = async (padID: string, text?: string, authorId: string = ''): Promise => { - // text is required - if (typeof text !== 'string') { - throw new CustomError('text is not a string', 'apierror'); - } +exports.setText = async ( + padID: string, + text?: string, + authorId: string = "", +): Promise => { + // text is required + if (typeof text !== "string") { + throw new CustomError("text is not a string", "apierror"); + } - // get the pad - const pad = await getPadSafe(padID, true); + // get the pad + const pad = await getPadSafe(padID, true); - await pad.setText(text, authorId); - await padMessageHandler.updatePadClients(pad); + await pad.setText(text, authorId); + await padMessageHandler.updatePadClients(pad); }; /** @@ -225,15 +235,19 @@ Example returns: @param {String} text the text of the pad @param {String} authorId the id of the author, defaulting to empty string */ -exports.appendText = async (padID:string, text?: string, authorId:string = '') => { - // text is required - if (typeof text !== 'string') { - throw new CustomError('text is not a string', 'apierror'); - } +exports.appendText = async ( + padID: string, + text?: string, + authorId: string = "", +) => { + // text is required + if (typeof text !== "string") { + throw new CustomError("text is not a string", "apierror"); + } - const pad = await getPadSafe(padID, true); - await pad.appendText(text, authorId); - await padMessageHandler.updatePadClients(pad); + const pad = await getPadSafe(padID, true); + await pad.appendText(text, authorId); + await padMessageHandler.updatePadClients(pad); }; /** @@ -247,28 +261,34 @@ Example returns: @param {String} rev the revision number, defaulting to the latest revision @return {Promise<{html: string}>} the html of the pad */ -exports.getHTML = async (padID: string, rev: string): Promise<{ html: string; }> => { - if (rev !== undefined) { - rev = checkValidRev(rev); - } +exports.getHTML = async ( + padID: string, + rev: string, +): Promise<{ html: string }> => { + if (rev !== undefined) { + rev = checkValidRev(rev); + } - const pad = await getPadSafe(padID, true); + const pad = await getPadSafe(padID, true); - // the client asked for a special revision - if (rev !== undefined) { - // check if this is a valid revision - const head = pad.getHeadRevisionNumber(); - if (rev > head) { - throw new CustomError('rev is higher than the head revision of the pad', 'apierror'); - } - } + // the client asked for a special revision + if (rev !== undefined) { + // check if this is a valid revision + const head = pad.getHeadRevisionNumber(); + if (rev > head) { + throw new CustomError( + "rev is higher than the head revision of the pad", + "apierror", + ); + } + } - // get the html of this revision - let html = await exportHtml.getPadHTML(pad, rev); + // get the html of this revision + let html = await exportHtml.getPadHTML(pad, rev); - // wrap the HTML - html = `${html}`; - return {html}; + // wrap the HTML + html = `${html}`; + return { html }; }; /** @@ -283,24 +303,28 @@ Example returns: @param {String} html the html of the pad @param {String} authorId the id of the author, defaulting to empty string */ -exports.setHTML = async (padID: string, html:string|object, authorId = '') => { - // html string is required - if (typeof html !== 'string') { - throw new CustomError('html is not a string', 'apierror'); - } +exports.setHTML = async ( + padID: string, + html: string | object, + authorId = "", +) => { + // html string is required + if (typeof html !== "string") { + throw new CustomError("html is not a string", "apierror"); + } - // get the pad - const pad = await getPadSafe(padID, true); + // get the pad + const pad = await getPadSafe(padID, true); - // add a new changeset with the new html to the pad - try { - await importHtml.setPadHTML(pad, cleanText(html), authorId); - } catch (e) { - throw new CustomError('HTML is malformed', 'apierror'); - } + // add a new changeset with the new html to the pad + try { + await importHtml.setPadHTML(pad, cleanText(html), authorId); + } catch (e) { + throw new CustomError("HTML is malformed", "apierror"); + } - // update the clients on the pad - padMessageHandler.updatePadClients(pad); + // update the clients on the pad + padMessageHandler.updatePadClients(pad); }; /* **************** @@ -324,41 +348,47 @@ Example returns: @param {Number} start the start point of the chat-history @param {Number} end the end point of the chat-history */ -exports.getChatHistory = async (padID: string, start:number, end:number) => { - if (start && end) { - if (start < 0) { - throw new CustomError('start is below zero', 'apierror'); - } - if (end < 0) { - throw new CustomError('end is below zero', 'apierror'); - } - if (start > end) { - throw new CustomError('start is higher than end', 'apierror'); - } - } +exports.getChatHistory = async (padID: string, start: number, end: number) => { + if (start && end) { + if (start < 0) { + throw new CustomError("start is below zero", "apierror"); + } + if (end < 0) { + throw new CustomError("end is below zero", "apierror"); + } + if (start > end) { + throw new CustomError("start is higher than end", "apierror"); + } + } - // get the pad - const pad = await getPadSafe(padID, true); + // get the pad + const pad = await getPadSafe(padID, true); - const chatHead = pad.chatHead; + const chatHead = pad.chatHead; - // fall back to getting the whole chat-history if a parameter is missing - if (!start || !end) { - start = 0; - end = pad.chatHead; - } + // fall back to getting the whole chat-history if a parameter is missing + if (!start || !end) { + start = 0; + end = pad.chatHead; + } - if (start > chatHead) { - throw new CustomError('start is higher than the current chatHead', 'apierror'); - } - if (end > chatHead) { - throw new CustomError('end is higher than the current chatHead', 'apierror'); - } + if (start > chatHead) { + throw new CustomError( + "start is higher than the current chatHead", + "apierror", + ); + } + if (end > chatHead) { + throw new CustomError( + "end is higher than the current chatHead", + "apierror", + ); + } - // the whole message-log and return it to the client - const messages = await pad.getChatMessages(start, end); + // the whole message-log and return it to the client + const messages = await pad.getChatMessages(start, end); - return {messages}; + return { messages }; }; /** @@ -374,21 +404,29 @@ Example returns: @param {String} authorID the id of the author @param {Number} time the timestamp of the chat-message */ -exports.appendChatMessage = async (padID: string, text: string|object, authorID: string, time: number) => { - // text is required - if (typeof text !== 'string') { - throw new CustomError('text is not a string', 'apierror'); - } +exports.appendChatMessage = async ( + padID: string, + text: string | object, + authorID: string, + time: number, +) => { + // text is required + if (typeof text !== "string") { + throw new CustomError("text is not a string", "apierror"); + } - // if time is not an integer value set time to current timestamp - if (time === undefined || !isInt(time)) { - time = Date.now(); - } + // if time is not an integer value set time to current timestamp + if (time === undefined || !isInt(time)) { + time = Date.now(); + } - // @TODO - missing getPadSafe() call ? + // @TODO - missing getPadSafe() call ? - // save chat message to database and send message to all connected clients - await padMessageHandler.sendChatMessageToPadClients(new ChatMessage(text, authorID, time), padID); + // save chat message to database and send message to all connected clients + await padMessageHandler.sendChatMessageToPadClients( + new ChatMessage(text, authorID, time), + padID, + ); }; /* *************** @@ -405,9 +443,9 @@ Example returns: @param {String} padID the id of the pad */ exports.getRevisionsCount = async (padID: string) => { - // get the pad - const pad = await getPadSafe(padID, true); - return {revisions: pad.getHeadRevisionNumber()}; + // get the pad + const pad = await getPadSafe(padID, true); + return { revisions: pad.getHeadRevisionNumber() }; }; /** @@ -420,9 +458,9 @@ Example returns: @param {String} padID the id of the pad */ exports.getSavedRevisionsCount = async (padID: string) => { - // get the pad - const pad = await getPadSafe(padID, true); - return {savedRevisions: pad.getSavedRevisionsNumber()}; + // get the pad + const pad = await getPadSafe(padID, true); + return { savedRevisions: pad.getSavedRevisionsNumber() }; }; /** @@ -435,9 +473,9 @@ Example returns: @param {String} padID the id of the pad */ exports.listSavedRevisions = async (padID: string) => { - // get the pad - const pad = await getPadSafe(padID, true); - return {savedRevisions: pad.getSavedRevisionsList()}; + // get the pad + const pad = await getPadSafe(padID, true); + return { savedRevisions: pad.getSavedRevisionsList() }; }; /** @@ -451,26 +489,29 @@ Example returns: @param {Number} rev the revision number, defaulting to the latest revision */ exports.saveRevision = async (padID: string, rev: number) => { - // check if rev is a number - if (rev !== undefined) { - rev = checkValidRev(rev); - } + // check if rev is a number + if (rev !== undefined) { + rev = checkValidRev(rev); + } - // get the pad - const pad = await getPadSafe(padID, true); - const head = pad.getHeadRevisionNumber(); + // get the pad + const pad = await getPadSafe(padID, true); + const head = pad.getHeadRevisionNumber(); - // the client asked for a special revision - if (rev !== undefined) { - if (rev > head) { - throw new CustomError('rev is higher than the head revision of the pad', 'apierror'); - } - } else { - rev = pad.getHeadRevisionNumber(); - } + // the client asked for a special revision + if (rev !== undefined) { + if (rev > head) { + throw new CustomError( + "rev is higher than the head revision of the pad", + "apierror", + ); + } + } else { + rev = pad.getHeadRevisionNumber(); + } - const author = await authorManager.createAuthor('API'); - await pad.addSavedRevision(rev, author.authorID, 'Saved through API call'); + const author = await authorManager.createAuthor("API"); + await pad.addSavedRevision(rev, author.authorID, "Saved through API call"); }; /** @@ -483,11 +524,13 @@ Example returns: @param {String} padID the id of the pad @return {Promise<{lastEdited: number}>} the timestamp of the last revision of the pad */ -exports.getLastEdited = async (padID: string): Promise<{ lastEdited: number; }> => { - // get the pad - const pad = await getPadSafe(padID, true); - const lastEdited = await pad.getLastEdit(); - return {lastEdited}; +exports.getLastEdited = async ( + padID: string, +): Promise<{ lastEdited: number }> => { + // get the pad + const pad = await getPadSafe(padID, true); + const lastEdited = await pad.getLastEdit(); + return { lastEdited }; }; /** @@ -501,21 +544,24 @@ Example returns: @param {String} text the initial text of the pad @param {String} authorId the id of the author, defaulting to empty string */ -exports.createPad = async (padID: string, text: string, authorId = '') => { - if (padID) { - // ensure there is no $ in the padID - if (padID.indexOf('$') !== -1) { - throw new CustomError("createPad can't create group pads", 'apierror'); - } +exports.createPad = async (padID: string, text: string, authorId = "") => { + if (padID) { + // ensure there is no $ in the padID + if (padID.indexOf("$") !== -1) { + throw new CustomError("createPad can't create group pads", "apierror"); + } - // check for url special characters - if (padID.match(/(\/|\?|&|#)/)) { - throw new CustomError('malformed padID: Remove special characters', 'apierror'); - } - } + // check for url special characters + if (padID.match(/(\/|\?|&|#)/)) { + throw new CustomError( + "malformed padID: Remove special characters", + "apierror", + ); + } + } - // create pad - await getPadSafe(padID, false, text, authorId); + // create pad + await getPadSafe(padID, false, text, authorId); }; /** @@ -528,8 +574,8 @@ Example returns: @param {String} padID the id of the pad */ exports.deletePad = async (padID: string) => { - const pad = await getPadSafe(padID, true); - await pad.remove(); + const pad = await getPadSafe(padID, true); + await pad.remove(); }; /** @@ -543,59 +589,69 @@ exports.deletePad = async (padID: string) => { @param {Number} rev the revision number, defaulting to the latest revision @param {String} authorId the id of the author, defaulting to empty string */ -exports.restoreRevision = async (padID: string, rev: number, authorId = '') => { - // check if rev is a number - if (rev === undefined) { - throw new CustomError('rev is not defined', 'apierror'); - } - rev = checkValidRev(rev); +exports.restoreRevision = async (padID: string, rev: number, authorId = "") => { + // check if rev is a number + if (rev === undefined) { + throw new CustomError("rev is not defined", "apierror"); + } + rev = checkValidRev(rev); - // get the pad - const pad = await getPadSafe(padID, true); + // get the pad + const pad = await getPadSafe(padID, true); - // check if this is a valid revision - if (rev > pad.getHeadRevisionNumber()) { - throw new CustomError('rev is higher than the head revision of the pad', 'apierror'); - } + // check if this is a valid revision + if (rev > pad.getHeadRevisionNumber()) { + throw new CustomError( + "rev is higher than the head revision of the pad", + "apierror", + ); + } - const atext = await pad.getInternalRevisionAText(rev); + const atext = await pad.getInternalRevisionAText(rev); - const oldText = pad.text(); - atext.text += '\n'; + const oldText = pad.text(); + atext.text += "\n"; - const eachAttribRun = (attribs: string[], func:Function) => { - let textIndex = 0; - const newTextStart = 0; - const newTextEnd = atext.text.length; - for (const op of Changeset.deserializeOps(attribs)) { - const nextIndex = textIndex + op.chars; - if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) { - func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs); - } - textIndex = nextIndex; - } - }; + const eachAttribRun = (attribs: string[], func: Function) => { + let textIndex = 0; + const newTextStart = 0; + const newTextEnd = atext.text.length; + for (const op of Changeset.deserializeOps(attribs)) { + const nextIndex = textIndex + op.chars; + if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) { + func( + Math.max(newTextStart, textIndex), + Math.min(newTextEnd, nextIndex), + op.attribs, + ); + } + textIndex = nextIndex; + } + }; - // create a new changeset with a helper builder object - const builder = Changeset.builder(oldText.length); + // create a new changeset with a helper builder object + const builder = Changeset.builder(oldText.length); - // assemble each line into the builder - eachAttribRun(atext.attribs, (start: number, end: number, attribs:string[]) => { - builder.insert(atext.text.substring(start, end), attribs); - }); + // assemble each line into the builder + eachAttribRun( + atext.attribs, + (start: number, end: number, attribs: string[]) => { + builder.insert(atext.text.substring(start, end), attribs); + }, + ); - const lastNewlinePos = oldText.lastIndexOf('\n'); - if (lastNewlinePos < 0) { - builder.remove(oldText.length - 1, 0); - } else { - builder.remove(lastNewlinePos, oldText.match(/\n/g).length - 1); - builder.remove(oldText.length - lastNewlinePos - 1, 0); - } + const lastNewlinePos = oldText.lastIndexOf("\n"); + if (lastNewlinePos < 0) { + builder.remove(oldText.length - 1, 0); + } else { + builder.remove(lastNewlinePos, oldText.match(/\n/g).length - 1); + builder.remove(oldText.length - lastNewlinePos - 1, 0); + } - const changeset = builder.toString(); + const changeset = builder.toString(); - await pad.appendRevision(changeset, authorId); - await padMessageHandler.updatePadClients(pad); + await pad.appendRevision(changeset, authorId); + await padMessageHandler.updatePadClients(pad); }; /** @@ -610,9 +666,13 @@ Example returns: @param {String} destinationID the id of the destination pad @param {Boolean} force whether to overwrite the destination pad if it exists */ -exports.copyPad = async (sourceID: string, destinationID: string, force: boolean) => { - const pad = await getPadSafe(sourceID, true); - await pad.copy(destinationID, force); +exports.copyPad = async ( + sourceID: string, + destinationID: string, + force: boolean, +) => { + const pad = await getPadSafe(sourceID, true); + await pad.copy(destinationID, force); }; /** @@ -628,9 +688,14 @@ Example returns: @param {Boolean} force whether to overwrite the destination pad if it exists @param {String} authorId the id of the author, defaulting to empty string */ -exports.copyPadWithoutHistory = async (sourceID: string, destinationID: string, force:boolean, authorId = '') => { - const pad = await getPadSafe(sourceID, true); - await pad.copyPadWithoutHistory(destinationID, force, authorId); +exports.copyPadWithoutHistory = async ( + sourceID: string, + destinationID: string, + force: boolean, + authorId = "", +) => { + const pad = await getPadSafe(sourceID, true); + await pad.copyPadWithoutHistory(destinationID, force, authorId); }; /** @@ -645,10 +710,14 @@ Example returns: @param {String} destinationID the id of the destination pad @param {Boolean} force whether to overwrite the destination pad if it exists */ -exports.movePad = async (sourceID: string, destinationID: string, force:boolean) => { - const pad = await getPadSafe(sourceID, true); - await pad.copy(destinationID, force); - await pad.remove(); +exports.movePad = async ( + sourceID: string, + destinationID: string, + force: boolean, +) => { + const pad = await getPadSafe(sourceID, true); + await pad.copy(destinationID, force); + await pad.remove(); }; /** @@ -661,13 +730,13 @@ Example returns: @param {String} padID the id of the pad */ exports.getReadOnlyID = async (padID: string) => { - // we don't need the pad object, but this function does all the security stuff for us - await getPadSafe(padID, true); + // we don't need the pad object, but this function does all the security stuff for us + await getPadSafe(padID, true); - // get the readonlyId - const readOnlyID = await readOnlyManager.getReadOnlyId(padID); + // get the readonlyId + const readOnlyID = await readOnlyManager.getReadOnlyId(padID); - return {readOnlyID}; + return { readOnlyID }; }; /** @@ -680,13 +749,13 @@ Example returns: @param {String} roID the readonly id of the pad */ exports.getPadID = async (roID: string) => { - // get the PadId - const padID = await readOnlyManager.getPadId(roID); - if (padID == null) { - throw new CustomError('padID does not exist', 'apierror'); - } + // get the PadId + const padID = await readOnlyManager.getPadId(roID); + if (padID == null) { + throw new CustomError("padID does not exist", "apierror"); + } - return {padID}; + return { padID }; }; /** @@ -699,19 +768,22 @@ Example returns: @param {String} padID the id of the pad @param {Boolean} publicStatus the public status of the pad */ -exports.setPublicStatus = async (padID: string, publicStatus: boolean|string) => { - // ensure this is a group pad - checkGroupPad(padID, 'publicStatus'); +exports.setPublicStatus = async ( + padID: string, + publicStatus: boolean | string, +) => { + // ensure this is a group pad + checkGroupPad(padID, "publicStatus"); - // get the pad - const pad = await getPadSafe(padID, true); + // get the pad + const pad = await getPadSafe(padID, true); - // convert string to boolean - if (typeof publicStatus === 'string') { - publicStatus = (publicStatus.toLowerCase() === 'true'); - } + // convert string to boolean + if (typeof publicStatus === "string") { + publicStatus = publicStatus.toLowerCase() === "true"; + } - await pad.setPublicStatus(publicStatus); + await pad.setPublicStatus(publicStatus); }; /** @@ -724,12 +796,12 @@ Example returns: @param {String} padID the id of the pad */ exports.getPublicStatus = async (padID: string) => { - // ensure this is a group pad - checkGroupPad(padID, 'publicStatus'); + // ensure this is a group pad + checkGroupPad(padID, "publicStatus"); - // get the pad - const pad = await getPadSafe(padID, true); - return {publicStatus: pad.getPublicStatus()}; + // get the pad + const pad = await getPadSafe(padID, true); + return { publicStatus: pad.getPublicStatus() }; }; /** @@ -742,10 +814,10 @@ Example returns: @param {String} padID the id of the pad */ exports.listAuthorsOfPad = async (padID: string) => { - // get the pad - const pad = await getPadSafe(padID, true); - const authorIDs = pad.getAllAuthors(); - return {authorIDs}; + // get the pad + const pad = await getPadSafe(padID, true); + const authorIDs = pad.getAllAuthors(); + return { authorIDs }; }; /** @@ -774,8 +846,8 @@ Example returns: */ exports.sendClientsMessage = async (padID: string, msg: string) => { - await getPadSafe(padID, true); // Throw if the padID is invalid or if the pad does not exist. - padMessageHandler.handleCustomMessage(padID, msg); + await getPadSafe(padID, true); // Throw if the padID is invalid or if the pad does not exist. + padMessageHandler.handleCustomMessage(padID, msg); }; /** @@ -786,8 +858,7 @@ Example returns: {"code":0,"message":"ok","data":null} {"code":4,"message":"no or wrong API Key","data":null} */ -exports.checkToken = async () => { -}; +exports.checkToken = async () => {}; /** getChatHead(padID) returns the chatHead (last number of the last chat-message) of the pad @@ -799,10 +870,10 @@ Example returns: @param {String} padID the id of the pad @return {Promise<{chatHead: number}>} the chatHead of the pad */ -exports.getChatHead = async (padID:string): Promise<{ chatHead: number; }> => { - // get the pad - const pad = await getPadSafe(padID, true); - return {chatHead: pad.chatHead}; +exports.getChatHead = async (padID: string): Promise<{ chatHead: number }> => { + // get the pad + const pad = await getPadSafe(padID, true); + return { chatHead: pad.chatHead }; }; /** @@ -825,35 +896,39 @@ Example returns: @param {Number} startRev the start revision number @param {Number} endRev the end revision number */ -exports.createDiffHTML = async (padID: string, startRev: number, endRev: number) => { - // check if startRev is a number - if (startRev !== undefined) { - startRev = checkValidRev(startRev); - } +exports.createDiffHTML = async ( + padID: string, + startRev: number, + endRev: number, +) => { + // check if startRev is a number + if (startRev !== undefined) { + startRev = checkValidRev(startRev); + } - // check if endRev is a number - if (endRev !== undefined) { - endRev = checkValidRev(endRev); - } + // check if endRev is a number + if (endRev !== undefined) { + endRev = checkValidRev(endRev); + } - // get the pad - const pad = await getPadSafe(padID, true); - const headRev = pad.getHeadRevisionNumber(); - if (startRev > headRev) startRev = headRev; + // get the pad + const pad = await getPadSafe(padID, true); + const headRev = pad.getHeadRevisionNumber(); + if (startRev > headRev) startRev = headRev; - if (endRev > headRev) endRev = headRev; + if (endRev > headRev) endRev = headRev; - let padDiff; - try { - padDiff = new PadDiff(pad, startRev, endRev); - } catch (e:any) { - throw {stop: e.message}; - } + let padDiff; + try { + padDiff = new PadDiff(pad, startRev, endRev); + } catch (e: any) { + throw { stop: e.message }; + } - const html = await padDiff.getHtml(); - const authors = await padDiff.getAuthors(); + const html = await padDiff.getHtml(); + const authors = await padDiff.getAuthors(); - return {html, authors}; + return { html, authors }; }; /* ******************** @@ -869,19 +944,21 @@ exports.createDiffHTML = async (padID: string, startRev: number, endRev: number) {"code":4,"message":"no or wrong API Key","data":null} */ exports.getStats = async () => { - const sessionInfos = padMessageHandler.sessioninfos; + const sessionInfos = padMessageHandler.sessioninfos; - const sessionKeys = Object.keys(sessionInfos); - // @ts-ignore - const activePads = new Set(Object.entries(sessionInfos).map((k) => k[1].padId)); + const sessionKeys = Object.keys(sessionInfos); + // @ts-ignore + const activePads = new Set( + Object.entries(sessionInfos).map((k) => k[1].padId), + ); - const {padIDs} = await padManager.listAllPads(); + const { padIDs } = await padManager.listAllPads(); - return { - totalPads: padIDs.length, - totalSessions: sessionKeys.length, - totalActivePads: activePads.size, - }; + return { + totalPads: padIDs.length, + totalSessions: sessionKeys.length, + totalActivePads: activePads.size, + }; }; /* **************************** @@ -889,39 +966,46 @@ exports.getStats = async () => { **************************** */ // gets a pad safe -const getPadSafe = async (padID: string|object, shouldExist: boolean, text?:string, authorId:string = '') => { - // check if padID is a string - if (typeof padID !== 'string') { - throw new CustomError('padID is not a string', 'apierror'); - } +const getPadSafe = async ( + padID: string | object, + shouldExist: boolean, + text?: string, + authorId: string = "", +) => { + // check if padID is a string + if (typeof padID !== "string") { + throw new CustomError("padID is not a string", "apierror"); + } - // check if the padID maches the requirements - if (!padManager.isValidPadId(padID)) { - throw new CustomError('padID did not match requirements', 'apierror'); - } + // check if the padID maches the requirements + if (!padManager.isValidPadId(padID)) { + throw new CustomError("padID did not match requirements", "apierror"); + } - // check if the pad exists - const exists = await padManager.doesPadExists(padID); + // check if the pad exists + const exists = await padManager.doesPadExists(padID); - if (!exists && shouldExist) { - // does not exist, but should - throw new CustomError('padID does not exist', 'apierror'); - } + if (!exists && shouldExist) { + // does not exist, but should + throw new CustomError("padID does not exist", "apierror"); + } - if (exists && !shouldExist) { - // does exist, but shouldn't - throw new CustomError('padID does already exist', 'apierror'); - } + if (exists && !shouldExist) { + // does exist, but shouldn't + throw new CustomError("padID does already exist", "apierror"); + } - // pad exists, let's get it - return padManager.getPad(padID, text, authorId); + // pad exists, let's get it + return padManager.getPad(padID, text, authorId); }; // checks if a padID is part of a group const checkGroupPad = (padID: string, field: string) => { - // ensure this is a group pad - if (padID && padID.indexOf('$') === -1) { - throw new CustomError( - `You can only get/set the ${field} of pads that belong to a group`, 'apierror'); - } + // ensure this is a group pad + if (padID && padID.indexOf("$") === -1) { + throw new CustomError( + `You can only get/set the ${field} of pads that belong to a group`, + "apierror", + ); + } }; diff --git a/src/node/db/AuthorManager.ts b/src/node/db/AuthorManager.ts index 2f4e7d751..193f75e3b 100644 --- a/src/node/db/AuthorManager.ts +++ b/src/node/db/AuthorManager.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * The AuthorManager controlls all information about the Pad authors */ @@ -19,76 +19,79 @@ * limitations under the License. */ -const db = require('./DB'); -const CustomError = require('../utils/customError'); -const hooks = require('../../static/js/pluginfw/hooks.js'); -const {randomString, padutils: {warnDeprecated}} = require('../../static/js/pad_utils'); +const db = require("./DB"); +const CustomError = require("../utils/customError"); +const hooks = require("../../static/js/pluginfw/hooks.js"); +const { + randomString, + padutils: { warnDeprecated }, +} = require("../../static/js/pad_utils"); exports.getColorPalette = () => [ - '#ffc7c7', - '#fff1c7', - '#e3ffc7', - '#c7ffd5', - '#c7ffff', - '#c7d5ff', - '#e3c7ff', - '#ffc7f1', - '#ffa8a8', - '#ffe699', - '#cfff9e', - '#99ffb3', - '#a3ffff', - '#99b3ff', - '#cc99ff', - '#ff99e5', - '#e7b1b1', - '#e9dcAf', - '#cde9af', - '#bfedcc', - '#b1e7e7', - '#c3cdee', - '#d2b8ea', - '#eec3e6', - '#e9cece', - '#e7e0ca', - '#d3e5c7', - '#bce1c5', - '#c1e2e2', - '#c1c9e2', - '#cfc1e2', - '#e0bdd9', - '#baded3', - '#a0f8eb', - '#b1e7e0', - '#c3c8e4', - '#cec5e2', - '#b1d5e7', - '#cda8f0', - '#f0f0a8', - '#f2f2a6', - '#f5a8eb', - '#c5f9a9', - '#ececbb', - '#e7c4bc', - '#daf0b2', - '#b0a0fd', - '#bce2e7', - '#cce2bb', - '#ec9afe', - '#edabbd', - '#aeaeea', - '#c4e7b1', - '#d722bb', - '#f3a5e7', - '#ffa8a8', - '#d8c0c5', - '#eaaedd', - '#adc6eb', - '#bedad1', - '#dee9af', - '#e9afc2', - '#f8d2a0', - '#b3b3e6', + "#ffc7c7", + "#fff1c7", + "#e3ffc7", + "#c7ffd5", + "#c7ffff", + "#c7d5ff", + "#e3c7ff", + "#ffc7f1", + "#ffa8a8", + "#ffe699", + "#cfff9e", + "#99ffb3", + "#a3ffff", + "#99b3ff", + "#cc99ff", + "#ff99e5", + "#e7b1b1", + "#e9dcAf", + "#cde9af", + "#bfedcc", + "#b1e7e7", + "#c3cdee", + "#d2b8ea", + "#eec3e6", + "#e9cece", + "#e7e0ca", + "#d3e5c7", + "#bce1c5", + "#c1e2e2", + "#c1c9e2", + "#cfc1e2", + "#e0bdd9", + "#baded3", + "#a0f8eb", + "#b1e7e0", + "#c3c8e4", + "#cec5e2", + "#b1d5e7", + "#cda8f0", + "#f0f0a8", + "#f2f2a6", + "#f5a8eb", + "#c5f9a9", + "#ececbb", + "#e7c4bc", + "#daf0b2", + "#b0a0fd", + "#bce2e7", + "#cce2bb", + "#ec9afe", + "#edabbd", + "#aeaeea", + "#c4e7b1", + "#d722bb", + "#f3a5e7", + "#ffa8a8", + "#d8c0c5", + "#eaaedd", + "#adc6eb", + "#bedad1", + "#dee9af", + "#e9afc2", + "#f8d2a0", + "#b3b3e6", ]; /** @@ -96,9 +99,9 @@ exports.getColorPalette = () => [ * @param {String} authorID The id of the author */ exports.doesAuthorExist = async (authorID: string) => { - const author = await db.get(`globalAuthor:${authorID}`); + const author = await db.get(`globalAuthor:${authorID}`); - return author != null; + return author != null; }; /** @@ -107,34 +110,33 @@ exports.doesAuthorExist = async (authorID: string) => { */ exports.doesAuthorExists = exports.doesAuthorExist; - /** * Returns the AuthorID for a mapper. We can map using a mapperkey, * so far this is token2author and mapper2author * @param {String} mapperkey The database key name for this mapper * @param {String} mapper The mapper */ -const mapAuthorWithDBKey = async (mapperkey: string, mapper:string) => { - // try to map to an author - const author = await db.get(`${mapperkey}:${mapper}`); +const mapAuthorWithDBKey = async (mapperkey: string, mapper: string) => { + // try to map to an author + const author = await db.get(`${mapperkey}:${mapper}`); - if (author == null) { - // there is no author with this mapper, so create one - const author = await exports.createAuthor(null); + if (author == null) { + // there is no author with this mapper, so create one + const author = await exports.createAuthor(null); - // create the token2author relation - await db.set(`${mapperkey}:${mapper}`, author.authorID); + // create the token2author relation + await db.set(`${mapperkey}:${mapper}`, author.authorID); - // return the author - return author; - } + // return the author + return author; + } - // there is an author with this mapper - // update the timestamp of this author - await db.setSub(`globalAuthor:${author}`, ['timestamp'], Date.now()); + // there is an author with this mapper + // update the timestamp of this author + await db.setSub(`globalAuthor:${author}`, ["timestamp"], Date.now()); - // return the author - return {authorID: author}; + // return the author + return { authorID: author }; }; /** @@ -143,10 +145,10 @@ const mapAuthorWithDBKey = async (mapperkey: string, mapper:string) => { * @return {Promise} */ const getAuthor4Token = async (token: string) => { - const author = await mapAuthorWithDBKey('token2author', token); + const author = await mapAuthorWithDBKey("token2author", token); - // return only the sub value authorID - return author ? author.authorID : author; + // return only the sub value authorID + return author ? author.authorID : author; }; /** @@ -156,10 +158,10 @@ const getAuthor4Token = async (token: string) => { * @return {Promise<*>} */ exports.getAuthorId = async (token: string, user: object) => { - const context = {dbKey: token, token, user}; - let [authorId] = await hooks.aCallFirst('getAuthorId', context); - if (!authorId) authorId = await getAuthor4Token(context.dbKey); - return authorId; + const context = { dbKey: token, token, user }; + let [authorId] = await hooks.aCallFirst("getAuthorId", context); + if (!authorId) authorId = await getAuthor4Token(context.dbKey); + return authorId; }; /** @@ -169,9 +171,10 @@ exports.getAuthorId = async (token: string, user: object) => { * @param {String} token The token */ exports.getAuthor4Token = async (token: string) => { - warnDeprecated( - 'AuthorManager.getAuthor4Token() is deprecated; use AuthorManager.getAuthorId() instead'); - return await getAuthor4Token(token); + warnDeprecated( + "AuthorManager.getAuthor4Token() is deprecated; use AuthorManager.getAuthorId() instead", + ); + return await getAuthor4Token(token); }; /** @@ -179,95 +182,100 @@ exports.getAuthor4Token = async (token: string) => { * @param {String} authorMapper The mapper * @param {String} name The name of the author (optional) */ -exports.createAuthorIfNotExistsFor = async (authorMapper: string, name: string) => { - const author = await mapAuthorWithDBKey('mapper2author', authorMapper); +exports.createAuthorIfNotExistsFor = async ( + authorMapper: string, + name: string, +) => { + const author = await mapAuthorWithDBKey("mapper2author", authorMapper); - if (name) { - // set the name of this author - await exports.setAuthorName(author.authorID, name); - } + if (name) { + // set the name of this author + await exports.setAuthorName(author.authorID, name); + } - return author; + return author; }; - /** * Internal function that creates the database entry for an author * @param {String} name The name of the author */ exports.createAuthor = async (name: string) => { - // create the new author name - const author = `a.${randomString(16)}`; + // create the new author name + const author = `a.${randomString(16)}`; - // create the globalAuthors db entry - const authorObj = { - colorId: Math.floor(Math.random() * (exports.getColorPalette().length)), - name, - timestamp: Date.now(), - }; + // create the globalAuthors db entry + const authorObj = { + colorId: Math.floor(Math.random() * exports.getColorPalette().length), + name, + timestamp: Date.now(), + }; - // set the global author db entry - await db.set(`globalAuthor:${author}`, authorObj); + // set the global author db entry + await db.set(`globalAuthor:${author}`, authorObj); - return {authorID: author}; + return { authorID: author }; }; /** * Returns the Author Obj of the author * @param {String} author The id of the author */ -exports.getAuthor = async (author: string) => await db.get(`globalAuthor:${author}`); +exports.getAuthor = async (author: string) => + await db.get(`globalAuthor:${author}`); /** * Returns the color Id of the author * @param {String} author The id of the author */ -exports.getAuthorColorId = async (author: string) => await db.getSub(`globalAuthor:${author}`, ['colorId']); +exports.getAuthorColorId = async (author: string) => + await db.getSub(`globalAuthor:${author}`, ["colorId"]); /** * Sets the color Id of the author * @param {String} author The id of the author * @param {String} colorId The color id of the author */ -exports.setAuthorColorId = async (author: string, colorId: string) => await db.setSub( - `globalAuthor:${author}`, ['colorId'], colorId); +exports.setAuthorColorId = async (author: string, colorId: string) => + await db.setSub(`globalAuthor:${author}`, ["colorId"], colorId); /** * Returns the name of the author * @param {String} author The id of the author */ -exports.getAuthorName = async (author: string) => await db.getSub(`globalAuthor:${author}`, ['name']); +exports.getAuthorName = async (author: string) => + await db.getSub(`globalAuthor:${author}`, ["name"]); /** * Sets the name of the author * @param {String} author The id of the author * @param {String} name The name of the author */ -exports.setAuthorName = async (author: string, name: string) => await db.setSub( - `globalAuthor:${author}`, ['name'], name); +exports.setAuthorName = async (author: string, name: string) => + await db.setSub(`globalAuthor:${author}`, ["name"], name); /** * Returns an array of all pads this author contributed to * @param {String} authorID The id of the author */ exports.listPadsOfAuthor = async (authorID: string) => { - /* There are two other places where this array is manipulated: - * (1) When the author is added to a pad, the author object is also updated - * (2) When a pad is deleted, each author of that pad is also updated - */ + /* There are two other places where this array is manipulated: + * (1) When the author is added to a pad, the author object is also updated + * (2) When a pad is deleted, each author of that pad is also updated + */ - // get the globalAuthor - const author = await db.get(`globalAuthor:${authorID}`); + // get the globalAuthor + const author = await db.get(`globalAuthor:${authorID}`); - if (author == null) { - // author does not exist - throw new CustomError('authorID does not exist', 'apierror'); - } + if (author == null) { + // author does not exist + throw new CustomError("authorID does not exist", "apierror"); + } - // everything is fine, return the pad IDs - const padIDs = Object.keys(author.padIDs || {}); + // everything is fine, return the pad IDs + const padIDs = Object.keys(author.padIDs || {}); - return {padIDs}; + return { padIDs }; }; /** @@ -276,25 +284,25 @@ exports.listPadsOfAuthor = async (authorID: string) => { * @param {String} padID The id of the pad the author contributes to */ exports.addPad = async (authorID: string, padID: string) => { - // get the entry - const author = await db.get(`globalAuthor:${authorID}`); + // get the entry + const author = await db.get(`globalAuthor:${authorID}`); - if (author == null) return; + if (author == null) return; - /* - * ACHTUNG: padIDs can also be undefined, not just null, so it is not possible - * to perform a strict check here - */ - if (!author.padIDs) { - // the entry doesn't exist so far, let's create it - author.padIDs = {}; - } + /* + * ACHTUNG: padIDs can also be undefined, not just null, so it is not possible + * to perform a strict check here + */ + if (!author.padIDs) { + // the entry doesn't exist so far, let's create it + author.padIDs = {}; + } - // add the entry for this pad - author.padIDs[padID] = 1; // anything, because value is not used + // add the entry for this pad + author.padIDs[padID] = 1; // anything, because value is not used - // save the new element back - await db.set(`globalAuthor:${authorID}`, author); + // save the new element back + await db.set(`globalAuthor:${authorID}`, author); }; /** @@ -303,13 +311,13 @@ exports.addPad = async (authorID: string, padID: string) => { * @param {String} padID The id of the pad the author contributes to */ exports.removePad = async (authorID: string, padID: string) => { - const author = await db.get(`globalAuthor:${authorID}`); + const author = await db.get(`globalAuthor:${authorID}`); - if (author == null) return; + if (author == null) return; - if (author.padIDs != null) { - // remove pad from author - delete author.padIDs[padID]; - await db.set(`globalAuthor:${authorID}`, author); - } + if (author.padIDs != null) { + // remove pad from author + delete author.padIDs[padID]; + await db.set(`globalAuthor:${authorID}`, author); + } }; diff --git a/src/node/db/DB.ts b/src/node/db/DB.ts index 542da6735..40ed366c6 100644 --- a/src/node/db/DB.ts +++ b/src/node/db/DB.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * The DB Module provides a database initialized with the settings @@ -21,12 +21,12 @@ * limitations under the License. */ -import ueberDB from 'ueberdb2'; -const settings = require('../utils/Settings'); -import log4js from 'log4js'; -const stats = require('../stats') +import ueberDB from "ueberdb2"; +const settings = require("../utils/Settings"); +import log4js from "log4js"; +const stats = require("../stats"); -const logger = log4js.getLogger('ueberDB'); +const logger = log4js.getLogger("ueberDB"); /** * The UeberDB Object that provides the database functions @@ -37,24 +37,30 @@ exports.db = null; * Initializes the database with the settings provided by the settings module */ exports.init = async () => { - exports.db = new ueberDB.Database(settings.dbType, settings.dbSettings, null, logger); - await exports.db.init(); - if (exports.db.metrics != null) { - for (const [metric, value] of Object.entries(exports.db.metrics)) { - if (typeof value !== 'number') continue; - stats.gauge(`ueberdb_${metric}`, () => exports.db.metrics[metric]); - } - } - for (const fn of ['get', 'set', 'findKeys', 'getSub', 'setSub', 'remove']) { - const f = exports.db[fn]; - exports[fn] = async (...args:string[]) => await f.call(exports.db, ...args); - Object.setPrototypeOf(exports[fn], Object.getPrototypeOf(f)); - Object.defineProperties(exports[fn], Object.getOwnPropertyDescriptors(f)); - } + exports.db = new ueberDB.Database( + settings.dbType, + settings.dbSettings, + null, + logger, + ); + await exports.db.init(); + if (exports.db.metrics != null) { + for (const [metric, value] of Object.entries(exports.db.metrics)) { + if (typeof value !== "number") continue; + stats.gauge(`ueberdb_${metric}`, () => exports.db.metrics[metric]); + } + } + for (const fn of ["get", "set", "findKeys", "getSub", "setSub", "remove"]) { + const f = exports.db[fn]; + exports[fn] = async (...args: string[]) => + await f.call(exports.db, ...args); + Object.setPrototypeOf(exports[fn], Object.getPrototypeOf(f)); + Object.defineProperties(exports[fn], Object.getOwnPropertyDescriptors(f)); + } }; -exports.shutdown = async (hookName: string, context:any) => { - if (exports.db != null) await exports.db.close(); - exports.db = null; - logger.log('Database closed'); +exports.shutdown = async (hookName: string, context: any) => { + if (exports.db != null) await exports.db.close(); + exports.db = null; + logger.log("Database closed"); }; diff --git a/src/node/db/GroupManager.ts b/src/node/db/GroupManager.ts index 0524c4eda..16ad2f337 100644 --- a/src/node/db/GroupManager.ts +++ b/src/node/db/GroupManager.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * The Group Manager provides functions to manage groups in the database */ @@ -19,22 +19,22 @@ * limitations under the License. */ -const CustomError = require('../utils/customError'); -const randomString = require('../../static/js/pad_utils').randomString; -const db = require('./DB'); -const padManager = require('./PadManager'); -const sessionManager = require('./SessionManager'); +const CustomError = require("../utils/customError"); +const randomString = require("../../static/js/pad_utils").randomString; +const db = require("./DB"); +const padManager = require("./PadManager"); +const sessionManager = require("./SessionManager"); /** * Lists all groups * @return {Promise<{groupIDs: string[]}>} The ids of all groups */ exports.listAllGroups = async () => { - let groups = await db.get('groups'); - groups = groups || {}; + let groups = await db.get("groups"); + groups = groups || {}; - const groupIDs = Object.keys(groups); - return {groupIDs}; + const groupIDs = Object.keys(groups); + return { groupIDs }; }; /** @@ -43,38 +43,44 @@ exports.listAllGroups = async () => { * @return {Promise} Resolves when the group is deleted */ exports.deleteGroup = async (groupID: string): Promise => { - const group = await db.get(`group:${groupID}`); + const group = await db.get(`group:${groupID}`); - // ensure group exists - if (group == null) { - // group does not exist - throw new CustomError('groupID does not exist', 'apierror'); - } + // ensure group exists + if (group == null) { + // group does not exist + throw new CustomError("groupID does not exist", "apierror"); + } - // iterate through all pads of this group and delete them (in parallel) - await Promise.all(Object.keys(group.pads).map(async (padId) => { - const pad = await padManager.getPad(padId); - await pad.remove(); - })); + // iterate through all pads of this group and delete them (in parallel) + await Promise.all( + Object.keys(group.pads).map(async (padId) => { + const pad = await padManager.getPad(padId); + await pad.remove(); + }), + ); - // Delete associated sessions in parallel. This should be done before deleting the group2sessions - // record because deleting a session updates the group2sessions record. - const {sessionIDs = {}} = await db.get(`group2sessions:${groupID}`) || {}; - await Promise.all(Object.keys(sessionIDs).map(async (sessionId) => { - await sessionManager.deleteSession(sessionId); - })); + // Delete associated sessions in parallel. This should be done before deleting the group2sessions + // record because deleting a session updates the group2sessions record. + const { sessionIDs = {} } = (await db.get(`group2sessions:${groupID}`)) || {}; + await Promise.all( + Object.keys(sessionIDs).map(async (sessionId) => { + await sessionManager.deleteSession(sessionId); + }), + ); - await Promise.all([ - db.remove(`group2sessions:${groupID}`), - // UeberDB's setSub() method atomically reads the record, updates the appropriate property, and - // writes the result. Setting a property to `undefined` deletes that property (JSON.stringify() - // ignores such properties). - db.setSub('groups', [groupID], undefined), - ...Object.keys(group.mappings || {}).map(async (m) => await db.remove(`mapper2group:${m}`)), - ]); + await Promise.all([ + db.remove(`group2sessions:${groupID}`), + // UeberDB's setSub() method atomically reads the record, updates the appropriate property, and + // writes the result. Setting a property to `undefined` deletes that property (JSON.stringify() + // ignores such properties). + db.setSub("groups", [groupID], undefined), + ...Object.keys(group.mappings || {}).map( + async (m) => await db.remove(`mapper2group:${m}`), + ), + ]); - // Remove the group record after updating the `groups` record so that the state is consistent. - await db.remove(`group:${groupID}`); + // Remove the group record after updating the `groups` record so that the state is consistent. + await db.remove(`group:${groupID}`); }; /** @@ -83,10 +89,10 @@ exports.deleteGroup = async (groupID: string): Promise => { * @return {Promise} Resolves to true if the group exists */ exports.doesGroupExist = async (groupID: string) => { - // try to get the group entry - const group = await db.get(`group:${groupID}`); + // try to get the group entry + const group = await db.get(`group:${groupID}`); - return (group != null); + return group != null; }; /** @@ -94,13 +100,13 @@ exports.doesGroupExist = async (groupID: string) => { * @return {Promise<{groupID: string}>} the id of the new group */ exports.createGroup = async () => { - const groupID = `g.${randomString(16)}`; - await db.set(`group:${groupID}`, {pads: {}, mappings: {}}); - // Add the group to the `groups` record after the group's individual record is created so that - // the state is consistent. Note: UeberDB's setSub() method atomically reads the record, updates - // the appropriate property, and writes the result. - await db.setSub('groups', [groupID], 1); - return {groupID}; + const groupID = `g.${randomString(16)}`; + await db.set(`group:${groupID}`, { pads: {}, mappings: {} }); + // Add the group to the `groups` record after the group's individual record is created so that + // the state is consistent. Note: UeberDB's setSub() method atomically reads the record, updates + // the appropriate property, and writes the result. + await db.setSub("groups", [groupID], 1); + return { groupID }; }; /** @@ -108,22 +114,22 @@ exports.createGroup = async () => { * @param groupMapper the mapper of the group * @return {Promise<{groupID: string}|{groupID: *}>} a promise that resolves to the group ID */ -exports.createGroupIfNotExistsFor = async (groupMapper: string|object) => { - if (typeof groupMapper !== 'string') { - throw new CustomError('groupMapper is not a string', 'apierror'); - } - const groupID = await db.get(`mapper2group:${groupMapper}`); - if (groupID && await exports.doesGroupExist(groupID)) return {groupID}; - const result = await exports.createGroup(); - await Promise.all([ - db.set(`mapper2group:${groupMapper}`, result.groupID), - // Remember the mapping in the group record so that it can be cleaned up when the group is - // deleted. Although the core Etherpad API does not support multiple mappings for the same - // group, the database record does support multiple mappings in case a plugin decides to extend - // the core Etherpad functionality. (It's also easy to implement it this way.) - db.setSub(`group:${result.groupID}`, ['mappings', groupMapper], 1), - ]); - return result; +exports.createGroupIfNotExistsFor = async (groupMapper: string | object) => { + if (typeof groupMapper !== "string") { + throw new CustomError("groupMapper is not a string", "apierror"); + } + const groupID = await db.get(`mapper2group:${groupMapper}`); + if (groupID && (await exports.doesGroupExist(groupID))) return { groupID }; + const result = await exports.createGroup(); + await Promise.all([ + db.set(`mapper2group:${groupMapper}`, result.groupID), + // Remember the mapping in the group record so that it can be cleaned up when the group is + // deleted. Although the core Etherpad API does not support multiple mappings for the same + // group, the database record does support multiple mappings in case a plugin decides to extend + // the core Etherpad functionality. (It's also easy to implement it this way.) + db.setSub(`group:${result.groupID}`, ["mappings", groupMapper], 1), + ]); + return result; }; /** @@ -134,32 +140,37 @@ exports.createGroupIfNotExistsFor = async (groupMapper: string|object) => { * @param {String} authorId The id of the author * @return {Promise<{padID: string}>} a promise that resolves to the id of the new pad */ -exports.createGroupPad = async (groupID: string, padName: string, text: string, authorId: string = ''): Promise<{ padID: string; }> => { - // create the padID - const padID = `${groupID}$${padName}`; +exports.createGroupPad = async ( + groupID: string, + padName: string, + text: string, + authorId: string = "", +): Promise<{ padID: string }> => { + // create the padID + const padID = `${groupID}$${padName}`; - // ensure group exists - const groupExists = await exports.doesGroupExist(groupID); + // ensure group exists + const groupExists = await exports.doesGroupExist(groupID); - if (!groupExists) { - throw new CustomError('groupID does not exist', 'apierror'); - } + if (!groupExists) { + throw new CustomError("groupID does not exist", "apierror"); + } - // ensure pad doesn't exist already - const padExists = await padManager.doesPadExists(padID); + // ensure pad doesn't exist already + const padExists = await padManager.doesPadExists(padID); - if (padExists) { - // pad exists already - throw new CustomError('padName does already exist', 'apierror'); - } + if (padExists) { + // pad exists already + throw new CustomError("padName does already exist", "apierror"); + } - // create the pad - await padManager.getPad(padID, text, authorId); + // create the pad + await padManager.getPad(padID, text, authorId); - // create an entry in the group for this pad - await db.setSub(`group:${groupID}`, ['pads', padID], 1); + // create an entry in the group for this pad + await db.setSub(`group:${groupID}`, ["pads", padID], 1); - return {padID}; + return { padID }; }; /** @@ -167,17 +178,17 @@ exports.createGroupPad = async (groupID: string, padName: string, text: string, * @param {String} groupID The id of the group * @return {Promise<{padIDs: string[]}>} a promise that resolves to the ids of all pads of the group */ -exports.listPads = async (groupID: string): Promise<{ padIDs: string[]; }> => { - const exists = await exports.doesGroupExist(groupID); +exports.listPads = async (groupID: string): Promise<{ padIDs: string[] }> => { + const exists = await exports.doesGroupExist(groupID); - // ensure the group exists - if (!exists) { - throw new CustomError('groupID does not exist', 'apierror'); - } + // ensure the group exists + if (!exists) { + throw new CustomError("groupID does not exist", "apierror"); + } - // group exists, let's get the pads - const result = await db.getSub(`group:${groupID}`, ['pads']); - const padIDs = Object.keys(result); + // group exists, let's get the pads + const result = await db.getSub(`group:${groupID}`, ["pads"]); + const padIDs = Object.keys(result); - return {padIDs}; + return { padIDs }; }; diff --git a/src/node/db/Pad.ts b/src/node/db/Pad.ts index fa4af994d..4d7a86166 100644 --- a/src/node/db/Pad.ts +++ b/src/node/db/Pad.ts @@ -1,30 +1,32 @@ -'use strict'; -import {Database} from "ueberdb2"; -import {AChangeSet, APool, AText} from "../types/PadType"; -import {MapArrayType} from "../types/MapType"; +"use strict"; +import { Database } from "ueberdb2"; +import { AChangeSet, APool, AText } from "../types/PadType"; +import { MapArrayType } from "../types/MapType"; /** * The pad object, defined with joose */ -const AttributeMap = require('../../static/js/AttributeMap'); -const Changeset = require('../../static/js/Changeset'); -const ChatMessage = require('../../static/js/ChatMessage'); -const AttributePool = require('../../static/js/AttributePool'); -const Stream = require('../utils/Stream'); -const assert = require('assert').strict; -const db = require('./DB'); -const settings = require('../utils/Settings'); -const authorManager = require('./AuthorManager'); -const padManager = require('./PadManager'); -const padMessageHandler = require('../handler/PadMessageHandler'); -const groupManager = require('./GroupManager'); -const CustomError = require('../utils/customError'); -const readOnlyManager = require('./ReadOnlyManager'); -const randomString = require('../utils/randomstring'); -const hooks = require('../../static/js/pluginfw/hooks'); -const {padutils: {warnDeprecated}} = require('../../static/js/pad_utils'); -const promises = require('../utils/promises'); +const AttributeMap = require("../../static/js/AttributeMap"); +const Changeset = require("../../static/js/Changeset"); +const ChatMessage = require("../../static/js/ChatMessage"); +const AttributePool = require("../../static/js/AttributePool"); +const Stream = require("../utils/Stream"); +const assert = require("assert").strict; +const db = require("./DB"); +const settings = require("../utils/Settings"); +const authorManager = require("./AuthorManager"); +const padManager = require("./PadManager"); +const padMessageHandler = require("../handler/PadMessageHandler"); +const groupManager = require("./GroupManager"); +const CustomError = require("../utils/customError"); +const readOnlyManager = require("./ReadOnlyManager"); +const randomString = require("../utils/randomstring"); +const hooks = require("../../static/js/pluginfw/hooks"); +const { + padutils: { warnDeprecated }, +} = require("../../static/js/pad_utils"); +const promises = require("../utils/promises"); /** * Copied from the Etherpad source code. It converts Windows line breaks to Unix @@ -32,734 +34,847 @@ const promises = require('../utils/promises'); * @param {String} txt The text to clean * @returns {String} The cleaned text */ -exports.cleanText = (txt:string): string => txt.replace(/\r\n/g, '\n') - .replace(/\r/g, '\n') - .replace(/\t/g, ' ') - .replace(/\xa0/g, ' '); +exports.cleanText = (txt: string): string => + txt + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/\t/g, " ") + .replace(/\xa0/g, " "); class Pad { - private db: Database; - private atext: AText; - private pool: APool; - private head: number; - private chatHead: number; - private publicStatus: boolean; - private id: string; - private savedRevisions: any[]; - /** - * @param id - * @param [database] - Database object to access this pad's records (and only this pad's records; - * the shared global Etherpad database object is still used for all other pad accesses, such - * as copying the pad). Defaults to the shared global Etherpad database object. This parameter - * can be used to shard pad storage across multiple database backends, to put each pad in its - * own database table, or to validate imported pad data before it is written to the database. - */ - constructor(id:string, database = db) { - this.db = database; - this.atext = Changeset.makeAText('\n'); - this.pool = new AttributePool(); - this.head = -1; - this.chatHead = -1; - this.publicStatus = false; - this.id = id; - this.savedRevisions = []; - } - - apool() { - return this.pool; - } - - getHeadRevisionNumber() { - return this.head; - } - - getSavedRevisionsNumber() { - return this.savedRevisions.length; - } - - getSavedRevisionsList() { - const savedRev = this.savedRevisions.map((rev) => rev.revNum); - savedRev.sort((a, b) => a - b); - return savedRev; - } - - getPublicStatus() { - return this.publicStatus; - } - - /** - * Appends a new revision - * @param {Object} aChangeset The changeset to append to the pad - * @param {String} authorId The id of the author - * @return {Promise} - */ - async appendRevision(aChangeset:AChangeSet, authorId = '') { - const newAText = Changeset.applyToAText(aChangeset, this.atext, this.pool); - if (newAText.text === this.atext.text && newAText.attribs === this.atext.attribs && - this.head !== -1) { - return this.head; - } - Changeset.copyAText(newAText, this.atext); - - const newRev = ++this.head; - - // ex. getNumForAuthor - if (authorId !== '') this.pool.putAttrib(['author', authorId]); - - const hook = this.head === 0 ? 'padCreate' : 'padUpdate'; - await Promise.all([ - // @ts-ignore - this.db.set(`pad:${this.id}:revs:${newRev}`, { - changeset: aChangeset, - meta: { - author: authorId, - timestamp: Date.now(), - ...newRev === this.getKeyRevisionNumber(newRev) ? { - pool: this.pool, - atext: this.atext, - } : {}, - }, - }), - this.saveToDatabase(), - authorId && authorManager.addPad(authorId, this.id), - hooks.aCallAll(hook, { - pad: this, - authorId, - get author() { - warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`); - return this.authorId; - }, - set author(authorId) { - warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`); - this.authorId = authorId; - }, - ...this.head === 0 ? {} : { - revs: newRev, - changeset: aChangeset, - }, - }), - ]); - return newRev; - } - - toJSON() { - const o:Pad = {...this, pool: this.pool.toJsonable()}; - // @ts-ignore - delete o.db; - // @ts-ignore - delete o.id; - return o; - } - - // save all attributes to the database - async saveToDatabase() { - // @ts-ignore - await this.db.set(`pad:${this.id}`, this); - } - - // get time of last edit (changeset application) - async getLastEdit() { - const revNum = this.getHeadRevisionNumber(); - // @ts-ignore - return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'timestamp']); - } - - async getRevisionChangeset(revNum: number) { - // @ts-ignore - return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['changeset']); - } - - async getRevisionAuthor(revNum: number) { - // @ts-ignore - return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'author']); - } - - async getRevisionDate(revNum: number) { - // @ts-ignore - return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'timestamp']); - } - - /** - * @param {number} revNum - Must be a key revision number (see `getKeyRevisionNumber`). - * @returns The attribute text stored at `revNum`. - */ - async _getKeyRevisionAText(revNum: number) { - // @ts-ignore - return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'atext']); - } - - /** - * Returns all authors that worked on this pad - * @return {[String]} The id of authors who contributed to this pad - */ - getAllAuthors() { - const authorIds = []; - - for (const key in this.pool.numToAttrib) { - if (this.pool.numToAttrib[key][0] === 'author' && this.pool.numToAttrib[key][1] !== '') { - authorIds.push(this.pool.numToAttrib[key][1]); - } - } - - return authorIds; - } - - async getInternalRevisionAText(targetRev: number) { - const keyRev = this.getKeyRevisionNumber(targetRev); - const headRev = this.getHeadRevisionNumber(); - if (targetRev > headRev) targetRev = headRev; - const [keyAText, changesets] = await Promise.all([ - this._getKeyRevisionAText(keyRev), - Promise.all( - Stream.range(keyRev + 1, targetRev + 1).map(this.getRevisionChangeset.bind(this))), - ]); - const apool = this.apool(); - let atext = keyAText; - for (const cs of changesets) atext = Changeset.applyToAText(cs, atext, apool); - return atext; - } - - async getRevision(revNum: number) { - return await this.db.get(`pad:${this.id}:revs:${revNum}`); - } - - async getAllAuthorColors() { - const authorIds = this.getAllAuthors(); - const returnTable:MapArrayType = {}; - const colorPalette = authorManager.getColorPalette(); - - await Promise.all( - authorIds.map((authorId) => authorManager.getAuthorColorId(authorId).then((colorId:string) => { - // colorId might be a hex color or an number out of the palette - returnTable[authorId] = colorPalette[colorId] || colorId; - }))); - - return returnTable; - } - - getValidRevisionRange(startRev: any, endRev:any) { - startRev = parseInt(startRev, 10); - const head = this.getHeadRevisionNumber(); - endRev = endRev ? parseInt(endRev, 10) : head; - - if (isNaN(startRev) || startRev < 0 || startRev > head) { - startRev = null; - } - - if (isNaN(endRev) || endRev < startRev) { - endRev = null; - } else if (endRev > head) { - endRev = head; - } - - if (startRev != null && endRev != null) { - return {startRev, endRev}; - } - return null; - } - - getKeyRevisionNumber(revNum: number) { - return Math.floor(revNum / 100) * 100; - } - - /** - * @returns {string} The pad's text. - */ - text(): string { - return this.atext.text; - } - - /** - * Splices text into the pad. If the result of the splice does not end with a newline, one will be - * automatically appended. - * - * @param {number} start - Location in pad text to start removing and inserting characters. Must - * be a non-negative integer less than or equal to `this.text().length`. - * @param {number} ndel - Number of characters to remove starting at `start`. Must be a - * non-negative integer less than or equal to `this.text().length - start`. - * @param {string} ins - New text to insert at `start` (after the `ndel` characters are deleted). - * @param {string} [authorId] - Author ID of the user making the change (if applicable). - */ - async spliceText(start:number, ndel:number, ins: string, authorId: string = '') { - if (start < 0) throw new RangeError(`start index must be non-negative (is ${start})`); - if (ndel < 0) throw new RangeError(`characters to delete must be non-negative (is ${ndel})`); - const orig = this.text(); - assert(orig.endsWith('\n')); - if (start + ndel > orig.length) throw new RangeError('start/delete past the end of the text'); - ins = exports.cleanText(ins); - const willEndWithNewline = - start + ndel < orig.length || // Keeping last char (which is guaranteed to be a newline). - ins.endsWith('\n') || - (!ins && start > 0 && orig[start - 1] === '\n'); - if (!willEndWithNewline) ins += '\n'; - if (ndel === 0 && ins.length === 0) return; - const changeset = Changeset.makeSplice(orig, start, ndel, ins); - await this.appendRevision(changeset, authorId); - } - - /** - * Replaces the pad's text with new text. - * - * @param {string} newText - The pad's new text. If this string does not end with a newline, one - * will be automatically appended. - * @param {string} [authorId] - The author ID of the user that initiated the change, if - * applicable. - */ - async setText(newText: string, authorId = '') { - await this.spliceText(0, this.text().length, newText, authorId); - } - - /** - * Appends text to the pad. - * - * @param {string} newText - Text to insert just BEFORE the pad's existing terminating newline. - * @param {string} [authorId] - The author ID of the user that initiated the change, if - * applicable. - */ - async appendText(newText:string, authorId = '') { - await this.spliceText(this.text().length - 1, 0, newText, authorId); - } - - /** - * Adds a chat message to the pad, including saving it to the database. - * - * @param {(ChatMessage|string)} msgOrText - Either a chat message object (recommended) or a - * string containing the raw text of the user's chat message (deprecated). - * @param {?string} [authorId] - The user's author ID. Deprecated; use `msgOrText.authorId` - * instead. - * @param {?number} [time] - Message timestamp (milliseconds since epoch). Deprecated; use - * `msgOrText.time` instead. - */ - async appendChatMessage(msgOrText: string|typeof ChatMessage, authorId = null, time = null) { - const msg = - msgOrText instanceof ChatMessage ? msgOrText : new ChatMessage(msgOrText, authorId, time); - this.chatHead++; - await Promise.all([ - // Don't save the display name in the database because the user can change it at any time. The - // `displayName` property will be populated with the current value when the message is read - // from the database. - this.db.set(`pad:${this.id}:chat:${this.chatHead}`, {...msg, displayName: undefined}), - this.saveToDatabase(), - ]); - } - - /** - * @param {number} entryNum - ID of the desired chat message. - * @returns {?ChatMessage} - */ - async getChatMessage(entryNum: number) { - const entry = await this.db.get(`pad:${this.id}:chat:${entryNum}`); - if (entry == null) return null; - const message = ChatMessage.fromObject(entry); - message.displayName = await authorManager.getAuthorName(message.authorId); - return message; - } - - /** - * @param {number} start - ID of the first desired chat message. - * @param {number} end - ID of the last desired chat message. - * @returns {ChatMessage[]} Any existing messages with IDs between `start` (inclusive) and `end` - * (inclusive), in order. Note: `start` and `end` form a closed interval, not a half-open - * interval as is typical in code. - */ - async getChatMessages(start: string, end: number) { - const entries = - await Promise.all(Stream.range(start, end + 1).map(this.getChatMessage.bind(this))); - - // sort out broken chat entries - // it looks like in happened in the past that the chat head was - // incremented, but the chat message wasn't added - return entries.filter((entry) => { - const pass = (entry != null); - if (!pass) { - console.warn(`WARNING: Found broken chat entry in pad ${this.id}`); - } - return pass; - }); - } - - async init(text:string, authorId = '') { - // try to load the pad - const value = await this.db.get(`pad:${this.id}`); - - // if this pad exists, load it - if (value != null) { - Object.assign(this, value); - if ('pool' in value) this.pool = new AttributePool().fromJsonable(value.pool); - } else { - if (text == null) { - const context = {pad: this, authorId, type: 'text', content: settings.defaultPadText}; - await hooks.aCallAll('padDefaultContent', context); - if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`); - text = exports.cleanText(context.content); - } - const firstChangeset = Changeset.makeSplice('\n', 0, 0, text); - await this.appendRevision(firstChangeset, authorId); - } - await hooks.aCallAll('padLoad', {pad: this}); - } - - async copy(destinationID: string, force: boolean) { - // Kick everyone from this pad. - // This was commented due to https://github.com/ether/etherpad-lite/issues/3183. - // Do we really need to kick everyone out? - // padMessageHandler.kickSessionsFromPad(sourceID); - - // flush the source pad: - await this.saveToDatabase(); - - // if it's a group pad, let's make sure the group exists. - const destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID); - - // if force is true and already exists a Pad with the same id, remove that Pad - await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force); - - const copyRecord = async (keySuffix: string) => { - const val = await this.db.get(`pad:${this.id}${keySuffix}`); - await db.set(`pad:${destinationID}${keySuffix}`, val); - }; - - const promises = (function* () { - yield copyRecord(''); - // @ts-ignore - yield* Stream.range(0, this.head + 1).map((i) => copyRecord(`:revs:${i}`)); - // @ts-ignore - yield* Stream.range(0, this.chatHead + 1).map((i) => copyRecord(`:chat:${i}`)); - // @ts-ignore - yield this.copyAuthorInfoToDestinationPad(destinationID); - if (destGroupID) yield db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1); - }).call(this); - for (const p of new Stream(promises).batch(100).buffer(99)) await p; - - // Initialize the new pad (will update the listAllPads cache) - const dstPad = await padManager.getPad(destinationID, null); - - // let the plugins know the pad was copied - await hooks.aCallAll('padCopy', { - get originalPad() { - warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead'); - return this.srcPad; - }, - get destinationID() { - warnDeprecated( - 'padCopy destinationID context property is deprecated; use dstPad.id instead'); - return this.dstPad.id; - }, - srcPad: this, - dstPad, - }); - - return {padID: destinationID}; - } - - async checkIfGroupExistAndReturnIt(destinationID: string) { - let destGroupID:false|string = false; - - if (destinationID.indexOf('$') >= 0) { - destGroupID = destinationID.split('$')[0]; - const groupExists = await groupManager.doesGroupExist(destGroupID); - - // group does not exist - if (!groupExists) { - throw new CustomError('groupID does not exist for destinationID', 'apierror'); - } - } - return destGroupID; - } - - async removePadIfForceIsTrueAndAlreadyExist(destinationID: string, force: boolean|string) { - // if the pad exists, we should abort, unless forced. - const exists = await padManager.doesPadExist(destinationID); - - // allow force to be a string - if (typeof force === 'string') { - force = (force.toLowerCase() === 'true'); - } else { - force = !!force; - } - - if (exists) { - if (!force) { - console.error('erroring out without force'); - throw new CustomError('destinationID already exists', 'apierror'); - } - - // exists and forcing - const pad = await padManager.getPad(destinationID); - await pad.remove(); - } - } - - async copyAuthorInfoToDestinationPad(destinationID: string) { - // add the new sourcePad to all authors who contributed to the old one - await Promise.all(this.getAllAuthors().map( - (authorID) => authorManager.addPad(authorID, destinationID))); - } - - async copyPadWithoutHistory(destinationID: string, force: string|boolean, authorId = '') { - // flush the source pad - this.saveToDatabase(); - - // if it's a group pad, let's make sure the group exists. - const destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID); - - // if force is true and already exists a Pad with the same id, remove that Pad - await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force); - - await this.copyAuthorInfoToDestinationPad(destinationID); - - // Group pad? Add it to the group's list - if (destGroupID) { - await db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1); - } - - // initialize the pad with a new line to avoid getting the defaultText - const dstPad = await padManager.getPad(destinationID, '\n', authorId); - dstPad.pool = this.pool.clone(); - - const oldAText = this.atext; - - // based on Changeset.makeSplice - const assem = Changeset.smartOpAssembler(); - for (const op of Changeset.opsFromAText(oldAText)) assem.append(op); - assem.endDocument(); - - // although we have instantiated the dstPad with '\n', an additional '\n' is - // added internally, so the pad text on the revision 0 is "\n\n" - const oldLength = 2; - - const newLength = assem.getLengthChange(); - const newText = oldAText.text; - - // create a changeset that removes the previous text and add the newText with - // all atributes present on the source pad - const changeset = Changeset.pack(oldLength, newLength, assem.toString(), newText); - dstPad.appendRevision(changeset, authorId); - - await hooks.aCallAll('padCopy', { - get originalPad() { - warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead'); - return this.srcPad; - }, - get destinationID() { - warnDeprecated( - 'padCopy destinationID context property is deprecated; use dstPad.id instead'); - return this.dstPad.id; - }, - srcPad: this, - dstPad, - }); - - return {padID: destinationID}; - } - - async remove() { - const padID = this.id; - const p = []; - - // kick everyone from this pad - padMessageHandler.kickSessionsFromPad(padID); - - // delete all relations - the original code used async.parallel but - // none of the operations except getting the group depended on callbacks - // so the database operations here are just started and then left to - // run to completion - - // is it a group pad? -> delete the entry of this pad in the group - if (padID.indexOf('$') >= 0) { - // it is a group pad - const groupID = padID.substring(0, padID.indexOf('$')); - const group = await db.get(`group:${groupID}`); - - // remove the pad entry - delete group.pads[padID]; - - // set the new value - p.push(db.set(`group:${groupID}`, group)); - } - - // remove the readonly entries - p.push(readOnlyManager.getReadOnlyId(padID).then(async (readonlyID: string) => { - await db.remove(`readonly2pad:${readonlyID}`); - })); - p.push(db.remove(`pad2readonly:${padID}`)); - - // delete all chat messages - p.push(promises.timesLimit(this.chatHead + 1, 500, async (i: string) => { - await this.db.remove(`pad:${this.id}:chat:${i}`, null); - })); - - // delete all revisions - p.push(promises.timesLimit(this.head + 1, 500, async (i: string) => { - await this.db.remove(`pad:${this.id}:revs:${i}`, null); - })); - - // remove pad from all authors who contributed - this.getAllAuthors().forEach((authorId) => { - p.push(authorManager.removePad(authorId, padID)); - }); - - // delete the pad entry and delete pad from padManager - p.push(padManager.removePad(padID)); - p.push(hooks.aCallAll('padRemove', { - get padID() { - warnDeprecated('padRemove padID context property is deprecated; use pad.id instead'); - return this.pad.id; - }, - pad: this, - })); - await Promise.all(p); - } - - // set in db - async setPublicStatus(publicStatus: boolean) { - this.publicStatus = publicStatus; - await this.saveToDatabase(); - } - - async addSavedRevision(revNum: string, savedById: string, label: string) { - // if this revision is already saved, return silently - for (const i in this.savedRevisions) { - if (this.savedRevisions[i] && this.savedRevisions[i].revNum === revNum) { - return; - } - } - - // build the saved revision object - const savedRevision:MapArrayType = {}; - savedRevision.revNum = revNum; - savedRevision.savedById = savedById; - savedRevision.label = label || `Revision ${revNum}`; - savedRevision.timestamp = Date.now(); - savedRevision.id = randomString(10); - - // save this new saved revision - this.savedRevisions.push(savedRevision); - await this.saveToDatabase(); - } - - getSavedRevisions() { - return this.savedRevisions; - } - - /** - * Asserts that all pad data is consistent. Throws if inconsistent. - */ - async check() { - assert(this.id != null); - assert.equal(typeof this.id, 'string'); - - const head = this.getHeadRevisionNumber(); - assert(head != null); - assert(Number.isInteger(head)); - assert(head >= -1); - - const savedRevisionsList = this.getSavedRevisionsList(); - assert(Array.isArray(savedRevisionsList)); - assert.equal(this.getSavedRevisionsNumber(), savedRevisionsList.length); - let prevSavedRev = null; - for (const rev of savedRevisionsList) { - assert(rev != null); - assert(Number.isInteger(rev)); - assert(rev >= 0); - assert(rev <= head); - assert(prevSavedRev == null || rev > prevSavedRev); - prevSavedRev = rev; - } - const savedRevisions = this.getSavedRevisions(); - assert(Array.isArray(savedRevisions)); - assert.equal(savedRevisions.length, savedRevisionsList.length); - const savedRevisionsIds = new Set(); - for (const savedRev of savedRevisions) { - assert(savedRev != null); - assert.equal(typeof savedRev, 'object'); - assert(savedRevisionsList.includes(savedRev.revNum)); - assert(savedRev.id != null); - assert.equal(typeof savedRev.id, 'string'); - assert(!savedRevisionsIds.has(savedRev.id)); - savedRevisionsIds.add(savedRev.id); - } - - const pool = this.apool(); - assert(pool instanceof AttributePool); - await pool.check(); - - const authorIds = new Set(); - pool.eachAttrib((k, v) => { - if (k === 'author' && v) authorIds.add(v); - }); - const revs = Stream.range(0, head + 1) - .map(async (r: number) => { - const isKeyRev = r === this.getKeyRevisionNumber(r); - try { - return await Promise.all([ - r, - this.getRevisionChangeset(r), - this.getRevisionAuthor(r), - this.getRevisionDate(r), - isKeyRev, - isKeyRev ? this._getKeyRevisionAText(r) : null, - ]); - } catch (err:any) { - err.message = `(pad ${this.id} revision ${r}) ${err.message}`; - throw err; - } - }) - .batch(100).buffer(99); - let atext = Changeset.makeAText('\n'); - for await (const [r, changeset, authorId, timestamp, isKeyRev, keyAText] of revs) { - try { - assert(authorId != null); - assert.equal(typeof authorId, 'string'); - if (authorId) authorIds.add(authorId); - assert(timestamp != null); - assert.equal(typeof timestamp, 'number'); - assert(timestamp > 0); - assert(changeset != null); - assert.equal(typeof changeset, 'string'); - Changeset.checkRep(changeset); - const unpacked = Changeset.unpack(changeset); - let text = atext.text; - for (const op of Changeset.deserializeOps(unpacked.ops)) { - if (['=', '-'].includes(op.opcode)) { - assert(text.length >= op.chars); - const consumed = text.slice(0, op.chars); - const nlines = (consumed.match(/\n/g) || []).length; - assert.equal(op.lines, nlines); - if (op.lines > 0) assert(consumed.endsWith('\n')); - text = text.slice(op.chars); - } - assert.equal(op.attribs, AttributeMap.fromString(op.attribs, pool).toString()); - } - atext = Changeset.applyToAText(changeset, atext, pool); - if (isKeyRev) assert.deepEqual(keyAText, atext); - } catch (err:any) { - err.message = `(pad ${this.id} revision ${r}) ${err.message}`; - throw err; - } - } - assert.equal(this.text(), atext.text); - assert.deepEqual(this.atext, atext); - assert.deepEqual(this.getAllAuthors().sort(), [...authorIds].sort()); - - assert(this.chatHead != null); - assert(Number.isInteger(this.chatHead)); - assert(this.chatHead >= -1); - const chats = Stream.range(0, this.chatHead + 1) - .map(async (c: number) => { - try { - const msg = await this.getChatMessage(c); - assert(msg != null); - assert(msg instanceof ChatMessage); - } catch (err:any) { - err.message = `(pad ${this.id} chat message ${c}) ${err.message}`; - throw err; - } - }) - .batch(100).buffer(99); - for (const p of chats) await p; - - await hooks.aCallAll('padCheck', {pad: this}); - } + private db: Database; + private atext: AText; + private pool: APool; + private head: number; + private chatHead: number; + private publicStatus: boolean; + private id: string; + private savedRevisions: any[]; + /** + * @param id + * @param [database] - Database object to access this pad's records (and only this pad's records; + * the shared global Etherpad database object is still used for all other pad accesses, such + * as copying the pad). Defaults to the shared global Etherpad database object. This parameter + * can be used to shard pad storage across multiple database backends, to put each pad in its + * own database table, or to validate imported pad data before it is written to the database. + */ + constructor(id: string, database = db) { + this.db = database; + this.atext = Changeset.makeAText("\n"); + this.pool = new AttributePool(); + this.head = -1; + this.chatHead = -1; + this.publicStatus = false; + this.id = id; + this.savedRevisions = []; + } + + apool() { + return this.pool; + } + + getHeadRevisionNumber() { + return this.head; + } + + getSavedRevisionsNumber() { + return this.savedRevisions.length; + } + + getSavedRevisionsList() { + const savedRev = this.savedRevisions.map((rev) => rev.revNum); + savedRev.sort((a, b) => a - b); + return savedRev; + } + + getPublicStatus() { + return this.publicStatus; + } + + /** + * Appends a new revision + * @param {Object} aChangeset The changeset to append to the pad + * @param {String} authorId The id of the author + * @return {Promise} + */ + async appendRevision(aChangeset: AChangeSet, authorId = "") { + const newAText = Changeset.applyToAText(aChangeset, this.atext, this.pool); + if ( + newAText.text === this.atext.text && + newAText.attribs === this.atext.attribs && + this.head !== -1 + ) { + return this.head; + } + Changeset.copyAText(newAText, this.atext); + + const newRev = ++this.head; + + // ex. getNumForAuthor + if (authorId !== "") this.pool.putAttrib(["author", authorId]); + + const hook = this.head === 0 ? "padCreate" : "padUpdate"; + await Promise.all([ + // @ts-ignore + this.db.set(`pad:${this.id}:revs:${newRev}`, { + changeset: aChangeset, + meta: { + author: authorId, + timestamp: Date.now(), + ...(newRev === this.getKeyRevisionNumber(newRev) + ? { + pool: this.pool, + atext: this.atext, + } + : {}), + }, + }), + this.saveToDatabase(), + authorId && authorManager.addPad(authorId, this.id), + hooks.aCallAll(hook, { + pad: this, + authorId, + get author() { + warnDeprecated( + `${hook} hook author context is deprecated; use authorId instead`, + ); + return this.authorId; + }, + set author(authorId) { + warnDeprecated( + `${hook} hook author context is deprecated; use authorId instead`, + ); + this.authorId = authorId; + }, + ...(this.head === 0 + ? {} + : { + revs: newRev, + changeset: aChangeset, + }), + }), + ]); + return newRev; + } + + toJSON() { + const o: Pad = { ...this, pool: this.pool.toJsonable() }; + // @ts-ignore + delete o.db; + // @ts-ignore + delete o.id; + return o; + } + + // save all attributes to the database + async saveToDatabase() { + // @ts-ignore + await this.db.set(`pad:${this.id}`, this); + } + + // get time of last edit (changeset application) + async getLastEdit() { + const revNum = this.getHeadRevisionNumber(); + // @ts-ignore + return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, [ + "meta", + "timestamp", + ]); + } + + async getRevisionChangeset(revNum: number) { + // @ts-ignore + return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ["changeset"]); + } + + async getRevisionAuthor(revNum: number) { + // @ts-ignore + return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, [ + "meta", + "author", + ]); + } + + async getRevisionDate(revNum: number) { + // @ts-ignore + return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, [ + "meta", + "timestamp", + ]); + } + + /** + * @param {number} revNum - Must be a key revision number (see `getKeyRevisionNumber`). + * @returns The attribute text stored at `revNum`. + */ + async _getKeyRevisionAText(revNum: number) { + // @ts-ignore + return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, [ + "meta", + "atext", + ]); + } + + /** + * Returns all authors that worked on this pad + * @return {[String]} The id of authors who contributed to this pad + */ + getAllAuthors() { + const authorIds = []; + + for (const key in this.pool.numToAttrib) { + if ( + this.pool.numToAttrib[key][0] === "author" && + this.pool.numToAttrib[key][1] !== "" + ) { + authorIds.push(this.pool.numToAttrib[key][1]); + } + } + + return authorIds; + } + + async getInternalRevisionAText(targetRev: number) { + const keyRev = this.getKeyRevisionNumber(targetRev); + const headRev = this.getHeadRevisionNumber(); + if (targetRev > headRev) targetRev = headRev; + const [keyAText, changesets] = await Promise.all([ + this._getKeyRevisionAText(keyRev), + Promise.all( + Stream.range(keyRev + 1, targetRev + 1).map( + this.getRevisionChangeset.bind(this), + ), + ), + ]); + const apool = this.apool(); + let atext = keyAText; + for (const cs of changesets) + atext = Changeset.applyToAText(cs, atext, apool); + return atext; + } + + async getRevision(revNum: number) { + return await this.db.get(`pad:${this.id}:revs:${revNum}`); + } + + async getAllAuthorColors() { + const authorIds = this.getAllAuthors(); + const returnTable: MapArrayType = {}; + const colorPalette = authorManager.getColorPalette(); + + await Promise.all( + authorIds.map((authorId) => + authorManager.getAuthorColorId(authorId).then((colorId: string) => { + // colorId might be a hex color or an number out of the palette + returnTable[authorId] = colorPalette[colorId] || colorId; + }), + ), + ); + + return returnTable; + } + + getValidRevisionRange(startRev: any, endRev: any) { + startRev = parseInt(startRev, 10); + const head = this.getHeadRevisionNumber(); + endRev = endRev ? parseInt(endRev, 10) : head; + + if (isNaN(startRev) || startRev < 0 || startRev > head) { + startRev = null; + } + + if (isNaN(endRev) || endRev < startRev) { + endRev = null; + } else if (endRev > head) { + endRev = head; + } + + if (startRev != null && endRev != null) { + return { startRev, endRev }; + } + return null; + } + + getKeyRevisionNumber(revNum: number) { + return Math.floor(revNum / 100) * 100; + } + + /** + * @returns {string} The pad's text. + */ + text(): string { + return this.atext.text; + } + + /** + * Splices text into the pad. If the result of the splice does not end with a newline, one will be + * automatically appended. + * + * @param {number} start - Location in pad text to start removing and inserting characters. Must + * be a non-negative integer less than or equal to `this.text().length`. + * @param {number} ndel - Number of characters to remove starting at `start`. Must be a + * non-negative integer less than or equal to `this.text().length - start`. + * @param {string} ins - New text to insert at `start` (after the `ndel` characters are deleted). + * @param {string} [authorId] - Author ID of the user making the change (if applicable). + */ + async spliceText( + start: number, + ndel: number, + ins: string, + authorId: string = "", + ) { + if (start < 0) + throw new RangeError(`start index must be non-negative (is ${start})`); + if (ndel < 0) + throw new RangeError( + `characters to delete must be non-negative (is ${ndel})`, + ); + const orig = this.text(); + assert(orig.endsWith("\n")); + if (start + ndel > orig.length) + throw new RangeError("start/delete past the end of the text"); + ins = exports.cleanText(ins); + const willEndWithNewline = + start + ndel < orig.length || // Keeping last char (which is guaranteed to be a newline). + ins.endsWith("\n") || + (!ins && start > 0 && orig[start - 1] === "\n"); + if (!willEndWithNewline) ins += "\n"; + if (ndel === 0 && ins.length === 0) return; + const changeset = Changeset.makeSplice(orig, start, ndel, ins); + await this.appendRevision(changeset, authorId); + } + + /** + * Replaces the pad's text with new text. + * + * @param {string} newText - The pad's new text. If this string does not end with a newline, one + * will be automatically appended. + * @param {string} [authorId] - The author ID of the user that initiated the change, if + * applicable. + */ + async setText(newText: string, authorId = "") { + await this.spliceText(0, this.text().length, newText, authorId); + } + + /** + * Appends text to the pad. + * + * @param {string} newText - Text to insert just BEFORE the pad's existing terminating newline. + * @param {string} [authorId] - The author ID of the user that initiated the change, if + * applicable. + */ + async appendText(newText: string, authorId = "") { + await this.spliceText(this.text().length - 1, 0, newText, authorId); + } + + /** + * Adds a chat message to the pad, including saving it to the database. + * + * @param {(ChatMessage|string)} msgOrText - Either a chat message object (recommended) or a + * string containing the raw text of the user's chat message (deprecated). + * @param {?string} [authorId] - The user's author ID. Deprecated; use `msgOrText.authorId` + * instead. + * @param {?number} [time] - Message timestamp (milliseconds since epoch). Deprecated; use + * `msgOrText.time` instead. + */ + async appendChatMessage( + msgOrText: string | typeof ChatMessage, + authorId = null, + time = null, + ) { + const msg = + msgOrText instanceof ChatMessage + ? msgOrText + : new ChatMessage(msgOrText, authorId, time); + this.chatHead++; + await Promise.all([ + // Don't save the display name in the database because the user can change it at any time. The + // `displayName` property will be populated with the current value when the message is read + // from the database. + this.db.set(`pad:${this.id}:chat:${this.chatHead}`, { + ...msg, + displayName: undefined, + }), + this.saveToDatabase(), + ]); + } + + /** + * @param {number} entryNum - ID of the desired chat message. + * @returns {?ChatMessage} + */ + async getChatMessage(entryNum: number) { + const entry = await this.db.get(`pad:${this.id}:chat:${entryNum}`); + if (entry == null) return null; + const message = ChatMessage.fromObject(entry); + message.displayName = await authorManager.getAuthorName(message.authorId); + return message; + } + + /** + * @param {number} start - ID of the first desired chat message. + * @param {number} end - ID of the last desired chat message. + * @returns {ChatMessage[]} Any existing messages with IDs between `start` (inclusive) and `end` + * (inclusive), in order. Note: `start` and `end` form a closed interval, not a half-open + * interval as is typical in code. + */ + async getChatMessages(start: string, end: number) { + const entries = await Promise.all( + Stream.range(start, end + 1).map(this.getChatMessage.bind(this)), + ); + + // sort out broken chat entries + // it looks like in happened in the past that the chat head was + // incremented, but the chat message wasn't added + return entries.filter((entry) => { + const pass = entry != null; + if (!pass) { + console.warn(`WARNING: Found broken chat entry in pad ${this.id}`); + } + return pass; + }); + } + + async init(text: string, authorId = "") { + // try to load the pad + const value = await this.db.get(`pad:${this.id}`); + + // if this pad exists, load it + if (value != null) { + Object.assign(this, value); + if ("pool" in value) + this.pool = new AttributePool().fromJsonable(value.pool); + } else { + if (text == null) { + const context = { + pad: this, + authorId, + type: "text", + content: settings.defaultPadText, + }; + await hooks.aCallAll("padDefaultContent", context); + if (context.type !== "text") + throw new Error(`unsupported content type: ${context.type}`); + text = exports.cleanText(context.content); + } + const firstChangeset = Changeset.makeSplice("\n", 0, 0, text); + await this.appendRevision(firstChangeset, authorId); + } + await hooks.aCallAll("padLoad", { pad: this }); + } + + async copy(destinationID: string, force: boolean) { + // Kick everyone from this pad. + // This was commented due to https://github.com/ether/etherpad-lite/issues/3183. + // Do we really need to kick everyone out? + // padMessageHandler.kickSessionsFromPad(sourceID); + + // flush the source pad: + await this.saveToDatabase(); + + // if it's a group pad, let's make sure the group exists. + const destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID); + + // if force is true and already exists a Pad with the same id, remove that Pad + await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force); + + const copyRecord = async (keySuffix: string) => { + const val = await this.db.get(`pad:${this.id}${keySuffix}`); + await db.set(`pad:${destinationID}${keySuffix}`, val); + }; + + const promises = function* () { + yield copyRecord(""); + // @ts-ignore + yield* Stream.range(0, this.head + 1).map((i) => + copyRecord(`:revs:${i}`), + ); + // @ts-ignore + yield* Stream.range(0, this.chatHead + 1).map((i) => + copyRecord(`:chat:${i}`), + ); + // @ts-ignore + yield this.copyAuthorInfoToDestinationPad(destinationID); + if (destGroupID) + yield db.setSub(`group:${destGroupID}`, ["pads", destinationID], 1); + }.call(this); + for (const p of new Stream(promises).batch(100).buffer(99)) await p; + + // Initialize the new pad (will update the listAllPads cache) + const dstPad = await padManager.getPad(destinationID, null); + + // let the plugins know the pad was copied + await hooks.aCallAll("padCopy", { + get originalPad() { + warnDeprecated( + "padCopy originalPad context property is deprecated; use srcPad instead", + ); + return this.srcPad; + }, + get destinationID() { + warnDeprecated( + "padCopy destinationID context property is deprecated; use dstPad.id instead", + ); + return this.dstPad.id; + }, + srcPad: this, + dstPad, + }); + + return { padID: destinationID }; + } + + async checkIfGroupExistAndReturnIt(destinationID: string) { + let destGroupID: false | string = false; + + if (destinationID.indexOf("$") >= 0) { + destGroupID = destinationID.split("$")[0]; + const groupExists = await groupManager.doesGroupExist(destGroupID); + + // group does not exist + if (!groupExists) { + throw new CustomError( + "groupID does not exist for destinationID", + "apierror", + ); + } + } + return destGroupID; + } + + async removePadIfForceIsTrueAndAlreadyExist( + destinationID: string, + force: boolean | string, + ) { + // if the pad exists, we should abort, unless forced. + const exists = await padManager.doesPadExist(destinationID); + + // allow force to be a string + if (typeof force === "string") { + force = force.toLowerCase() === "true"; + } else { + force = !!force; + } + + if (exists) { + if (!force) { + console.error("erroring out without force"); + throw new CustomError("destinationID already exists", "apierror"); + } + + // exists and forcing + const pad = await padManager.getPad(destinationID); + await pad.remove(); + } + } + + async copyAuthorInfoToDestinationPad(destinationID: string) { + // add the new sourcePad to all authors who contributed to the old one + await Promise.all( + this.getAllAuthors().map((authorID) => + authorManager.addPad(authorID, destinationID), + ), + ); + } + + async copyPadWithoutHistory( + destinationID: string, + force: string | boolean, + authorId = "", + ) { + // flush the source pad + this.saveToDatabase(); + + // if it's a group pad, let's make sure the group exists. + const destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID); + + // if force is true and already exists a Pad with the same id, remove that Pad + await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force); + + await this.copyAuthorInfoToDestinationPad(destinationID); + + // Group pad? Add it to the group's list + if (destGroupID) { + await db.setSub(`group:${destGroupID}`, ["pads", destinationID], 1); + } + + // initialize the pad with a new line to avoid getting the defaultText + const dstPad = await padManager.getPad(destinationID, "\n", authorId); + dstPad.pool = this.pool.clone(); + + const oldAText = this.atext; + + // based on Changeset.makeSplice + const assem = Changeset.smartOpAssembler(); + for (const op of Changeset.opsFromAText(oldAText)) assem.append(op); + assem.endDocument(); + + // although we have instantiated the dstPad with '\n', an additional '\n' is + // added internally, so the pad text on the revision 0 is "\n\n" + const oldLength = 2; + + const newLength = assem.getLengthChange(); + const newText = oldAText.text; + + // create a changeset that removes the previous text and add the newText with + // all atributes present on the source pad + const changeset = Changeset.pack( + oldLength, + newLength, + assem.toString(), + newText, + ); + dstPad.appendRevision(changeset, authorId); + + await hooks.aCallAll("padCopy", { + get originalPad() { + warnDeprecated( + "padCopy originalPad context property is deprecated; use srcPad instead", + ); + return this.srcPad; + }, + get destinationID() { + warnDeprecated( + "padCopy destinationID context property is deprecated; use dstPad.id instead", + ); + return this.dstPad.id; + }, + srcPad: this, + dstPad, + }); + + return { padID: destinationID }; + } + + async remove() { + const padID = this.id; + const p = []; + + // kick everyone from this pad + padMessageHandler.kickSessionsFromPad(padID); + + // delete all relations - the original code used async.parallel but + // none of the operations except getting the group depended on callbacks + // so the database operations here are just started and then left to + // run to completion + + // is it a group pad? -> delete the entry of this pad in the group + if (padID.indexOf("$") >= 0) { + // it is a group pad + const groupID = padID.substring(0, padID.indexOf("$")); + const group = await db.get(`group:${groupID}`); + + // remove the pad entry + delete group.pads[padID]; + + // set the new value + p.push(db.set(`group:${groupID}`, group)); + } + + // remove the readonly entries + p.push( + readOnlyManager.getReadOnlyId(padID).then(async (readonlyID: string) => { + await db.remove(`readonly2pad:${readonlyID}`); + }), + ); + p.push(db.remove(`pad2readonly:${padID}`)); + + // delete all chat messages + p.push( + promises.timesLimit(this.chatHead + 1, 500, async (i: string) => { + await this.db.remove(`pad:${this.id}:chat:${i}`, null); + }), + ); + + // delete all revisions + p.push( + promises.timesLimit(this.head + 1, 500, async (i: string) => { + await this.db.remove(`pad:${this.id}:revs:${i}`, null); + }), + ); + + // remove pad from all authors who contributed + this.getAllAuthors().forEach((authorId) => { + p.push(authorManager.removePad(authorId, padID)); + }); + + // delete the pad entry and delete pad from padManager + p.push(padManager.removePad(padID)); + p.push( + hooks.aCallAll("padRemove", { + get padID() { + warnDeprecated( + "padRemove padID context property is deprecated; use pad.id instead", + ); + return this.pad.id; + }, + pad: this, + }), + ); + await Promise.all(p); + } + + // set in db + async setPublicStatus(publicStatus: boolean) { + this.publicStatus = publicStatus; + await this.saveToDatabase(); + } + + async addSavedRevision(revNum: string, savedById: string, label: string) { + // if this revision is already saved, return silently + for (const i in this.savedRevisions) { + if (this.savedRevisions[i] && this.savedRevisions[i].revNum === revNum) { + return; + } + } + + // build the saved revision object + const savedRevision: MapArrayType = {}; + savedRevision.revNum = revNum; + savedRevision.savedById = savedById; + savedRevision.label = label || `Revision ${revNum}`; + savedRevision.timestamp = Date.now(); + savedRevision.id = randomString(10); + + // save this new saved revision + this.savedRevisions.push(savedRevision); + await this.saveToDatabase(); + } + + getSavedRevisions() { + return this.savedRevisions; + } + + /** + * Asserts that all pad data is consistent. Throws if inconsistent. + */ + async check() { + assert(this.id != null); + assert.equal(typeof this.id, "string"); + + const head = this.getHeadRevisionNumber(); + assert(head != null); + assert(Number.isInteger(head)); + assert(head >= -1); + + const savedRevisionsList = this.getSavedRevisionsList(); + assert(Array.isArray(savedRevisionsList)); + assert.equal(this.getSavedRevisionsNumber(), savedRevisionsList.length); + let prevSavedRev = null; + for (const rev of savedRevisionsList) { + assert(rev != null); + assert(Number.isInteger(rev)); + assert(rev >= 0); + assert(rev <= head); + assert(prevSavedRev == null || rev > prevSavedRev); + prevSavedRev = rev; + } + const savedRevisions = this.getSavedRevisions(); + assert(Array.isArray(savedRevisions)); + assert.equal(savedRevisions.length, savedRevisionsList.length); + const savedRevisionsIds = new Set(); + for (const savedRev of savedRevisions) { + assert(savedRev != null); + assert.equal(typeof savedRev, "object"); + assert(savedRevisionsList.includes(savedRev.revNum)); + assert(savedRev.id != null); + assert.equal(typeof savedRev.id, "string"); + assert(!savedRevisionsIds.has(savedRev.id)); + savedRevisionsIds.add(savedRev.id); + } + + const pool = this.apool(); + assert(pool instanceof AttributePool); + await pool.check(); + + const authorIds = new Set(); + pool.eachAttrib((k, v) => { + if (k === "author" && v) authorIds.add(v); + }); + const revs = Stream.range(0, head + 1) + .map(async (r: number) => { + const isKeyRev = r === this.getKeyRevisionNumber(r); + try { + return await Promise.all([ + r, + this.getRevisionChangeset(r), + this.getRevisionAuthor(r), + this.getRevisionDate(r), + isKeyRev, + isKeyRev ? this._getKeyRevisionAText(r) : null, + ]); + } catch (err: any) { + err.message = `(pad ${this.id} revision ${r}) ${err.message}`; + throw err; + } + }) + .batch(100) + .buffer(99); + let atext = Changeset.makeAText("\n"); + for await (const [ + r, + changeset, + authorId, + timestamp, + isKeyRev, + keyAText, + ] of revs) { + try { + assert(authorId != null); + assert.equal(typeof authorId, "string"); + if (authorId) authorIds.add(authorId); + assert(timestamp != null); + assert.equal(typeof timestamp, "number"); + assert(timestamp > 0); + assert(changeset != null); + assert.equal(typeof changeset, "string"); + Changeset.checkRep(changeset); + const unpacked = Changeset.unpack(changeset); + let text = atext.text; + for (const op of Changeset.deserializeOps(unpacked.ops)) { + if (["=", "-"].includes(op.opcode)) { + assert(text.length >= op.chars); + const consumed = text.slice(0, op.chars); + const nlines = (consumed.match(/\n/g) || []).length; + assert.equal(op.lines, nlines); + if (op.lines > 0) assert(consumed.endsWith("\n")); + text = text.slice(op.chars); + } + assert.equal( + op.attribs, + AttributeMap.fromString(op.attribs, pool).toString(), + ); + } + atext = Changeset.applyToAText(changeset, atext, pool); + if (isKeyRev) assert.deepEqual(keyAText, atext); + } catch (err: any) { + err.message = `(pad ${this.id} revision ${r}) ${err.message}`; + throw err; + } + } + assert.equal(this.text(), atext.text); + assert.deepEqual(this.atext, atext); + assert.deepEqual(this.getAllAuthors().sort(), [...authorIds].sort()); + + assert(this.chatHead != null); + assert(Number.isInteger(this.chatHead)); + assert(this.chatHead >= -1); + const chats = Stream.range(0, this.chatHead + 1) + .map(async (c: number) => { + try { + const msg = await this.getChatMessage(c); + assert(msg != null); + assert(msg instanceof ChatMessage); + } catch (err: any) { + err.message = `(pad ${this.id} chat message ${c}) ${err.message}`; + throw err; + } + }) + .batch(100) + .buffer(99); + for (const p of chats) await p; + + await hooks.aCallAll("padCheck", { pad: this }); + } } exports.Pad = Pad; diff --git a/src/node/db/PadManager.ts b/src/node/db/PadManager.ts index 54dbbf089..e7e8b17b3 100644 --- a/src/node/db/PadManager.ts +++ b/src/node/db/PadManager.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * The Pad Manager is a Factory for pad Objects */ @@ -19,13 +19,13 @@ * limitations under the License. */ -import {MapArrayType} from "../types/MapType"; -import {PadType} from "../types/PadType"; +import { MapArrayType } from "../types/MapType"; +import { PadType } from "../types/PadType"; -const CustomError = require('../utils/customError'); -const Pad = require('../db/Pad'); -const db = require('./DB'); -const settings = require('../utils/Settings'); +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. @@ -38,18 +38,16 @@ const settings = require('../utils/Settings'); * If this is needed in other places, it would be wise to make this a prototype * that's defined somewhere more sensible. */ -const globalPads:MapArrayType = { - get(name: string) - { - return this[`:${name}`]; - }, - set(name: string, value: any) - { - this[`:${name}`] = value; - }, - remove(name: string) { - delete this[`:${name}`]; - }, +const globalPads: MapArrayType = { + get(name: string) { + return this[`:${name}`]; + }, + set(name: string, value: any) { + this[`:${name}`] = value; + }, + remove(name: string) { + delete this[`:${name}`]; + }, }; /** @@ -57,45 +55,45 @@ const globalPads:MapArrayType = { * * Updated without db access as new pads are created/old ones removed. */ -const padList = new class { - private _cachedList: string[] | null; - private _list: Set; - private _loaded: Promise | null; - constructor() { - this._cachedList = null; - this._list = new Set(); - this._loaded = null; - } +const padList = new (class { + private _cachedList: string[] | null; + private _list: Set; + private _loaded: Promise | null; + constructor() { + this._cachedList = null; + this._list = new Set(); + this._loaded = null; + } - /** - * Returns all pads in alphabetical order as array. - * @returns {Promise} A promise that resolves to an array of pad IDs. - */ - async getPads() { - if (!this._loaded) { - this._loaded = (async () => { - const dbData = await db.findKeys('pad:*', '*:*:*'); - if (dbData == null) return; - for (const val of dbData) this.addPad(val.replace(/^pad:/, '')); - })(); - } - await this._loaded; - if (!this._cachedList) this._cachedList = [...this._list].sort(); - return this._cachedList; - } + /** + * Returns all pads in alphabetical order as array. + * @returns {Promise} A promise that resolves to an array of pad IDs. + */ + async getPads() { + if (!this._loaded) { + this._loaded = (async () => { + const dbData = await db.findKeys("pad:*", "*:*:*"); + if (dbData == null) return; + for (const val of dbData) this.addPad(val.replace(/^pad:/, "")); + })(); + } + await this._loaded; + if (!this._cachedList) this._cachedList = [...this._list].sort(); + return this._cachedList; + } - addPad(name: string) { - if (this._list.has(name)) return; - this._list.add(name); - this._cachedList = null; - } + addPad(name: string) { + if (this._list.has(name)) return; + this._list.add(name); + this._cachedList = null; + } - removePad(name: string) { - if (!this._list.has(name)) return; - this._list.delete(name); - this._cachedList = null; - } -}(); + removePad(name: string) { + if (!this._list.has(name)) return; + this._list.delete(name); + this._cachedList = null; + } +})(); // initialises the all-knowing data structure @@ -106,57 +104,58 @@ const padList = new class { * @param {string} [authorId] - Optional author ID of the user that initiated the pad creation (if * applicable). */ -exports.getPad = async (id: string, text?: string|null, authorId:string|null = ''):Promise => { - // check if this is a valid padId - if (!exports.isValidPadId(id)) { - throw new CustomError(`${id} is not a valid padId`, 'apierror'); - } +exports.getPad = async ( + id: string, + text?: string | null, + authorId: string | null = "", +): Promise => { + // check if this is a valid padId + if (!exports.isValidPadId(id)) { + throw new CustomError(`${id} is not a valid padId`, "apierror"); + } - // check if this is a valid text - if (text != null) { - // check if text is a string - if (typeof text !== 'string') { - throw new CustomError('text is not a string', 'apierror'); - } + // check if this is a valid text + if (text != null) { + // check if text is a string + if (typeof text !== "string") { + throw new CustomError("text is not a string", "apierror"); + } - // check if text is less than 100k chars - if (text.length > 100000) { - throw new CustomError('text must be less than 100k chars', 'apierror'); - } - } + // check if text is less than 100k chars + if (text.length > 100000) { + throw new CustomError("text must be less than 100k chars", "apierror"); + } + } - let pad = globalPads.get(id); + let pad = globalPads.get(id); - // return pad if it's already loaded - if (pad != null) { - return pad; - } + // return pad if it's already loaded + if (pad != null) { + return pad; + } - // try to load pad - pad = new Pad.Pad(id); + // try to load pad + pad = new Pad.Pad(id); - // initialize the pad - await pad.init(text, authorId); - globalPads.set(id, pad); - padList.addPad(id); + // initialize the pad + await pad.init(text, authorId); + globalPads.set(id, pad); + padList.addPad(id); - return pad; + return pad; }; exports.listAllPads = async () => { - const padIDs = await padList.getPads(); + const padIDs = await padList.getPads(); - return {padIDs}; + return { padIDs }; }; - - - // checks if a pad exists exports.doesPadExist = async (padId: string) => { - const value = await db.get(`pad:${padId}`); + const value = await db.get(`pad:${padId}`); - return (value != null && value.atext); + return value != null && value.atext; }; // alias for backwards compatibility @@ -167,44 +166,45 @@ exports.doesPadExists = exports.doesPadExist; * time, and allow us to "play back" these changes so legacy padIds can be found. */ const padIdTransforms = [ - [/\s+/g, '_'], - [/:+/g, '_'], + [/\s+/g, "_"], + [/:+/g, "_"], ]; // returns a sanitized padId, respecting legacy pad id formats exports.sanitizePadId = async (padId: string) => { - for (let i = 0, n = padIdTransforms.length; i < n; ++i) { - const exists = await exports.doesPadExist(padId); + for (let i = 0, n = padIdTransforms.length; i < n; ++i) { + const exists = await exports.doesPadExist(padId); - if (exists) { - return padId; - } + if (exists) { + return padId; + } - const [from, to] = padIdTransforms[i]; + const [from, to] = padIdTransforms[i]; - // @ts-ignore - padId = padId.replace(from, to); - } + // @ts-ignore + padId = padId.replace(from, to); + } - if (settings.lowerCasePadIds) padId = padId.toLowerCase(); + if (settings.lowerCasePadIds) padId = padId.toLowerCase(); - // we're out of possible transformations, so just return it - return padId; + // we're out of possible transformations, so just return it + return padId; }; -exports.isValidPadId = (padId: string) => /^(g.[a-zA-Z0-9]{16}\$)?[^$]{1,50}$/.test(padId); +exports.isValidPadId = (padId: string) => + /^(g.[a-zA-Z0-9]{16}\$)?[^$]{1,50}$/.test(padId); /** * Removes the pad from database and unloads it. */ exports.removePad = async (padId: string) => { - const p = db.remove(`pad:${padId}`); - exports.unloadPad(padId); - padList.removePad(padId); - await p; + const p = db.remove(`pad:${padId}`); + exports.unloadPad(padId); + padList.removePad(padId); + await p; }; // removes a pad from the cache exports.unloadPad = (padId: string) => { - globalPads.remove(padId); + globalPads.remove(padId); }; diff --git a/src/node/db/ReadOnlyManager.ts b/src/node/db/ReadOnlyManager.ts index 23639d665..aedded6eb 100644 --- a/src/node/db/ReadOnlyManager.ts +++ b/src/node/db/ReadOnlyManager.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * The ReadOnlyManager manages the database and rendering releated to read only pads */ @@ -19,37 +19,35 @@ * limitations under the License. */ - -const db = require('./DB'); -const randomString = require('../utils/randomstring'); - +const db = require("./DB"); +const randomString = require("../utils/randomstring"); /** * checks if the id pattern matches a read-only pad id * @param {String} id the pad's id * @return {Boolean} true if the id is readonly */ -exports.isReadOnlyId = (id:string) => id.startsWith('r.'); +exports.isReadOnlyId = (id: string) => id.startsWith("r."); /** * returns a read only id for a pad * @param {String} padId the id of the pad * @return {String} the read only id */ -exports.getReadOnlyId = async (padId:string) => { - // check if there is a pad2readonly entry - let readOnlyId = await db.get(`pad2readonly:${padId}`); +exports.getReadOnlyId = async (padId: string) => { + // check if there is a pad2readonly entry + let readOnlyId = await db.get(`pad2readonly:${padId}`); - // there is no readOnly Entry in the database, let's create one - if (readOnlyId == null) { - readOnlyId = `r.${randomString(16)}`; - await Promise.all([ - db.set(`pad2readonly:${padId}`, readOnlyId), - db.set(`readonly2pad:${readOnlyId}`, padId), - ]); - } + // there is no readOnly Entry in the database, let's create one + if (readOnlyId == null) { + readOnlyId = `r.${randomString(16)}`; + await Promise.all([ + db.set(`pad2readonly:${padId}`, readOnlyId), + db.set(`readonly2pad:${readOnlyId}`, padId), + ]); + } - return readOnlyId; + return readOnlyId; }; /** @@ -57,19 +55,20 @@ exports.getReadOnlyId = async (padId:string) => { * @param {String} readOnlyId read only id * @return {String} the padId */ -exports.getPadId = async (readOnlyId:string) => await db.get(`readonly2pad:${readOnlyId}`); +exports.getPadId = async (readOnlyId: string) => + await db.get(`readonly2pad:${readOnlyId}`); /** * returns the padId and readonlyPadId in an object for any id * @param {String} id read only id or real pad id * @return {Object} an object with the padId and readonlyPadId */ -exports.getIds = async (id:string) => { - const readonly = exports.isReadOnlyId(id); +exports.getIds = async (id: string) => { + const readonly = exports.isReadOnlyId(id); - // Might be null, if this is an unknown read-only id - const readOnlyPadId = readonly ? id : await exports.getReadOnlyId(id); - const padId = readonly ? await exports.getPadId(id) : id; + // Might be null, if this is an unknown read-only id + const readOnlyPadId = readonly ? id : await exports.getReadOnlyId(id); + const padId = readonly ? await exports.getPadId(id) : id; - return {readOnlyPadId, padId, readonly}; + return { readOnlyPadId, padId, readonly }; }; diff --git a/src/node/db/SecurityManager.ts b/src/node/db/SecurityManager.ts index 326bf3659..bca19c444 100644 --- a/src/node/db/SecurityManager.ts +++ b/src/node/db/SecurityManager.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * Controls the security of pad access */ @@ -19,20 +19,20 @@ * limitations under the License. */ -import {UserSettingsObject} from "../types/UserSettingsObject"; +import { UserSettingsObject } from "../types/UserSettingsObject"; -const authorManager = require('./AuthorManager'); -const hooks = require('../../static/js/pluginfw/hooks.js'); -const padManager = require('./PadManager'); -const readOnlyManager = require('./ReadOnlyManager'); -const sessionManager = require('./SessionManager'); -const settings = require('../utils/Settings'); -const webaccess = require('../hooks/express/webaccess'); -const log4js = require('log4js'); -const authLogger = log4js.getLogger('auth'); -const {padutils} = require('../../static/js/pad_utils'); +const authorManager = require("./AuthorManager"); +const hooks = require("../../static/js/pluginfw/hooks.js"); +const padManager = require("./PadManager"); +const readOnlyManager = require("./ReadOnlyManager"); +const sessionManager = require("./SessionManager"); +const settings = require("../utils/Settings"); +const webaccess = require("../hooks/express/webaccess"); +const log4js = require("log4js"); +const authLogger = log4js.getLogger("auth"); +const { padutils } = require("../../static/js/pad_utils"); -const DENY = Object.freeze({accessStatus: 'deny'}); +const DENY = Object.freeze({ accessStatus: "deny" }); /** * Determines whether the user can access a pad. @@ -57,94 +57,123 @@ const DENY = Object.freeze({accessStatus: 'deny'}); * @param {Object} userSettings * @return {DENY|{accessStatus: String, authorID: String}} */ -exports.checkAccess = async (padID:string, sessionCookie:string, token:string, userSettings:UserSettingsObject) => { - if (!padID) { - authLogger.debug('access denied: missing padID'); - return DENY; - } +exports.checkAccess = async ( + padID: string, + sessionCookie: string, + token: string, + userSettings: UserSettingsObject, +) => { + if (!padID) { + authLogger.debug("access denied: missing padID"); + return DENY; + } - let canCreate = !settings.editOnly; + let canCreate = !settings.editOnly; - if (readOnlyManager.isReadOnlyId(padID)) { - canCreate = false; - padID = await readOnlyManager.getPadId(padID); - if (padID == null) { - authLogger.debug('access denied: read-only pad ID for a pad that does not exist'); - return DENY; - } - } + if (readOnlyManager.isReadOnlyId(padID)) { + canCreate = false; + padID = await readOnlyManager.getPadId(padID); + if (padID == null) { + authLogger.debug( + "access denied: read-only pad ID for a pad that does not exist", + ); + return DENY; + } + } - // Authentication and authorization checks. - if (settings.loadTest) { - console.warn( - 'bypassing socket.io authentication and authorization checks due to settings.loadTest'); - } else if (settings.requireAuthentication) { - if (userSettings == null) { - authLogger.debug('access denied: authentication is required'); - return DENY; - } - if (userSettings.canCreate != null && !userSettings.canCreate) canCreate = false; - if (userSettings.readOnly) canCreate = false; - // Note: userSettings.padAuthorizations should still be populated even if - // settings.requireAuthorization is false. - const padAuthzs = userSettings.padAuthorizations || {}; - const level = webaccess.normalizeAuthzLevel(padAuthzs[padID]); - if (!level) { - authLogger.debug('access denied: unauthorized'); - return DENY; - } - if (level !== 'create') canCreate = false; - } + // Authentication and authorization checks. + if (settings.loadTest) { + console.warn( + "bypassing socket.io authentication and authorization checks due to settings.loadTest", + ); + } else if (settings.requireAuthentication) { + if (userSettings == null) { + authLogger.debug("access denied: authentication is required"); + return DENY; + } + if (userSettings.canCreate != null && !userSettings.canCreate) + canCreate = false; + if (userSettings.readOnly) canCreate = false; + // Note: userSettings.padAuthorizations should still be populated even if + // settings.requireAuthorization is false. + const padAuthzs = userSettings.padAuthorizations || {}; + const level = webaccess.normalizeAuthzLevel(padAuthzs[padID]); + if (!level) { + authLogger.debug("access denied: unauthorized"); + return DENY; + } + if (level !== "create") canCreate = false; + } - // allow plugins to deny access - const isFalse = (x:boolean) => x === false; - if (hooks.callAll('onAccessCheck', {padID, token, sessionCookie}).some(isFalse)) { - authLogger.debug('access denied: an onAccessCheck hook function returned false'); - return DENY; - } + // allow plugins to deny access + const isFalse = (x: boolean) => x === false; + if ( + hooks + .callAll("onAccessCheck", { padID, token, sessionCookie }) + .some(isFalse) + ) { + authLogger.debug( + "access denied: an onAccessCheck hook function returned false", + ); + return DENY; + } - const padExists = await padManager.doesPadExist(padID); - if (!padExists && !canCreate) { - authLogger.debug('access denied: user attempted to create a pad, which is prohibited'); - return DENY; - } + const padExists = await padManager.doesPadExist(padID); + if (!padExists && !canCreate) { + authLogger.debug( + "access denied: user attempted to create a pad, which is prohibited", + ); + return DENY; + } - const sessionAuthorID = await sessionManager.findAuthorID(padID.split('$')[0], sessionCookie); - if (settings.requireSession && !sessionAuthorID) { - authLogger.debug('access denied: HTTP API session is required'); - return DENY; - } - if (!sessionAuthorID && token != null && !padutils.isValidAuthorToken(token)) { - // The author token should be kept secret, so do not log it. - authLogger.debug('access denied: invalid author token'); - return DENY; - } + const sessionAuthorID = await sessionManager.findAuthorID( + padID.split("$")[0], + sessionCookie, + ); + if (settings.requireSession && !sessionAuthorID) { + authLogger.debug("access denied: HTTP API session is required"); + return DENY; + } + if ( + !sessionAuthorID && + token != null && + !padutils.isValidAuthorToken(token) + ) { + // The author token should be kept secret, so do not log it. + authLogger.debug("access denied: invalid author token"); + return DENY; + } - const grant = { - accessStatus: 'grant', - authorID: sessionAuthorID || await authorManager.getAuthorId(token, userSettings), - }; + const grant = { + accessStatus: "grant", + authorID: + sessionAuthorID || (await authorManager.getAuthorId(token, userSettings)), + }; - if (!padID.includes('$')) { - // Only group pads can be private, so there is nothing more to check for this non-group pad. - return grant; - } + if (!padID.includes("$")) { + // Only group pads can be private, so there is nothing more to check for this non-group pad. + return grant; + } - if (!padExists) { - if (sessionAuthorID == null) { - authLogger.debug('access denied: must have an HTTP API session to create a group pad'); - return DENY; - } - // Creating a group pad, so there is no public status to check. - return grant; - } + if (!padExists) { + if (sessionAuthorID == null) { + authLogger.debug( + "access denied: must have an HTTP API session to create a group pad", + ); + return DENY; + } + // Creating a group pad, so there is no public status to check. + return grant; + } - const pad = await padManager.getPad(padID); + const pad = await padManager.getPad(padID); - if (!pad.getPublicStatus() && sessionAuthorID == null) { - authLogger.debug('access denied: must have an HTTP API session to access private group pads'); - return DENY; - } + if (!pad.getPublicStatus() && sessionAuthorID == null) { + authLogger.debug( + "access denied: must have an HTTP API session to access private group pads", + ); + return DENY; + } - return grant; + return grant; }; diff --git a/src/node/db/SessionManager.ts b/src/node/db/SessionManager.ts index c0e43a659..950be63db 100644 --- a/src/node/db/SessionManager.ts +++ b/src/node/db/SessionManager.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * The Session Manager provides functions to manage session in the database, * it only provides session management for sessions created by the API @@ -20,12 +20,12 @@ * limitations under the License. */ -const CustomError = require('../utils/customError'); -const promises = require('../utils/promises'); -const randomString = require('../utils/randomstring'); -const db = require('./DB'); -const groupManager = require('./GroupManager'); -const authorManager = require('./AuthorManager'); +const CustomError = require("../utils/customError"); +const promises = require("../utils/promises"); +const randomString = require("../utils/randomstring"); +const db = require("./DB"); +const groupManager = require("./GroupManager"); +const authorManager = require("./AuthorManager"); /** * Finds the author ID for a session with matching ID and group. @@ -36,52 +36,59 @@ const authorManager = require('./AuthorManager'); * sessionCookie, and is bound to a group with the given ID, then this returns the author ID * bound to the session. Otherwise, returns undefined. */ -exports.findAuthorID = async (groupID:string, sessionCookie: string) => { - if (!sessionCookie) return undefined; - /* - * Sometimes, RFC 6265-compliant web servers may send back a cookie whose - * value is enclosed in double quotes, such as: - * - * Set-Cookie: sessionCookie="s.37cf5299fbf981e14121fba3a588c02b, - * s.2b21517bf50729d8130ab85736a11346"; Version=1; Path=/; Domain=localhost; Discard - * - * Where the double quotes at the start and the end of the header value are - * just delimiters. This is perfectly legal: Etherpad parsing logic should - * cope with that, and remove the quotes early in the request phase. - * - * Somehow, this does not happen, and in such cases the actual value that - * sessionCookie ends up having is: - * - * sessionCookie = '"s.37cf5299fbf981e14121fba3a588c02b,s.2b21517bf50729d8130ab85736a11346"' - * - * As quick measure, let's strip the double quotes (when present). - * Note that here we are being minimal, limiting ourselves to just removing - * quotes at the start and the end of the string. - * - * Fixes #3819. - * Also, see #3820. - */ - const sessionIDs = sessionCookie.replace(/^"|"$/g, '').split(','); - const sessionInfoPromises = sessionIDs.map(async (id) => { - try { - return await exports.getSessionInfo(id); - } catch (err:any) { - if (err.message === 'sessionID does not exist') { - console.debug(`SessionManager getAuthorID: no session exists with ID ${id}`); - } else { - throw err; - } - } - return undefined; - }); - const now = Math.floor(Date.now() / 1000); - const isMatch = (si: { - groupID: string; - validUntil: number; - }|null) => (si != null && si.groupID === groupID && now < si.validUntil); - const sessionInfo = await promises.firstSatisfies(sessionInfoPromises, isMatch); - if (sessionInfo == null) return undefined; - return sessionInfo.authorID; +exports.findAuthorID = async (groupID: string, sessionCookie: string) => { + if (!sessionCookie) return undefined; + /* + * Sometimes, RFC 6265-compliant web servers may send back a cookie whose + * value is enclosed in double quotes, such as: + * + * Set-Cookie: sessionCookie="s.37cf5299fbf981e14121fba3a588c02b, + * s.2b21517bf50729d8130ab85736a11346"; Version=1; Path=/; Domain=localhost; Discard + * + * Where the double quotes at the start and the end of the header value are + * just delimiters. This is perfectly legal: Etherpad parsing logic should + * cope with that, and remove the quotes early in the request phase. + * + * Somehow, this does not happen, and in such cases the actual value that + * sessionCookie ends up having is: + * + * sessionCookie = '"s.37cf5299fbf981e14121fba3a588c02b,s.2b21517bf50729d8130ab85736a11346"' + * + * As quick measure, let's strip the double quotes (when present). + * Note that here we are being minimal, limiting ourselves to just removing + * quotes at the start and the end of the string. + * + * Fixes #3819. + * Also, see #3820. + */ + const sessionIDs = sessionCookie.replace(/^"|"$/g, "").split(","); + const sessionInfoPromises = sessionIDs.map(async (id) => { + try { + return await exports.getSessionInfo(id); + } catch (err: any) { + if (err.message === "sessionID does not exist") { + console.debug( + `SessionManager getAuthorID: no session exists with ID ${id}`, + ); + } else { + throw err; + } + } + return undefined; + }); + const now = Math.floor(Date.now() / 1000); + const isMatch = ( + si: { + groupID: string; + validUntil: number; + } | null, + ) => si != null && si.groupID === groupID && now < si.validUntil; + const sessionInfo = await promises.firstSatisfies( + sessionInfoPromises, + isMatch, + ); + if (sessionInfo == null) return undefined; + return sessionInfo.authorID; }; /** @@ -90,9 +97,9 @@ exports.findAuthorID = async (groupID:string, sessionCookie: string) => { * @return {Promise} Resolves to true if the session exists */ exports.doesSessionExist = async (sessionID: string) => { - // check if the database entry of this session exists - const session = await db.get(`session:${sessionID}`); - return (session != null); + // check if the database entry of this session exists + const session = await db.get(`session:${sessionID}`); + return session != null; }; /** @@ -102,60 +109,64 @@ exports.doesSessionExist = async (sessionID: string) => { * @param {Number} validUntil The unix timestamp when the session should expire * @return {Promise<{sessionID: string}>} the id of the new session */ -exports.createSession = async (groupID: string, authorID: string, validUntil: number) => { - // check if the group exists - const groupExists = await groupManager.doesGroupExist(groupID); - if (!groupExists) { - throw new CustomError('groupID does not exist', 'apierror'); - } +exports.createSession = async ( + groupID: string, + authorID: string, + validUntil: number, +) => { + // check if the group exists + const groupExists = await groupManager.doesGroupExist(groupID); + if (!groupExists) { + throw new CustomError("groupID does not exist", "apierror"); + } - // check if the author exists - const authorExists = await authorManager.doesAuthorExist(authorID); - if (!authorExists) { - throw new CustomError('authorID does not exist', 'apierror'); - } + // check if the author exists + const authorExists = await authorManager.doesAuthorExist(authorID); + if (!authorExists) { + throw new CustomError("authorID does not exist", "apierror"); + } - // try to parse validUntil if it's not a number - if (typeof validUntil !== 'number') { - validUntil = parseInt(validUntil); - } + // try to parse validUntil if it's not a number + if (typeof validUntil !== "number") { + validUntil = parseInt(validUntil); + } - // check it's a valid number - if (isNaN(validUntil)) { - throw new CustomError('validUntil is not a number', 'apierror'); - } + // check it's a valid number + if (isNaN(validUntil)) { + throw new CustomError("validUntil is not a number", "apierror"); + } - // ensure this is not a negative number - if (validUntil < 0) { - throw new CustomError('validUntil is a negative number', 'apierror'); - } + // ensure this is not a negative number + if (validUntil < 0) { + throw new CustomError("validUntil is a negative number", "apierror"); + } - // ensure this is not a float value - if (!isInt(validUntil)) { - throw new CustomError('validUntil is a float value', 'apierror'); - } + // ensure this is not a float value + if (!isInt(validUntil)) { + throw new CustomError("validUntil is a float value", "apierror"); + } - // check if validUntil is in the future - if (validUntil < Math.floor(Date.now() / 1000)) { - throw new CustomError('validUntil is in the past', 'apierror'); - } + // check if validUntil is in the future + if (validUntil < Math.floor(Date.now() / 1000)) { + throw new CustomError("validUntil is in the past", "apierror"); + } - // generate sessionID - const sessionID = `s.${randomString(16)}`; + // generate sessionID + const sessionID = `s.${randomString(16)}`; - // set the session into the database - await db.set(`session:${sessionID}`, {groupID, authorID, validUntil}); + // set the session into the database + await db.set(`session:${sessionID}`, { groupID, authorID, validUntil }); - // Add the session ID to the group2sessions and author2sessions records after creating the session - // so that the state is consistent. - await Promise.all([ - // UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object - // property, and writes the result. - db.setSub(`group2sessions:${groupID}`, ['sessionIDs', sessionID], 1), - db.setSub(`author2sessions:${authorID}`, ['sessionIDs', sessionID], 1), - ]); + // Add the session ID to the group2sessions and author2sessions records after creating the session + // so that the state is consistent. + await Promise.all([ + // UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object + // property, and writes the result. + db.setSub(`group2sessions:${groupID}`, ["sessionIDs", sessionID], 1), + db.setSub(`author2sessions:${authorID}`, ["sessionIDs", sessionID], 1), + ]); - return {sessionID}; + return { sessionID }; }; /** @@ -163,17 +174,17 @@ exports.createSession = async (groupID: string, authorID: string, validUntil: nu * @param {String} sessionID The id of the session * @return {Promise} the sessioninfos */ -exports.getSessionInfo = async (sessionID:string) => { - // check if the database entry of this session exists - const session = await db.get(`session:${sessionID}`); +exports.getSessionInfo = async (sessionID: string) => { + // check if the database entry of this session exists + const session = await db.get(`session:${sessionID}`); - if (session == null) { - // session does not exist - throw new CustomError('sessionID does not exist', 'apierror'); - } + if (session == null) { + // session does not exist + throw new CustomError("sessionID does not exist", "apierror"); + } - // everything is fine, return the sessioninfos - return session; + // everything is fine, return the sessioninfos + return session; }; /** @@ -181,28 +192,36 @@ exports.getSessionInfo = async (sessionID:string) => { * @param {String} sessionID The id of the session * @return {Promise} Resolves when the session is deleted */ -exports.deleteSession = async (sessionID:string) => { - // ensure that the session exists - const session = await db.get(`session:${sessionID}`); - if (session == null) { - throw new CustomError('sessionID does not exist', 'apierror'); - } +exports.deleteSession = async (sessionID: string) => { + // ensure that the session exists + const session = await db.get(`session:${sessionID}`); + if (session == null) { + throw new CustomError("sessionID does not exist", "apierror"); + } - // everything is fine, use the sessioninfos - const groupID = session.groupID; - const authorID = session.authorID; + // everything is fine, use the sessioninfos + const groupID = session.groupID; + const authorID = session.authorID; - await Promise.all([ - // UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object - // property, and writes the result. Setting a property to `undefined` deletes that property - // (JSON.stringify() ignores such properties). - db.setSub(`group2sessions:${groupID}`, ['sessionIDs', sessionID], undefined), - db.setSub(`author2sessions:${authorID}`, ['sessionIDs', sessionID], undefined), - ]); + await Promise.all([ + // UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object + // property, and writes the result. Setting a property to `undefined` deletes that property + // (JSON.stringify() ignores such properties). + db.setSub( + `group2sessions:${groupID}`, + ["sessionIDs", sessionID], + undefined, + ), + db.setSub( + `author2sessions:${authorID}`, + ["sessionIDs", sessionID], + undefined, + ), + ]); - // Delete the session record after updating group2sessions and author2sessions so that the state - // is consistent. - await db.remove(`session:${sessionID}`); + // Delete the session record after updating group2sessions and author2sessions so that the state + // is consistent. + await db.remove(`session:${sessionID}`); }; /** @@ -211,14 +230,14 @@ exports.deleteSession = async (sessionID:string) => { * @return {Promise} The sessioninfos of all sessions of this group */ exports.listSessionsOfGroup = async (groupID: string) => { - // check that the group exists - const exists = await groupManager.doesGroupExist(groupID); - if (!exists) { - throw new CustomError('groupID does not exist', 'apierror'); - } + // check that the group exists + const exists = await groupManager.doesGroupExist(groupID); + if (!exists) { + throw new CustomError("groupID does not exist", "apierror"); + } - const sessions = await listSessionsWithDBKey(`group2sessions:${groupID}`); - return sessions; + const sessions = await listSessionsWithDBKey(`group2sessions:${groupID}`); + return sessions; }; /** @@ -227,13 +246,13 @@ exports.listSessionsOfGroup = async (groupID: string) => { * @return {Promise} The sessioninfos of all sessions of this author */ exports.listSessionsOfAuthor = async (authorID: string) => { - // check that the author exists - const exists = await authorManager.doesAuthorExist(authorID); - if (!exists) { - throw new CustomError('authorID does not exist', 'apierror'); - } + // check that the author exists + const exists = await authorManager.doesAuthorExist(authorID); + if (!exists) { + throw new CustomError("authorID does not exist", "apierror"); + } - return await listSessionsWithDBKey(`author2sessions:${authorID}`); + return await listSessionsWithDBKey(`author2sessions:${authorID}`); }; // this function is basically the code listSessionsOfAuthor and listSessionsOfGroup has in common @@ -244,32 +263,32 @@ exports.listSessionsOfAuthor = async (authorID: string) => { * @return {Promise<*>} */ const listSessionsWithDBKey = async (dbkey: string) => { - // get the group2sessions entry - const sessionObject = await db.get(dbkey); - const sessions = sessionObject ? sessionObject.sessionIDs : null; + // get the group2sessions entry + const sessionObject = await db.get(dbkey); + const sessions = sessionObject ? sessionObject.sessionIDs : null; - // iterate through the sessions and get the sessioninfos - for (const sessionID of Object.keys(sessions || {})) { - try { - sessions[sessionID] = await exports.getSessionInfo(sessionID); - } catch (err:any) { - if (err.name === 'apierror') { - console.warn(`Found bad session ${sessionID} in ${dbkey}`); - sessions[sessionID] = null; - } else { - throw err; - } - } - } + // iterate through the sessions and get the sessioninfos + for (const sessionID of Object.keys(sessions || {})) { + try { + sessions[sessionID] = await exports.getSessionInfo(sessionID); + } catch (err: any) { + if (err.name === "apierror") { + console.warn(`Found bad session ${sessionID} in ${dbkey}`); + sessions[sessionID] = null; + } else { + throw err; + } + } + } - return sessions; + return sessions; }; - /** * checks if a number is an int * @param {number|string} value * @return {boolean} If the value is an integer */ // @ts-ignore -const isInt = (value:number|string): boolean => (parseFloat(value) === parseInt(value)) && !isNaN(value); +const isInt = (value: number | string): boolean => + parseFloat(value) === parseInt(value) && !isNaN(value); diff --git a/src/node/db/SessionStore.ts b/src/node/db/SessionStore.ts index 5dca5e201..355918f17 100644 --- a/src/node/db/SessionStore.ts +++ b/src/node/db/SessionStore.ts @@ -1,114 +1,121 @@ -'use strict'; +"use strict"; -const DB = require('./DB'); -const Store = require('express-session').Store; -const log4js = require('log4js'); -const util = require('util'); +const DB = require("./DB"); +const Store = require("express-session").Store; +const log4js = require("log4js"); +const util = require("util"); -const logger = log4js.getLogger('SessionStore'); +const logger = log4js.getLogger("SessionStore"); class SessionStore extends Store { - /** - * @param {?number} [refresh] - How often (in milliseconds) `touch()` will update a session's - * database record with the cookie's latest expiration time. If the difference between the - * value saved in the database and the actual value is greater than this amount, the database - * record will be updated to reflect the actual value. Use this to avoid continual database - * writes caused by express-session's rolling=true feature (see - * https://github.com/expressjs/session#rolling). A good value is high enough to keep query - * rate low but low enough to avoid annoying premature logouts (session invalidation) if - * Etherpad is restarted. Use `null` to prevent `touch()` from ever updating the record. - * Ignored if the cookie does not expire. - */ - constructor(refresh = null) { - super(); - this._refresh = refresh; - // Maps session ID to an object with the following properties: - // - `db`: Session expiration as recorded in the database (ms since epoch, not a Date). - // - `real`: Actual session expiration (ms since epoch, not a Date). Always greater than or - // equal to `db`. - // - `timeout`: Timeout ID for a timeout that will clean up the database record. - this._expirations = new Map(); - } + /** + * @param {?number} [refresh] - How often (in milliseconds) `touch()` will update a session's + * database record with the cookie's latest expiration time. If the difference between the + * value saved in the database and the actual value is greater than this amount, the database + * record will be updated to reflect the actual value. Use this to avoid continual database + * writes caused by express-session's rolling=true feature (see + * https://github.com/expressjs/session#rolling). A good value is high enough to keep query + * rate low but low enough to avoid annoying premature logouts (session invalidation) if + * Etherpad is restarted. Use `null` to prevent `touch()` from ever updating the record. + * Ignored if the cookie does not expire. + */ + constructor(refresh = null) { + super(); + this._refresh = refresh; + // Maps session ID to an object with the following properties: + // - `db`: Session expiration as recorded in the database (ms since epoch, not a Date). + // - `real`: Actual session expiration (ms since epoch, not a Date). Always greater than or + // equal to `db`. + // - `timeout`: Timeout ID for a timeout that will clean up the database record. + this._expirations = new Map(); + } - shutdown() { - for (const {timeout} of this._expirations.values()) clearTimeout(timeout); - } + shutdown() { + for (const { timeout } of this._expirations.values()) clearTimeout(timeout); + } - async _updateExpirations(sid: string, sess: any, updateDbExp = true) { - const exp = this._expirations.get(sid) || {}; - clearTimeout(exp.timeout); - // @ts-ignore - const {cookie: {expires} = {}} = sess || {}; - if (expires) { - const sessExp = new Date(expires).getTime(); - if (updateDbExp) exp.db = sessExp; - exp.real = Math.max(exp.real || 0, exp.db || 0, sessExp); - const now = Date.now(); - if (exp.real <= now) return await this._destroy(sid); - // If reading from the database, update the expiration with the latest value from touch() so - // that touch() appears to write to the database every time even though it doesn't. - if (typeof expires === 'string') sess.cookie.expires = new Date(exp.real).toJSON(); - // Use this._get(), not this._destroy(), to destroy the DB record for the expired session. - // This is done in case multiple Etherpad instances are sharing the same database and users - // are bouncing between the instances. By using this._get(), this instance will query the DB - // for the latest expiration time written by any of the instances, ensuring that the record - // isn't prematurely deleted if the expiration time was updated by a different Etherpad - // instance. (Important caveat: Client-side database caching, which ueberdb does by default, - // could still cause the record to be prematurely deleted because this instance might get a - // stale expiration time from cache.) - exp.timeout = setTimeout(() => this._get(sid), exp.real - now); - this._expirations.set(sid, exp); - } else { - this._expirations.delete(sid); - } - return sess; - } + async _updateExpirations(sid: string, sess: any, updateDbExp = true) { + const exp = this._expirations.get(sid) || {}; + clearTimeout(exp.timeout); + // @ts-ignore + const { + cookie: { expires } = {}, + } = sess || {}; + if (expires) { + const sessExp = new Date(expires).getTime(); + if (updateDbExp) exp.db = sessExp; + exp.real = Math.max(exp.real || 0, exp.db || 0, sessExp); + const now = Date.now(); + if (exp.real <= now) return await this._destroy(sid); + // If reading from the database, update the expiration with the latest value from touch() so + // that touch() appears to write to the database every time even though it doesn't. + if (typeof expires === "string") + sess.cookie.expires = new Date(exp.real).toJSON(); + // Use this._get(), not this._destroy(), to destroy the DB record for the expired session. + // This is done in case multiple Etherpad instances are sharing the same database and users + // are bouncing between the instances. By using this._get(), this instance will query the DB + // for the latest expiration time written by any of the instances, ensuring that the record + // isn't prematurely deleted if the expiration time was updated by a different Etherpad + // instance. (Important caveat: Client-side database caching, which ueberdb does by default, + // could still cause the record to be prematurely deleted because this instance might get a + // stale expiration time from cache.) + exp.timeout = setTimeout(() => this._get(sid), exp.real - now); + this._expirations.set(sid, exp); + } else { + this._expirations.delete(sid); + } + return sess; + } - async _write(sid: string, sess: any) { - await DB.set(`sessionstorage:${sid}`, sess); - } + async _write(sid: string, sess: any) { + await DB.set(`sessionstorage:${sid}`, sess); + } - async _get(sid: string) { - logger.debug(`GET ${sid}`); - const s = await DB.get(`sessionstorage:${sid}`); - return await this._updateExpirations(sid, s); - } + async _get(sid: string) { + logger.debug(`GET ${sid}`); + const s = await DB.get(`sessionstorage:${sid}`); + return await this._updateExpirations(sid, s); + } - async _set(sid: string, sess:any) { - logger.debug(`SET ${sid}`); - sess = await this._updateExpirations(sid, sess); - if (sess != null) await this._write(sid, sess); - } + async _set(sid: string, sess: any) { + logger.debug(`SET ${sid}`); + sess = await this._updateExpirations(sid, sess); + if (sess != null) await this._write(sid, sess); + } - async _destroy(sid:string) { - logger.debug(`DESTROY ${sid}`); - clearTimeout((this._expirations.get(sid) || {}).timeout); - this._expirations.delete(sid); - await DB.remove(`sessionstorage:${sid}`); - } + async _destroy(sid: string) { + logger.debug(`DESTROY ${sid}`); + clearTimeout((this._expirations.get(sid) || {}).timeout); + this._expirations.delete(sid); + await DB.remove(`sessionstorage:${sid}`); + } - // Note: express-session might call touch() before it calls set() for the first time. Ideally this - // would behave like set() in that case but it's OK if it doesn't -- express-session will call - // set() soon enough. - async _touch(sid: string, sess:any) { - logger.debug(`TOUCH ${sid}`); - sess = await this._updateExpirations(sid, sess, false); - if (sess == null) return; // Already expired. - const exp = this._expirations.get(sid); - // If the session doesn't expire, don't do anything. Ideally we would write the session to the - // database if it didn't already exist, but we have no way of knowing that without querying the - // database. The query overhead is not worth it because set() should be called soon anyway. - if (exp == null) return; - if (exp.db != null && (this._refresh == null || exp.real < exp.db + this._refresh)) return; - await this._write(sid, sess); - exp.db = new Date(sess.cookie.expires).getTime(); - } + // Note: express-session might call touch() before it calls set() for the first time. Ideally this + // would behave like set() in that case but it's OK if it doesn't -- express-session will call + // set() soon enough. + async _touch(sid: string, sess: any) { + logger.debug(`TOUCH ${sid}`); + sess = await this._updateExpirations(sid, sess, false); + if (sess == null) return; // Already expired. + const exp = this._expirations.get(sid); + // If the session doesn't expire, don't do anything. Ideally we would write the session to the + // database if it didn't already exist, but we have no way of knowing that without querying the + // database. The query overhead is not worth it because set() should be called soon anyway. + if (exp == null) return; + if ( + exp.db != null && + (this._refresh == null || exp.real < exp.db + this._refresh) + ) + return; + await this._write(sid, sess); + exp.db = new Date(sess.cookie.expires).getTime(); + } } // express-session doesn't support Promise-based methods. This is where the callbackified versions // used by express-session are defined. -for (const m of ['get', 'set', 'destroy', 'touch']) { - SessionStore.prototype[m] = util.callbackify(SessionStore.prototype[`_${m}`]); +for (const m of ["get", "set", "destroy", "touch"]) { + SessionStore.prototype[m] = util.callbackify(SessionStore.prototype[`_${m}`]); } module.exports = SessionStore; diff --git a/src/node/eejs/index.ts b/src/node/eejs/index.ts index b7f2cf998..3e7859305 100644 --- a/src/node/eejs/index.ts +++ b/src/node/eejs/index.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /* * Copyright (c) 2011 RedHog (Egil Möller) * @@ -20,94 +20,106 @@ * require("./index").require("./path/to/template.ejs") */ -const ejs = require('ejs'); -const fs = require('fs'); -const hooks = require('../../static/js/pluginfw/hooks.js'); -const path = require('path'); -const resolve = require('resolve'); -const settings = require('../utils/Settings'); -import {pluginInstallPath} from '../../static/js/pluginfw/installer' +const ejs = require("ejs"); +const fs = require("fs"); +const hooks = require("../../static/js/pluginfw/hooks.js"); +const path = require("path"); +const resolve = require("resolve"); +const settings = require("../utils/Settings"); +import { pluginInstallPath } from "../../static/js/pluginfw/installer"; const templateCache = new Map(); exports.info = { - __output_stack: [], - block_stack: [], - file_stack: [], - args: [], + __output_stack: [], + block_stack: [], + file_stack: [], + args: [], }; -const getCurrentFile = () => exports.info.file_stack[exports.info.file_stack.length - 1]; +const getCurrentFile = () => + exports.info.file_stack[exports.info.file_stack.length - 1]; exports._init = (b: any, recursive: boolean) => { - exports.info.__output_stack.push(exports.info.__output); - exports.info.__output = b; + exports.info.__output_stack.push(exports.info.__output); + exports.info.__output = b; }; -exports._exit = (b:any, recursive:boolean) => { - exports.info.__output = exports.info.__output_stack.pop(); +exports._exit = (b: any, recursive: boolean) => { + exports.info.__output = exports.info.__output_stack.pop(); }; -exports.begin_block = (name:string) => { - exports.info.block_stack.push(name); - exports.info.__output_stack.push(exports.info.__output.get()); - exports.info.__output.set(''); +exports.begin_block = (name: string) => { + exports.info.block_stack.push(name); + exports.info.__output_stack.push(exports.info.__output.get()); + exports.info.__output.set(""); }; exports.end_block = () => { - const name = exports.info.block_stack.pop(); - const renderContext = exports.info.args[exports.info.args.length - 1]; - const content = exports.info.__output.get(); - exports.info.__output.set(exports.info.__output_stack.pop()); - const args = {content, renderContext}; - hooks.callAll(`eejsBlock_${name}`, args); - exports.info.__output.set(exports.info.__output.get().concat(args.content)); + const name = exports.info.block_stack.pop(); + const renderContext = exports.info.args[exports.info.args.length - 1]; + const content = exports.info.__output.get(); + exports.info.__output.set(exports.info.__output_stack.pop()); + const args = { content, renderContext }; + hooks.callAll(`eejsBlock_${name}`, args); + exports.info.__output.set(exports.info.__output.get().concat(args.content)); }; -exports.require = (name:string, args:{ - e?: Function, - require?: Function, -}, mod:{ - filename:string, - paths:string[], -}) => { - if (args == null) args = {}; +exports.require = ( + name: string, + args: { + e?: Function; + require?: Function; + }, + mod: { + filename: string; + paths: string[]; + }, +) => { + if (args == null) args = {}; - let basedir = __dirname; - let paths:string[] = []; + let basedir = __dirname; + let paths: string[] = []; - if (exports.info.file_stack.length) { - basedir = path.dirname(getCurrentFile().path); - } - if (mod) { - basedir = path.dirname(mod.filename); - paths = mod.paths; - } + if (exports.info.file_stack.length) { + basedir = path.dirname(getCurrentFile().path); + } + if (mod) { + basedir = path.dirname(mod.filename); + paths = mod.paths; + } - /** - * Add the plugin install path to the paths array - */ - if (!paths.includes(pluginInstallPath)) { - paths.push(pluginInstallPath) - } + /** + * Add the plugin install path to the paths array + */ + if (!paths.includes(pluginInstallPath)) { + paths.push(pluginInstallPath); + } - const ejspath = resolve.sync(name, {paths, basedir, extensions: ['.html', '.ejs']}); + const ejspath = resolve.sync(name, { + paths, + basedir, + extensions: [".html", ".ejs"], + }); - args.e = exports; - args.require = require; + args.e = exports; + args.require = require; - const cache = settings.maxAge !== 0; - const template = cache && templateCache.get(ejspath) || ejs.compile( - '<% e._init({get: () => __output, set: (s) => { __output = s; }}); %>' + - `${fs.readFileSync(ejspath).toString()}<% e._exit(); %>`, - {filename: ejspath}); - if (cache) templateCache.set(ejspath, template); + const cache = settings.maxAge !== 0; + const template = + (cache && templateCache.get(ejspath)) || + ejs.compile( + "<% e._init({get: () => __output, set: (s) => { __output = s; }}); %>" + + `${fs.readFileSync(ejspath).toString()}<% e._exit(); %>`, + { filename: ejspath }, + ); + if (cache) templateCache.set(ejspath, template); - exports.info.args.push(args); - exports.info.file_stack.push({path: ejspath}); - const res = template(args); - exports.info.file_stack.pop(); - exports.info.args.pop(); + exports.info.args.push(args); + exports.info.file_stack.push({ path: ejspath }); + const res = template(args); + exports.info.file_stack.pop(); + exports.info.args.pop(); - return res; + return res; }; diff --git a/src/node/handler/APIHandler.ts b/src/node/handler/APIHandler.ts index 17346b791..a3c99bd93 100644 --- a/src/node/handler/APIHandler.ts +++ b/src/node/handler/APIHandler.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * The API Handler handles all API http requests */ @@ -19,140 +19,139 @@ * limitations under the License. */ -import {MapArrayType} from "../types/MapType"; +import { MapArrayType } from "../types/MapType"; -const api = require('../db/API'); -const padManager = require('../db/PadManager'); -import createHTTPError from 'http-errors'; -import {Http2ServerRequest, Http2ServerResponse} from "node:http2"; -import {publicKeyExported} from "../security/OAuth2Provider"; -import {jwtVerify} from "jose"; +const api = require("../db/API"); +const padManager = require("../db/PadManager"); +import createHTTPError from "http-errors"; +import { Http2ServerRequest, Http2ServerResponse } from "node:http2"; +import { publicKeyExported } from "../security/OAuth2Provider"; +import { jwtVerify } from "jose"; // a list of all functions -const version:MapArrayType = {}; +const version: MapArrayType = {}; -version['1'] = { - createGroup: [], - createGroupIfNotExistsFor: ['groupMapper'], - deleteGroup: ['groupID'], - listPads: ['groupID'], - createPad: ['padID', 'text'], - createGroupPad: ['groupID', 'padName', 'text'], - createAuthor: ['name'], - createAuthorIfNotExistsFor: ['authorMapper', 'name'], - listPadsOfAuthor: ['authorID'], - createSession: ['groupID', 'authorID', 'validUntil'], - deleteSession: ['sessionID'], - getSessionInfo: ['sessionID'], - listSessionsOfGroup: ['groupID'], - listSessionsOfAuthor: ['authorID'], - getText: ['padID', 'rev'], - setText: ['padID', 'text'], - getHTML: ['padID', 'rev'], - setHTML: ['padID', 'html'], - getRevisionsCount: ['padID'], - getLastEdited: ['padID'], - deletePad: ['padID'], - getReadOnlyID: ['padID'], - setPublicStatus: ['padID', 'publicStatus'], - getPublicStatus: ['padID'], - listAuthorsOfPad: ['padID'], - padUsersCount: ['padID'], +version["1"] = { + createGroup: [], + createGroupIfNotExistsFor: ["groupMapper"], + deleteGroup: ["groupID"], + listPads: ["groupID"], + createPad: ["padID", "text"], + createGroupPad: ["groupID", "padName", "text"], + createAuthor: ["name"], + createAuthorIfNotExistsFor: ["authorMapper", "name"], + listPadsOfAuthor: ["authorID"], + createSession: ["groupID", "authorID", "validUntil"], + deleteSession: ["sessionID"], + getSessionInfo: ["sessionID"], + listSessionsOfGroup: ["groupID"], + listSessionsOfAuthor: ["authorID"], + getText: ["padID", "rev"], + setText: ["padID", "text"], + getHTML: ["padID", "rev"], + setHTML: ["padID", "html"], + getRevisionsCount: ["padID"], + getLastEdited: ["padID"], + deletePad: ["padID"], + getReadOnlyID: ["padID"], + setPublicStatus: ["padID", "publicStatus"], + getPublicStatus: ["padID"], + listAuthorsOfPad: ["padID"], + padUsersCount: ["padID"], }; -version['1.1'] = { - ...version['1'], - getAuthorName: ['authorID'], - padUsers: ['padID'], - sendClientsMessage: ['padID', 'msg'], - listAllGroups: [], +version["1.1"] = { + ...version["1"], + getAuthorName: ["authorID"], + padUsers: ["padID"], + sendClientsMessage: ["padID", "msg"], + listAllGroups: [], }; -version['1.2'] = { - ...version['1.1'], - checkToken: [], +version["1.2"] = { + ...version["1.1"], + checkToken: [], }; -version['1.2.1'] = { - ...version['1.2'], - listAllPads: [], +version["1.2.1"] = { + ...version["1.2"], + listAllPads: [], }; -version['1.2.7'] = { - ...version['1.2.1'], - createDiffHTML: ['padID', 'startRev', 'endRev'], - getChatHistory: ['padID', 'start', 'end'], - getChatHead: ['padID'], +version["1.2.7"] = { + ...version["1.2.1"], + createDiffHTML: ["padID", "startRev", "endRev"], + getChatHistory: ["padID", "start", "end"], + getChatHead: ["padID"], }; -version['1.2.8'] = { - ...version['1.2.7'], - getAttributePool: ['padID'], - getRevisionChangeset: ['padID', 'rev'], +version["1.2.8"] = { + ...version["1.2.7"], + getAttributePool: ["padID"], + getRevisionChangeset: ["padID", "rev"], }; -version['1.2.9'] = { - ...version['1.2.8'], - copyPad: ['sourceID', 'destinationID', 'force'], - movePad: ['sourceID', 'destinationID', 'force'], +version["1.2.9"] = { + ...version["1.2.8"], + copyPad: ["sourceID", "destinationID", "force"], + movePad: ["sourceID", "destinationID", "force"], }; -version['1.2.10'] = { - ...version['1.2.9'], - getPadID: ['roID'], +version["1.2.10"] = { + ...version["1.2.9"], + getPadID: ["roID"], }; -version['1.2.11'] = { - ...version['1.2.10'], - getSavedRevisionsCount: ['padID'], - listSavedRevisions: ['padID'], - saveRevision: ['padID', 'rev'], - restoreRevision: ['padID', 'rev'], +version["1.2.11"] = { + ...version["1.2.10"], + getSavedRevisionsCount: ["padID"], + listSavedRevisions: ["padID"], + saveRevision: ["padID", "rev"], + restoreRevision: ["padID", "rev"], }; -version['1.2.12'] = { - ...version['1.2.11'], - appendChatMessage: ['padID', 'text', 'authorID', 'time'], +version["1.2.12"] = { + ...version["1.2.11"], + appendChatMessage: ["padID", "text", "authorID", "time"], }; -version['1.2.13'] = { - ...version['1.2.12'], - appendText: ['padID', 'text'], +version["1.2.13"] = { + ...version["1.2.12"], + appendText: ["padID", "text"], }; -version['1.2.14'] = { - ...version['1.2.13'], - getStats: [], +version["1.2.14"] = { + ...version["1.2.13"], + getStats: [], }; -version['1.2.15'] = { - ...version['1.2.14'], - copyPadWithoutHistory: ['sourceID', 'destinationID', 'force'], +version["1.2.15"] = { + ...version["1.2.14"], + copyPadWithoutHistory: ["sourceID", "destinationID", "force"], }; -version['1.3.0'] = { - ...version['1.2.15'], - appendText: ['padID', 'text', 'authorId'], - copyPadWithoutHistory: ['sourceID', 'destinationID', 'force', 'authorId'], - createGroupPad: ['groupID', 'padName', 'text', 'authorId'], - createPad: ['padID', 'text', 'authorId'], - restoreRevision: ['padID', 'rev', 'authorId'], - setHTML: ['padID', 'html', 'authorId'], - setText: ['padID', 'text', 'authorId'], +version["1.3.0"] = { + ...version["1.2.15"], + appendText: ["padID", "text", "authorId"], + copyPadWithoutHistory: ["sourceID", "destinationID", "force", "authorId"], + createGroupPad: ["groupID", "padName", "text", "authorId"], + createPad: ["padID", "text", "authorId"], + restoreRevision: ["padID", "rev", "authorId"], + setHTML: ["padID", "html", "authorId"], + setText: ["padID", "text", "authorId"], }; // set the latest available API version here -exports.latestApiVersion = '1.3.0'; +exports.latestApiVersion = "1.3.0"; // exports the versions so it can be used by the new Swagger endpoint exports.version = version; - type APIFields = { - api_key: string; - padID: string; - padName: string; -} + api_key: string; + padID: string; + padName: string; +}; /** * Handles an HTTP API call @@ -162,46 +161,54 @@ type APIFields = { * @param req express request object * @param res express response object */ -exports.handle = async function (apiVersion: string, functionName: string, fields: APIFields, req: Http2ServerRequest, res: Http2ServerResponse) { - // say goodbye if this is an unknown API version - if (!(apiVersion in version)) { - throw new createHTTPError.NotFound('no such api version'); - } +exports.handle = async function ( + apiVersion: string, + functionName: string, + fields: APIFields, + req: Http2ServerRequest, + res: Http2ServerResponse, +) { + // say goodbye if this is an unknown API version + if (!(apiVersion in version)) { + throw new createHTTPError.NotFound("no such api version"); + } - // say goodbye if this is an unknown function - if (!(functionName in version[apiVersion])) { - throw new createHTTPError.NotFound('no such function'); - } + // say goodbye if this is an unknown function + if (!(functionName in version[apiVersion])) { + throw new createHTTPError.NotFound("no such function"); + } - if(!req.headers.authorization) { - throw new createHTTPError.Unauthorized('no or wrong API Key'); - } + if (!req.headers.authorization) { + throw new createHTTPError.Unauthorized("no or wrong API Key"); + } - try { - await jwtVerify(req.headers.authorization!.replace("Bearer ", ""), publicKeyExported!, {algorithms: ['RS256'], - requiredClaims: ["admin"]}) + try { + await jwtVerify( + req.headers.authorization!.replace("Bearer ", ""), + publicKeyExported!, + { algorithms: ["RS256"], requiredClaims: ["admin"] }, + ); + } catch (e) { + throw new createHTTPError.Unauthorized("no or wrong API Key"); + } - } catch (e) { - throw new createHTTPError.Unauthorized('no or wrong API Key'); - } + // sanitize any padIDs before continuing + if (fields.padID) { + fields.padID = await padManager.sanitizePadId(fields.padID); + } + // there was an 'else' here before - removed it to ensure + // that this sanitize step can't be circumvented by forcing + // the first branch to be taken + if (fields.padName) { + fields.padName = await padManager.sanitizePadId(fields.padName); + } + // put the function parameters in an array + // @ts-ignore + const functionParams = version[apiVersion][functionName].map( + (field) => fields[field], + ); - - // sanitize any padIDs before continuing - if (fields.padID) { - fields.padID = await padManager.sanitizePadId(fields.padID); - } - // there was an 'else' here before - removed it to ensure - // that this sanitize step can't be circumvented by forcing - // the first branch to be taken - if (fields.padName) { - fields.padName = await padManager.sanitizePadId(fields.padName); - } - - // put the function parameters in an array - // @ts-ignore - const functionParams = version[apiVersion][functionName].map((field) => fields[field]); - - // call the api function - return api[functionName].apply(this, functionParams); + // call the api function + return api[functionName].apply(this, functionParams); }; diff --git a/src/node/handler/ExportHandler.ts b/src/node/handler/ExportHandler.ts index 0bf57e2d1..84fbdd6a6 100644 --- a/src/node/handler/ExportHandler.ts +++ b/src/node/handler/ExportHandler.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * Handles the export requests */ @@ -20,15 +20,15 @@ * limitations under the License. */ -const exporthtml = require('../utils/ExportHtml'); -const exporttxt = require('../utils/ExportTxt'); -const exportEtherpad = require('../utils/ExportEtherpad'); -import fs from 'fs'; -const settings = require('../utils/Settings'); -import os from 'os'; -const hooks = require('../../static/js/pluginfw/hooks'); -import util from 'util'; -const { checkValidRev } = require('../utils/checkValidRev'); +const exporthtml = require("../utils/ExportHtml"); +const exporttxt = require("../utils/ExportTxt"); +const exportEtherpad = require("../utils/ExportEtherpad"); +import fs from "fs"; +const settings = require("../utils/Settings"); +import os from "os"; +const hooks = require("../../static/js/pluginfw/hooks"); +import util from "util"; +const { checkValidRev } = require("../utils/checkValidRev"); const fsp_writeFile = util.promisify(fs.writeFile); const fsp_unlink = util.promisify(fs.unlink); @@ -43,84 +43,101 @@ const tempDirectory = os.tmpdir(); * @param {String} readOnlyId the read only id of the pad to export * @param {String} type the type to export */ -exports.doExport = async (req: any, res: any, padId: string, readOnlyId: string, type:string) => { - // avoid naming the read-only file as the original pad's id - let fileName = readOnlyId ? readOnlyId : padId; +exports.doExport = async ( + req: any, + res: any, + padId: string, + readOnlyId: string, + type: string, +) => { + // avoid naming the read-only file as the original pad's id + let fileName = readOnlyId ? readOnlyId : padId; - // allow fileName to be overwritten by a hook, the type type is kept static for security reasons - const hookFileName = await hooks.aCallFirst('exportFileName', padId); + // allow fileName to be overwritten by a hook, the type type is kept static for security reasons + const hookFileName = await hooks.aCallFirst("exportFileName", padId); - // if fileName is set then set it to the padId, note that fileName is returned as an array. - if (hookFileName.length) { - fileName = hookFileName; - } + // if fileName is set then set it to the padId, note that fileName is returned as an array. + if (hookFileName.length) { + fileName = hookFileName; + } - // tell the browser that this is a downloadable file - res.attachment(`${fileName}.${type}`); + // tell the browser that this is a downloadable file + res.attachment(`${fileName}.${type}`); - if (req.params.rev !== undefined) { - // ensure revision is a number - // modify req, as we use it in a later call to exportConvert - req.params.rev = checkValidRev(req.params.rev); - } + if (req.params.rev !== undefined) { + // ensure revision is a number + // modify req, as we use it in a later call to exportConvert + req.params.rev = checkValidRev(req.params.rev); + } - // if this is a plain text export, we can do this directly - // We have to over engineer this because tabs are stored as attributes and not plain text - if (type === 'etherpad') { - const pad = await exportEtherpad.getPadRaw(padId, readOnlyId); - res.send(pad); - } else if (type === 'txt') { - const txt = await exporttxt.getPadTXTDocument(padId, req.params.rev); - res.send(txt); - } else { - // render the html document - let html = await exporthtml.getPadHTMLDocument(padId, req.params.rev, readOnlyId); + // if this is a plain text export, we can do this directly + // We have to over engineer this because tabs are stored as attributes and not plain text + if (type === "etherpad") { + const pad = await exportEtherpad.getPadRaw(padId, readOnlyId); + res.send(pad); + } else if (type === "txt") { + const txt = await exporttxt.getPadTXTDocument(padId, req.params.rev); + res.send(txt); + } else { + // render the html document + let html = await exporthtml.getPadHTMLDocument( + padId, + req.params.rev, + readOnlyId, + ); - // decide what to do with the html export + // decide what to do with the html export - // if this is a html export, we can send this from here directly - if (type === 'html') { - // do any final changes the plugin might want to make - const newHTML = await hooks.aCallFirst('exportHTMLSend', html); - if (newHTML.length) html = newHTML; - res.send(html); - return; - } + // if this is a html export, we can send this from here directly + if (type === "html") { + // do any final changes the plugin might want to make + const newHTML = await hooks.aCallFirst("exportHTMLSend", html); + if (newHTML.length) html = newHTML; + res.send(html); + return; + } - // else write the html export to a file - const randNum = Math.floor(Math.random() * 0xFFFFFFFF); - const srcFile = `${tempDirectory}/etherpad_export_${randNum}.html`; - await fsp_writeFile(srcFile, html); + // else write the html export to a file + const randNum = Math.floor(Math.random() * 0xffffffff); + const srcFile = `${tempDirectory}/etherpad_export_${randNum}.html`; + await fsp_writeFile(srcFile, html); - // ensure html can be collected by the garbage collector - html = null; + // ensure html can be collected by the garbage collector + html = null; - // send the convert job to the converter (abiword, libreoffice, ..) - const destFile = `${tempDirectory}/etherpad_export_${randNum}.${type}`; + // send the convert job to the converter (abiword, libreoffice, ..) + const destFile = `${tempDirectory}/etherpad_export_${randNum}.${type}`; - // Allow plugins to overwrite the convert in export process - const result = await hooks.aCallAll('exportConvert', {srcFile, destFile, req, res}); - if (result.length > 0) { - // console.log("export handled by plugin", destFile); - } else { - const converter = - settings.soffice != null ? require('../utils/LibreOffice') - : settings.abiword != null ? require('../utils/Abiword') - : null; - await converter.convertFile(srcFile, destFile, type); - } + // Allow plugins to overwrite the convert in export process + const result = await hooks.aCallAll("exportConvert", { + srcFile, + destFile, + req, + res, + }); + if (result.length > 0) { + // console.log("export handled by plugin", destFile); + } else { + const converter = + settings.soffice != null + ? require("../utils/LibreOffice") + : settings.abiword != null + ? require("../utils/Abiword") + : null; + await converter.convertFile(srcFile, destFile, type); + } - // send the file - await res.sendFile(destFile, null); + // send the file + await res.sendFile(destFile, null); - // clean up temporary files - await fsp_unlink(srcFile); + // clean up temporary files + await fsp_unlink(srcFile); - // 100ms delay to accommodate for slow windows fs - if (os.type().indexOf('Windows') > -1) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } + // 100ms delay to accommodate for slow windows fs + if (os.type().indexOf("Windows") > -1) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } - await fsp_unlink(destFile); - } + await fsp_unlink(destFile); + } }; diff --git a/src/node/handler/ImportHandler.ts b/src/node/handler/ImportHandler.ts index 330a2d6f9..f95935b0b 100644 --- a/src/node/handler/ImportHandler.ts +++ b/src/node/handler/ImportHandler.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * Handles the import requests */ @@ -21,53 +21,53 @@ * limitations under the License. */ -const padManager = require('../db/PadManager'); -const padMessageHandler = require('./PadMessageHandler'); -import {promises as fs} from 'fs'; -import path from 'path'; -const settings = require('../utils/Settings'); -const {Formidable} = require('formidable'); -import os from 'os'; -const importHtml = require('../utils/ImportHtml'); -const importEtherpad = require('../utils/ImportEtherpad'); -import log4js from 'log4js'; -const hooks = require('../../static/js/pluginfw/hooks.js'); +const padManager = require("../db/PadManager"); +const padMessageHandler = require("./PadMessageHandler"); +import { promises as fs } from "fs"; +import path from "path"; +const settings = require("../utils/Settings"); +const { Formidable } = require("formidable"); +import os from "os"; +const importHtml = require("../utils/ImportHtml"); +const importEtherpad = require("../utils/ImportEtherpad"); +import log4js from "log4js"; +const hooks = require("../../static/js/pluginfw/hooks.js"); -const logger = log4js.getLogger('ImportHandler'); +const logger = log4js.getLogger("ImportHandler"); // `status` must be a string supported by `importErrorMessage()` in `src/static/js/pad_impexp.js`. class ImportError extends Error { - status: string; - constructor(status: string, ...args:any) { - super(...args); - if (Error.captureStackTrace) Error.captureStackTrace(this, ImportError); - this.name = 'ImportError'; - this.status = status; - const msg = this.message == null ? '' : String(this.message); - if (status !== '') this.message = msg === '' ? status : `${status}: ${msg}`; - } + status: string; + constructor(status: string, ...args: any) { + super(...args); + if (Error.captureStackTrace) Error.captureStackTrace(this, ImportError); + this.name = "ImportError"; + this.status = status; + const msg = this.message == null ? "" : String(this.message); + if (status !== "") this.message = msg === "" ? status : `${status}: ${msg}`; + } } const rm = async (path: string) => { - try { - await fs.unlink(path); - } catch (err:any) { - if (err.code !== 'ENOENT') throw err; - } + try { + await fs.unlink(path); + } catch (err: any) { + if (err.code !== "ENOENT") throw err; + } }; -let converter:any = null; -let exportExtension = 'htm'; +let converter: any = null; +let exportExtension = "htm"; // load abiword only if it is enabled and if soffice is disabled if (settings.abiword != null && settings.soffice == null) { - converter = require('../utils/Abiword'); + converter = require("../utils/Abiword"); } // load soffice only if it is enabled if (settings.soffice != null) { - converter = require('../utils/LibreOffice'); - exportExtension = 'html'; + converter = require("../utils/LibreOffice"); + exportExtension = "html"; } const tmpDirectory = os.tmpdir(); @@ -79,163 +79,193 @@ const tmpDirectory = os.tmpdir(); * @param {String} padId the pad id to export * @param {String} authorId the author id to use for the import */ -const doImport = async (req:any, res:any, padId:string, authorId:string) => { - // pipe to a file - // convert file to html via abiword or soffice - // set html in the pad - const randNum = Math.floor(Math.random() * 0xFFFFFFFF); +const doImport = async ( + req: any, + res: any, + padId: string, + authorId: string, +) => { + // pipe to a file + // convert file to html via abiword or soffice + // set html in the pad + const randNum = Math.floor(Math.random() * 0xffffffff); - // setting flag for whether to use converter or not - let useConverter = (converter != null); + // setting flag for whether to use converter or not + let useConverter = converter != null; - const form = new Formidable({ - keepExtensions: true, - uploadDir: tmpDirectory, - maxFileSize: settings.importMaxFileSize, - }); + const form = new Formidable({ + keepExtensions: true, + uploadDir: tmpDirectory, + maxFileSize: settings.importMaxFileSize, + }); - let srcFile; - let files; - let fields; - try { - [fields, files] = await form.parse(req); - } catch (err:any) { - logger.warn(`Import failed due to form error: ${err.stack || err}`); - if (err.code === Formidable.formidableErrors.biggerThanMaxFileSize) { - throw new ImportError('maxFileSize'); - } - throw new ImportError('uploadFailed'); - } - if (!files.file) { - logger.warn('Import failed because form had no file'); - throw new ImportError('uploadFailed'); - } else { - srcFile = files.file[0].filepath; - } + let srcFile; + let files; + let fields; + try { + [fields, files] = await form.parse(req); + } catch (err: any) { + logger.warn(`Import failed due to form error: ${err.stack || err}`); + if (err.code === Formidable.formidableErrors.biggerThanMaxFileSize) { + throw new ImportError("maxFileSize"); + } + throw new ImportError("uploadFailed"); + } + if (!files.file) { + logger.warn("Import failed because form had no file"); + throw new ImportError("uploadFailed"); + } else { + srcFile = files.file[0].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 - const fileEnding = path.extname(files.file[0].originalFilename).toLowerCase(); - const knownFileEndings = - ['.txt', '.doc', '.docx', '.pdf', '.odt', '.html', '.htm', '.etherpad', '.rtf']; - const fileEndingUnknown = (knownFileEndings.indexOf(fileEnding) < 0); + // 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 + const fileEnding = path.extname(files.file[0].originalFilename).toLowerCase(); + const knownFileEndings = [ + ".txt", + ".doc", + ".docx", + ".pdf", + ".odt", + ".html", + ".htm", + ".etherpad", + ".rtf", + ]; + const fileEndingUnknown = knownFileEndings.indexOf(fileEnding) < 0; - if (fileEndingUnknown) { - // the file ending is not known + if (fileEndingUnknown) { + // the file ending is not known - if (settings.allowUnknownFileEnds === true) { - // we need to rename this file with a .txt ending - const oldSrcFile = srcFile; + if (settings.allowUnknownFileEnds === true) { + // we need to rename this file with a .txt ending + const oldSrcFile = srcFile; - srcFile = path.join(path.dirname(srcFile), `${path.basename(srcFile, fileEnding)}.txt`); - await fs.rename(oldSrcFile, srcFile); - } else { - logger.warn(`Not allowing unknown file type to be imported: ${fileEnding}`); - throw new ImportError('uploadFailed'); - } - } + srcFile = path.join( + path.dirname(srcFile), + `${path.basename(srcFile, fileEnding)}.txt`, + ); + await fs.rename(oldSrcFile, srcFile); + } else { + logger.warn( + `Not allowing unknown file type to be imported: ${fileEnding}`, + ); + throw new ImportError("uploadFailed"); + } + } - const destFile = path.join(tmpDirectory, `etherpad_import_${randNum}.${exportExtension}`); - const context = {srcFile, destFile, fileEnding, padId, ImportError}; - const importHandledByPlugin = (await hooks.aCallAll('import', context)).some((x:string) => x); - const fileIsEtherpad = (fileEnding === '.etherpad'); - const fileIsHTML = (fileEnding === '.html' || fileEnding === '.htm'); - const fileIsTXT = (fileEnding === '.txt'); + const destFile = path.join( + tmpDirectory, + `etherpad_import_${randNum}.${exportExtension}`, + ); + const context = { srcFile, destFile, fileEnding, padId, ImportError }; + const importHandledByPlugin = (await hooks.aCallAll("import", context)).some( + (x: string) => x, + ); + const fileIsEtherpad = fileEnding === ".etherpad"; + const fileIsHTML = fileEnding === ".html" || fileEnding === ".htm"; + const fileIsTXT = fileEnding === ".txt"; - let directDatabaseAccess = false; - if (fileIsEtherpad) { - // Use '\n' to avoid the default pad text if the pad doesn't yet exist. - const pad = await padManager.getPad(padId, '\n', authorId); - const headCount = pad.head; - if (headCount >= 10) { - logger.warn('Aborting direct database import attempt of a pad that already has content'); - throw new ImportError('padHasData'); - } - const text = await fs.readFile(srcFile, 'utf8'); - directDatabaseAccess = true; - await importEtherpad.setPadRaw(padId, text, authorId); - } + let directDatabaseAccess = false; + if (fileIsEtherpad) { + // Use '\n' to avoid the default pad text if the pad doesn't yet exist. + const pad = await padManager.getPad(padId, "\n", authorId); + const headCount = pad.head; + if (headCount >= 10) { + logger.warn( + "Aborting direct database import attempt of a pad that already has content", + ); + throw new ImportError("padHasData"); + } + const text = await fs.readFile(srcFile, "utf8"); + directDatabaseAccess = true; + await importEtherpad.setPadRaw(padId, text, authorId); + } - // convert file to html if necessary - if (!importHandledByPlugin && !directDatabaseAccess) { - if (fileIsTXT) { - // Don't use converter for text files - useConverter = false; - } + // convert file to html if necessary + if (!importHandledByPlugin && !directDatabaseAccess) { + if (fileIsTXT) { + // Don't use converter for text files + useConverter = false; + } - // See https://github.com/ether/etherpad-lite/issues/2572 - if (fileIsHTML || !useConverter) { - // if no converter only rename - await fs.rename(srcFile, destFile); - } else { - try { - await converter.convertFile(srcFile, destFile, exportExtension); - } catch (err:any) { - logger.warn(`Converting Error: ${err.stack || err}`); - throw new ImportError('convertFailed'); - } - } - } + // See https://github.com/ether/etherpad-lite/issues/2572 + if (fileIsHTML || !useConverter) { + // if no converter only rename + await fs.rename(srcFile, destFile); + } else { + try { + await converter.convertFile(srcFile, destFile, exportExtension); + } catch (err: any) { + logger.warn(`Converting Error: ${err.stack || err}`); + throw new ImportError("convertFailed"); + } + } + } - if (!useConverter && !directDatabaseAccess) { - // Read the file with no encoding for raw buffer access. - const buf = await fs.readFile(destFile); + if (!useConverter && !directDatabaseAccess) { + // Read the file with no encoding for raw buffer access. + const buf = await fs.readFile(destFile); - // Check if there are only ascii chars in the uploaded file - const isAscii = !Array.prototype.some.call(buf, (c) => (c > 240)); + // Check if there are only ascii chars in the uploaded file + const isAscii = !Array.prototype.some.call(buf, (c) => c > 240); - if (!isAscii) { - logger.warn('Attempt to import non-ASCII file'); - throw new ImportError('uploadFailed'); - } - } + if (!isAscii) { + logger.warn("Attempt to import non-ASCII file"); + throw new ImportError("uploadFailed"); + } + } - // Use '\n' to avoid the default pad text if the pad doesn't yet exist. - let pad = await padManager.getPad(padId, '\n', authorId); + // Use '\n' to avoid the default pad text if the pad doesn't yet exist. + let pad = await padManager.getPad(padId, "\n", authorId); - // read the text - let text; + // read the text + let text; - if (!directDatabaseAccess) { - text = await fs.readFile(destFile, 'utf8'); + if (!directDatabaseAccess) { + text = await fs.readFile(destFile, "utf8"); - // node on windows has a delay on releasing of the file lock. - // We add a 100ms delay to work around this - if (os.type().indexOf('Windows') > -1) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } + // node on windows has a delay on releasing of the file lock. + // We add a 100ms delay to work around this + if (os.type().indexOf("Windows") > -1) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } - // change text of the pad and broadcast the changeset - if (!directDatabaseAccess) { - if (importHandledByPlugin || useConverter || fileIsHTML) { - try { - await importHtml.setPadHTML(pad, text, authorId); - } catch (err:any) { - logger.warn(`Error importing, possibly caused by malformed HTML: ${err.stack || err}`); - } - } else { - await pad.setText(text, authorId); - } - } + // change text of the pad and broadcast the changeset + if (!directDatabaseAccess) { + if (importHandledByPlugin || useConverter || fileIsHTML) { + try { + await importHtml.setPadHTML(pad, text, authorId); + } catch (err: any) { + logger.warn( + `Error importing, possibly caused by malformed HTML: ${ + err.stack || err + }`, + ); + } + } else { + await pad.setText(text, authorId); + } + } - // Load the Pad into memory then broadcast updates to all clients - padManager.unloadPad(padId); - pad = await padManager.getPad(padId, '\n', authorId); - padManager.unloadPad(padId); + // Load the Pad into memory then broadcast updates to all clients + padManager.unloadPad(padId); + pad = await padManager.getPad(padId, "\n", authorId); + padManager.unloadPad(padId); - // Direct database access means a pad user should reload the pad and not attempt to receive - // updated pad data. - if (directDatabaseAccess) return true; + // Direct database access means a pad user should reload the pad and not attempt to receive + // updated pad data. + if (directDatabaseAccess) return true; - // tell clients to update - await padMessageHandler.updatePadClients(pad); + // tell clients to update + await padMessageHandler.updatePadClients(pad); - // clean up temporary files - rm(srcFile); - rm(destFile); + // clean up temporary files + rm(srcFile); + rm(destFile); - return false; + return false; }; /** @@ -246,19 +276,27 @@ const doImport = async (req:any, res:any, padId:string, authorId:string) => { * @param {String} authorId the author id to use for the import * @return {Promise} a promise */ -exports.doImport = async (req:any, res:any, padId:string, authorId:string = '') => { - let httpStatus = 200; - let code = 0; - let message = 'ok'; - let directDatabaseAccess; - try { - directDatabaseAccess = await doImport(req, res, padId, authorId); - } catch (err:any) { - const known = err instanceof ImportError && err.status; - if (!known) logger.error(`Internal error during import: ${err.stack || err}`); - httpStatus = known ? 400 : 500; - code = known ? 1 : 2; - message = known ? err.status : 'internalError'; - } - res.status(httpStatus).json({code, message, data: {directDatabaseAccess}}); +exports.doImport = async ( + req: any, + res: any, + padId: string, + authorId: string = "", +) => { + let httpStatus = 200; + let code = 0; + let message = "ok"; + let directDatabaseAccess; + try { + directDatabaseAccess = await doImport(req, res, padId, authorId); + } catch (err: any) { + const known = err instanceof ImportError && err.status; + if (!known) + logger.error(`Internal error during import: ${err.stack || err}`); + httpStatus = known ? 400 : 500; + code = known ? 1 : 2; + message = known ? err.status : "internalError"; + } + res + .status(httpStatus) + .json({ code, message, data: { directDatabaseAccess } }); }; diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index fc454ee14..724009e77 100644 --- a/src/node/handler/PadMessageHandler.ts +++ b/src/node/handler/PadMessageHandler.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * The MessageHandler handles all Messages that comes from Socket.IO and controls the sessions */ @@ -19,52 +19,57 @@ * limitations under the License. */ -import {MapArrayType} from "../types/MapType"; +import { MapArrayType } from "../types/MapType"; -const AttributeMap = require('../../static/js/AttributeMap'); -const padManager = require('../db/PadManager'); -const Changeset = require('../../static/js/Changeset'); -const ChatMessage = require('../../static/js/ChatMessage'); -const AttributePool = require('../../static/js/AttributePool'); -const AttributeManager = require('../../static/js/AttributeManager'); -const authorManager = require('../db/AuthorManager'); -const {padutils} = require('../../static/js/pad_utils'); -const readOnlyManager = require('../db/ReadOnlyManager'); -const settings = require('../utils/Settings'); -const securityManager = require('../db/SecurityManager'); -const plugins = require('../../static/js/pluginfw/plugin_defs.js'); -import log4js from 'log4js'; -const messageLogger = log4js.getLogger('message'); -const accessLogger = log4js.getLogger('access'); -const hooks = require('../../static/js/pluginfw/hooks.js'); -const stats = require('../stats') -const assert = require('assert').strict; -import {RateLimiterMemory} from 'rate-limiter-flexible'; -import {ChangesetRequest, PadUserInfo, SocketClientRequest} from "../types/SocketClientRequest"; -import {APool, AText, PadAuthor, PadType} from "../types/PadType"; -import {ChangeSet} from "../types/ChangeSet"; -const webaccess = require('../hooks/express/webaccess'); -const { checkValidRev } = require('../utils/checkValidRev'); +const AttributeMap = require("../../static/js/AttributeMap"); +const padManager = require("../db/PadManager"); +const Changeset = require("../../static/js/Changeset"); +const ChatMessage = require("../../static/js/ChatMessage"); +const AttributePool = require("../../static/js/AttributePool"); +const AttributeManager = require("../../static/js/AttributeManager"); +const authorManager = require("../db/AuthorManager"); +const { padutils } = require("../../static/js/pad_utils"); +const readOnlyManager = require("../db/ReadOnlyManager"); +const settings = require("../utils/Settings"); +const securityManager = require("../db/SecurityManager"); +const plugins = require("../../static/js/pluginfw/plugin_defs.js"); +import log4js from "log4js"; +const messageLogger = log4js.getLogger("message"); +const accessLogger = log4js.getLogger("access"); +const hooks = require("../../static/js/pluginfw/hooks.js"); +const stats = require("../stats"); +const assert = require("assert").strict; +import { RateLimiterMemory } from "rate-limiter-flexible"; +import { + ChangesetRequest, + PadUserInfo, + SocketClientRequest, +} from "../types/SocketClientRequest"; +import { APool, AText, PadAuthor, PadType } from "../types/PadType"; +import { ChangeSet } from "../types/ChangeSet"; +const webaccess = require("../hooks/express/webaccess"); +const { checkValidRev } = require("../utils/checkValidRev"); -let rateLimiter:any; +let rateLimiter: any; let socketio: any = null; -hooks.deprecationNotices.clientReady = 'use the userJoin hook instead'; +hooks.deprecationNotices.clientReady = "use the userJoin hook instead"; -const addContextToError = (err:any, pfx:string) => { - const newErr = new Error(`${pfx}${err.message}`, {cause: err}); - if (Error.captureStackTrace) Error.captureStackTrace(newErr, addContextToError); - // Check for https://github.com/tc39/proposal-error-cause support, available in Node.js >= v16.10. - if (newErr.cause === err) return newErr; - err.message = `${pfx}${err.message}`; - return err; +const addContextToError = (err: any, pfx: string) => { + const newErr = new Error(`${pfx}${err.message}`, { cause: err }); + if (Error.captureStackTrace) + Error.captureStackTrace(newErr, addContextToError); + // Check for https://github.com/tc39/proposal-error-cause support, available in Node.js >= v16.10. + if (newErr.cause === err) return newErr; + err.message = `${pfx}${err.message}`; + return err; }; exports.socketio = () => { - // The rate limiter is created in this hook so that restarting the server resets the limiter. The - // settings.commitRateLimiting object is passed directly to the rate limiter so that the limits - // can be dynamically changed during runtime by modifying its properties. - rateLimiter = new RateLimiterMemory(settings.commitRateLimiting); + // The rate limiter is created in this hook so that restarting the server resets the limiter. The + // settings.commitRateLimiting object is passed directly to the rate limiter so that the limits + // can be dynamically changed during runtime by modifying its properties. + rateLimiter = new RateLimiterMemory(settings.commitRateLimiting); }; /** @@ -85,128 +90,135 @@ exports.socketio = () => { * - readonly: Whether the client has read-only access (true) or read/write access (false). * - rev: The last revision that was sent to the client. */ -const sessioninfos:MapArrayType = {}; +const sessioninfos: MapArrayType = {}; exports.sessioninfos = sessioninfos; -stats.gauge('totalUsers', () => socketio ? socketio.sockets.size : 0); -stats.gauge('activePads', () => { - const padIds = new Set(); - for (const {padId} of Object.values(sessioninfos)) { - if (!padId) continue; - padIds.add(padId); - } - return padIds.size; +stats.gauge("totalUsers", () => (socketio ? socketio.sockets.size : 0)); +stats.gauge("activePads", () => { + const padIds = new Set(); + for (const { padId } of Object.values(sessioninfos)) { + if (!padId) continue; + padIds.add(padId); + } + return padIds.size; }); /** * Processes one task at a time per channel. */ class Channels { - private readonly _exec: (ch:any, task:any) => any; - private _promiseChains: Map>; - /** - * @param {(ch, task) => any} [exec] - Task executor. If omitted, tasks are assumed to be - * functions that will be executed with the channel as the only argument. - */ - constructor(exec = (ch: string, task:any) => task(ch)) { - this._exec = exec; - this._promiseChains = new Map(); - } + private readonly _exec: (ch: any, task: any) => any; + private _promiseChains: Map>; + /** + * @param {(ch, task) => any} [exec] - Task executor. If omitted, tasks are assumed to be + * functions that will be executed with the channel as the only argument. + */ + constructor(exec = (ch: string, task: any) => task(ch)) { + this._exec = exec; + this._promiseChains = new Map(); + } - /** - * Schedules a task for execution. The task will be executed once all previously enqueued tasks - * for the named channel have completed. - * - * @param {any} ch - Identifies the channel. - * @param {any} task - The task to give to the executor. - * @returns {Promise} The value returned by the executor. - */ - async enqueue(ch:any, task:any): Promise { - const p = (this._promiseChains.get(ch) || Promise.resolve()).then(() => this._exec(ch, task)); - const pc = p - .catch(() => {}) // Prevent rejections from halting the queue. - .then(() => { - // Clean up this._promiseChains if there are no more tasks for the channel. - if (this._promiseChains.get(ch) === pc) this._promiseChains.delete(ch); - }); - this._promiseChains.set(ch, pc); - return await p; - } + /** + * Schedules a task for execution. The task will be executed once all previously enqueued tasks + * for the named channel have completed. + * + * @param {any} ch - Identifies the channel. + * @param {any} task - The task to give to the executor. + * @returns {Promise} The value returned by the executor. + */ + async enqueue(ch: any, task: any): Promise { + const p = (this._promiseChains.get(ch) || Promise.resolve()).then(() => + this._exec(ch, task), + ); + const pc = p + .catch(() => {}) // Prevent rejections from halting the queue. + .then(() => { + // Clean up this._promiseChains if there are no more tasks for the channel. + if (this._promiseChains.get(ch) === pc) this._promiseChains.delete(ch); + }); + this._promiseChains.set(ch, pc); + return await p; + } } /** * A changeset queue per pad that is processed by handleUserChanges() */ -const padChannels = new Channels((ch, {socket, message}) => handleUserChanges(socket, message)); +const padChannels = new Channels((ch, { socket, message }) => + handleUserChanges(socket, message), +); /** * This Method is called by server.ts to tell the message handler on which socket it should send * @param socket_io The Socket */ -exports.setSocketIO = (socket_io:any) => { - socketio = socket_io; +exports.setSocketIO = (socket_io: any) => { + socketio = socket_io; }; /** * Handles the connection of a new user * @param socket the socket.io Socket object for the new connection from the client */ -exports.handleConnect = (socket:any) => { - stats.meter('connects').mark(); +exports.handleConnect = (socket: any) => { + stats.meter("connects").mark(); - // Initialize sessioninfos for this new session - sessioninfos[socket.id] = {}; + // Initialize sessioninfos for this new session + sessioninfos[socket.id] = {}; }; /** * Kicks all sessions from a pad */ exports.kickSessionsFromPad = (padID: string) => { + if (socketio.sockets == null) return; - if(socketio.sockets == null) return; + // skip if there is nobody on this pad + if (_getRoomSockets(padID).length === 0) return; - // skip if there is nobody on this pad - if (_getRoomSockets(padID).length === 0) return; - - // disconnect everyone from this pad - socketio.in(padID).emit('message', {disconnect: 'deleted'}); + // disconnect everyone from this pad + socketio.in(padID).emit("message", { disconnect: "deleted" }); }; /** * Handles the disconnection of a user * @param socket the socket.io Socket object for the client */ -exports.handleDisconnect = async (socket:any) => { - stats.meter('disconnects').mark(); - const session = sessioninfos[socket.id]; - delete sessioninfos[socket.id]; - // session.padId can be nullish if the user disconnects before sending CLIENT_READY. - if (!session || !session.author || !session.padId) return; - const {session: {user} = {}} = socket.client.request as SocketClientRequest; - /* eslint-disable prefer-template -- it doesn't support breaking across multiple lines */ - accessLogger.info('[LEAVE]' + - ` pad:${session.padId}` + - ` socket:${socket.id}` + - ` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` + - ` authorID:${session.author}` + - (user && user.username ? ` username:${user.username}` : '')); - /* eslint-enable prefer-template */ - socket.broadcast.to(session.padId).emit('message', { - type: 'COLLABROOM', - data: { - type: 'USER_LEAVE', - userInfo: { - colorId: await authorManager.getAuthorColorId(session.author), - userId: session.author, - }, - }, - }); - await hooks.aCallAll('userLeave', { - ...session, // For backwards compatibility. - authorId: session.author, - readOnly: session.readonly, - socket, - }); +exports.handleDisconnect = async (socket: any) => { + stats.meter("disconnects").mark(); + const session = sessioninfos[socket.id]; + delete sessioninfos[socket.id]; + // session.padId can be nullish if the user disconnects before sending CLIENT_READY. + if (!session || !session.author || !session.padId) return; + const { + session: { user } = {}, + } = socket.client.request as SocketClientRequest; + /* eslint-disable prefer-template -- it doesn't support breaking across multiple lines */ + accessLogger.info( + "[LEAVE]" + + ` pad:${session.padId}` + + ` socket:${socket.id}` + + ` IP:${settings.disableIPlogging ? "ANONYMOUS" : socket.request.ip}` + + ` authorID:${session.author}` + + (user && user.username ? ` username:${user.username}` : ""), + ); + /* eslint-enable prefer-template */ + socket.broadcast.to(session.padId).emit("message", { + type: "COLLABROOM", + data: { + type: "USER_LEAVE", + userInfo: { + colorId: await authorManager.getAuthorColorId(session.author), + userId: session.author, + }, + }, + }); + await hooks.aCallAll("userLeave", { + ...session, // For backwards compatibility. + authorId: session.author, + readOnly: session.readonly, + socket, + }); }; /** @@ -214,180 +226,224 @@ exports.handleDisconnect = async (socket:any) => { * @param socket the socket.io Socket object for the client * @param message the message from the client */ -exports.handleMessage = async (socket:any, message:typeof ChatMessage) => { - const env = process.env.NODE_ENV || 'development'; +exports.handleMessage = async (socket: any, message: typeof ChatMessage) => { + const env = process.env.NODE_ENV || "development"; - if (env === 'production') { - try { - await rateLimiter.consume(socket.request.ip); // consume 1 point per event from IP - } catch (err) { - messageLogger.warn(`Rate limited IP ${socket.request.ip}. To reduce the amount of rate ` + - 'limiting that happens edit the rateLimit values in settings.json'); - stats.meter('rateLimited').mark(); - socket.emit('message', {disconnect: 'rateLimited'}); - throw err; - } - } + if (env === "production") { + try { + await rateLimiter.consume(socket.request.ip); // consume 1 point per event from IP + } catch (err) { + messageLogger.warn( + `Rate limited IP ${socket.request.ip}. To reduce the amount of rate ` + + "limiting that happens edit the rateLimit values in settings.json", + ); + stats.meter("rateLimited").mark(); + socket.emit("message", { disconnect: "rateLimited" }); + throw err; + } + } - if (message == null) throw new Error('message is null'); - if (!message.type) throw new Error('message type missing'); + if (message == null) throw new Error("message is null"); + if (!message.type) throw new Error("message type missing"); - const thisSession = sessioninfos[socket.id]; - if (!thisSession) throw new Error('message from an unknown connection'); + const thisSession = sessioninfos[socket.id]; + if (!thisSession) throw new Error("message from an unknown connection"); - if (message.type === 'CLIENT_READY') { - // Remember this information since we won't have the cookie in further socket.io messages. This - // information will be used to check if the sessionId of this connection is still valid since it - // could have been deleted by the API. - thisSession.auth = { - sessionID: message.sessionID, - padID: message.padId, - token: message.token, - }; + if (message.type === "CLIENT_READY") { + // Remember this information since we won't have the cookie in further socket.io messages. This + // information will be used to check if the sessionId of this connection is still valid since it + // could have been deleted by the API. + thisSession.auth = { + sessionID: message.sessionID, + 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; - thisSession.readonly = - padIds.readonly || !webaccess.userCanModify(thisSession.auth.padID, socket.client.request); - } - // Outside of the checks done by this function, message.padId must not be accessed because it is - // too easy to introduce a security vulnerability that allows malicious users to read or modify - // pads that they should not be able to access. Code should instead use - // sessioninfos[socket.id].padId if the real pad ID is needed or - // sessioninfos[socket.id].auth.padID if the original user-supplied pad ID is needed. - Object.defineProperty(message, 'padId', {get: () => { - throw new Error('message.padId must not be accessed (for security reasons)'); - }}); + // 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; + thisSession.readonly = + padIds.readonly || + !webaccess.userCanModify(thisSession.auth.padID, socket.client.request); + } + // Outside of the checks done by this function, message.padId must not be accessed because it is + // too easy to introduce a security vulnerability that allows malicious users to read or modify + // pads that they should not be able to access. Code should instead use + // sessioninfos[socket.id].padId if the real pad ID is needed or + // sessioninfos[socket.id].auth.padID if the original user-supplied pad ID is needed. + Object.defineProperty(message, "padId", { + get: () => { + throw new Error( + "message.padId must not be accessed (for security reasons)", + ); + }, + }); - const auth = thisSession.auth; - if (!auth) { - const ip = settings.disableIPlogging ? 'ANONYMOUS' : (socket.request.ip || ''); - const msg = JSON.stringify(message, null, 2); - throw new Error(`pre-CLIENT_READY message from IP ${ip}: ${msg}`); - } + const auth = thisSession.auth; + if (!auth) { + const ip = settings.disableIPlogging + ? "ANONYMOUS" + : socket.request.ip || ""; + const msg = JSON.stringify(message, null, 2); + throw new Error(`pre-CLIENT_READY message from IP ${ip}: ${msg}`); + } - const {session: {user} = {}} = socket.client.request as SocketClientRequest; - const {accessStatus, authorID} = - await securityManager.checkAccess(auth.padID, auth.sessionID, auth.token, user); - if (accessStatus !== 'grant') { - socket.emit('message', {accessStatus}); - throw new Error('access denied'); - } - if (thisSession.author != null && thisSession.author !== authorID) { - socket.emit('message', {disconnect: 'rejected'}); - throw new Error([ - 'Author ID changed mid-session. Bad or missing token or sessionID?', - `socket:${socket.id}`, - `IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}`, - `originalAuthorID:${thisSession.author}`, - `newAuthorID:${authorID}`, - ...(user && user.username) ? [`username:${user.username}`] : [], - `message:${message}`, - ].join(' ')); - } - thisSession.author = authorID; + const { + session: { user } = {}, + } = socket.client.request as SocketClientRequest; + const { accessStatus, authorID } = await securityManager.checkAccess( + auth.padID, + auth.sessionID, + auth.token, + user, + ); + if (accessStatus !== "grant") { + socket.emit("message", { accessStatus }); + throw new Error("access denied"); + } + if (thisSession.author != null && thisSession.author !== authorID) { + socket.emit("message", { disconnect: "rejected" }); + throw new Error( + [ + "Author ID changed mid-session. Bad or missing token or sessionID?", + `socket:${socket.id}`, + `IP:${settings.disableIPlogging ? "ANONYMOUS" : socket.request.ip}`, + `originalAuthorID:${thisSession.author}`, + `newAuthorID:${authorID}`, + ...(user && user.username ? [`username:${user.username}`] : []), + `message:${message}`, + ].join(" "), + ); + } + thisSession.author = authorID; - // Allow plugins to bypass the readonly message blocker - let readOnly = thisSession.readonly; - const context = { - message, - sessionInfo: { - authorId: thisSession.author, - padId: thisSession.padId, - readOnly: thisSession.readonly, - }, - socket, - get client() { - padutils.warnDeprecated( - 'the `client` context property for the handleMessageSecurity and handleMessage hooks ' + - 'is deprecated; use the `socket` property instead'); - return this.socket; - }, - }; - for (const res of await hooks.aCallAll('handleMessageSecurity', context)) { - switch (res) { - case true: - padutils.warnDeprecated( - 'returning `true` from a `handleMessageSecurity` hook function is deprecated; ' + - 'return "permitOnce" instead'); - thisSession.readonly = false; - // Fall through: - case 'permitOnce': - readOnly = false; - break; - default: - messageLogger.warn( - 'Ignoring unsupported return value from handleMessageSecurity hook function:', res); - } - } + // Allow plugins to bypass the readonly message blocker + let readOnly = thisSession.readonly; + const context = { + message, + sessionInfo: { + authorId: thisSession.author, + padId: thisSession.padId, + readOnly: thisSession.readonly, + }, + socket, + get client() { + padutils.warnDeprecated( + "the `client` context property for the handleMessageSecurity and handleMessage hooks " + + "is deprecated; use the `socket` property instead", + ); + return this.socket; + }, + }; + for (const res of await hooks.aCallAll("handleMessageSecurity", context)) { + switch (res) { + case true: + padutils.warnDeprecated( + "returning `true` from a `handleMessageSecurity` hook function is deprecated; " + + 'return "permitOnce" instead', + ); + thisSession.readonly = false; + // Fall through: + case "permitOnce": + readOnly = false; + break; + default: + messageLogger.warn( + "Ignoring unsupported return value from handleMessageSecurity hook function:", + res, + ); + } + } - // Call handleMessage hook. If a plugin returns null, the message will be dropped. - if ((await hooks.aCallAll('handleMessage', context)).some((m: null|string) => m == null)) { - return; - } + // Call handleMessage hook. If a plugin returns null, the message will be dropped. + if ( + (await hooks.aCallAll("handleMessage", context)).some( + (m: null | string) => m == null, + ) + ) { + return; + } - // Drop the message if the client disconnected during the above processing. - if (sessioninfos[socket.id] !== thisSession) throw new Error('client disconnected'); + // Drop the message if the client disconnected during the above processing. + if (sessioninfos[socket.id] !== thisSession) + throw new Error("client disconnected"); - const {type} = message; - try { - switch (type) { - case 'CLIENT_READY': await handleClientReady(socket, message); break; - case 'CHANGESET_REQ': await handleChangesetRequest(socket, message); break; - case 'COLLABROOM': { - if (readOnly) throw new Error('write attempt on read-only pad'); - const {type} = message.data; - try { - switch (type) { - case 'USER_CHANGES': - stats.counter('pendingEdits').inc(); - await padChannels.enqueue(thisSession.padId, {socket, message}); - break; - case 'USERINFO_UPDATE': await handleUserInfoUpdate(socket, message); break; - case 'CHAT_MESSAGE': await handleChatMessage(socket, message); break; - case 'GET_CHAT_MESSAGES': await handleGetChatMessages(socket, message); break; - case 'SAVE_REVISION': await handleSaveRevisionMessage(socket, message); break; - case 'CLIENT_MESSAGE': { - const {type} = message.data.payload; - try { - switch (type) { - case 'suggestUserName': handleSuggestUserName(socket, message); break; - default: throw new Error('unknown message type'); - } - } catch (err) { - throw addContextToError(err, `${type}: `); - } - break; - } - default: throw new Error('unknown message type'); - } - } catch (err) { - throw addContextToError(err, `${type}: `); - } - break; - } - default: throw new Error('unknown message type'); - } - } catch (err) { - throw addContextToError(err, `${type}: `); - } + const { type } = message; + try { + switch (type) { + case "CLIENT_READY": + await handleClientReady(socket, message); + break; + case "CHANGESET_REQ": + await handleChangesetRequest(socket, message); + break; + case "COLLABROOM": { + if (readOnly) throw new Error("write attempt on read-only pad"); + const { type } = message.data; + try { + switch (type) { + case "USER_CHANGES": + stats.counter("pendingEdits").inc(); + await padChannels.enqueue(thisSession.padId, { socket, message }); + break; + case "USERINFO_UPDATE": + await handleUserInfoUpdate(socket, message); + break; + case "CHAT_MESSAGE": + await handleChatMessage(socket, message); + break; + case "GET_CHAT_MESSAGES": + await handleGetChatMessages(socket, message); + break; + case "SAVE_REVISION": + await handleSaveRevisionMessage(socket, message); + break; + case "CLIENT_MESSAGE": { + const { type } = message.data.payload; + try { + switch (type) { + case "suggestUserName": + handleSuggestUserName(socket, message); + break; + default: + throw new Error("unknown message type"); + } + } catch (err) { + throw addContextToError(err, `${type}: `); + } + break; + } + default: + throw new Error("unknown message type"); + } + } catch (err) { + throw addContextToError(err, `${type}: `); + } + break; + } + default: + throw new Error("unknown message type"); + } + } catch (err) { + throw addContextToError(err, `${type}: `); + } }; - /** * Handles a save revision message * @param socket the socket.io Socket object for the client * @param message the message from the client */ -const handleSaveRevisionMessage = async (socket:any, message: string) => { - const {padId, author: authorId} = sessioninfos[socket.id]; - const pad = await padManager.getPad(padId, null, authorId); - await pad.addSavedRevision(pad.head, authorId); +const handleSaveRevisionMessage = async (socket: any, message: string) => { + const { padId, author: authorId } = sessioninfos[socket.id]; + const pad = await padManager.getPad(padId, null, authorId); + await pad.addSavedRevision(pad.head, authorId); }; /** @@ -397,16 +453,19 @@ const handleSaveRevisionMessage = async (socket:any, message: string) => { * @param msg {Object} the message we're sending * @param sessionID {string} the socketIO session to which we're sending this message */ -exports.handleCustomObjectMessage = (msg: typeof ChatMessage, sessionID: string) => { - if (msg.data.type === 'CUSTOM') { - if (sessionID) { - // a sessionID is targeted: directly to this sessionID - socketio.sockets.socket(sessionID).emit('message', msg); - } else { - // broadcast to all clients on this pad - socketio.sockets.in(msg.data.payload.padId).emit('message', msg); - } - } +exports.handleCustomObjectMessage = ( + msg: typeof ChatMessage, + sessionID: string, +) => { + if (msg.data.type === "CUSTOM") { + if (sessionID) { + // a sessionID is targeted: directly to this sessionID + socketio.sockets.socket(sessionID).emit("message", msg); + } else { + // broadcast to all clients on this pad + socketio.sockets.in(msg.data.payload.padId).emit("message", msg); + } + } }; /** @@ -415,16 +474,16 @@ exports.handleCustomObjectMessage = (msg: typeof ChatMessage, sessionID: string) * @param padID {Pad} the pad to which we're sending this message * @param msgString {String} the message we're sending */ -exports.handleCustomMessage = (padID: string, msgString:string) => { - const time = Date.now(); - const msg = { - type: 'COLLABROOM', - data: { - type: msgString, - time, - }, - }; - socketio.sockets.in(padID).emit('message', msg); +exports.handleCustomMessage = (padID: string, msgString: string) => { + const time = Date.now(); + const msg = { + type: "COLLABROOM", + data: { + type: msgString, + time, + }, + }; + socketio.sockets.in(padID).emit("message", msg); }; /** @@ -432,13 +491,13 @@ exports.handleCustomMessage = (padID: string, msgString:string) => { * @param socket the socket.io Socket object for the client * @param message the message from the client */ -const handleChatMessage = async (socket:any, message: typeof ChatMessage) => { - const chatMessage = ChatMessage.fromObject(message.data.message); - const {padId, author: authorId} = sessioninfos[socket.id]; - // Don't trust the user-supplied values. - chatMessage.time = Date.now(); - chatMessage.authorId = authorId; - await exports.sendChatMessageToPadClients(chatMessage, padId); +const handleChatMessage = async (socket: any, message: typeof ChatMessage) => { + const chatMessage = ChatMessage.fromObject(message.data.message); + const { padId, author: authorId } = sessioninfos[socket.id]; + // Don't trust the user-supplied values. + chatMessage.time = Date.now(); + chatMessage.authorId = authorId; + await exports.sendChatMessageToPadClients(chatMessage, padId); }; /** @@ -452,20 +511,26 @@ const handleChatMessage = async (socket:any, message: typeof ChatMessage) => { * @param {string} [padId] - The destination pad ID. Deprecated; pass a chat message * object as the first argument and the destination pad ID as the second argument instead. */ -exports.sendChatMessageToPadClients = async (mt: typeof ChatMessage|number, puId: string, text:string|null = null, padId:string|null = null) => { - const message = mt instanceof ChatMessage ? mt : new ChatMessage(text, puId, mt); - padId = mt instanceof ChatMessage ? puId : padId; - const pad = await padManager.getPad(padId, null, message.authorId); - await hooks.aCallAll('chatNewMessage', {message, pad, padId}); - // pad.appendChatMessage() ignores the displayName property so we don't need to wait for - // authorManager.getAuthorName() to resolve before saving the message to the database. - const promise = pad.appendChatMessage(message); - message.displayName = await authorManager.getAuthorName(message.authorId); - socketio.sockets.in(padId).emit('message', { - type: 'COLLABROOM', - data: {type: 'CHAT_MESSAGE', message}, - }); - await promise; +exports.sendChatMessageToPadClients = async ( + mt: typeof ChatMessage | number, + puId: string, + text: string | null = null, + padId: string | null = null, +) => { + const message = + mt instanceof ChatMessage ? mt : new ChatMessage(text, puId, mt); + padId = mt instanceof ChatMessage ? puId : padId; + const pad = await padManager.getPad(padId, null, message.authorId); + await hooks.aCallAll("chatNewMessage", { message, pad, padId }); + // pad.appendChatMessage() ignores the displayName property so we don't need to wait for + // authorManager.getAuthorName() to resolve before saving the message to the database. + const promise = pad.appendChatMessage(message); + message.displayName = await authorManager.getAuthorName(message.authorId); + socketio.sockets.in(padId).emit("message", { + type: "COLLABROOM", + data: { type: "CHAT_MESSAGE", message }, + }); + await promise; }; /** @@ -473,25 +538,30 @@ exports.sendChatMessageToPadClients = async (mt: typeof ChatMessage|number, puId * @param socket the socket.io Socket object for the client * @param message the message from the client */ -const handleGetChatMessages = async (socket:any, {data: {start, end}}:any) => { - if (!Number.isInteger(start)) throw new Error(`missing or invalid start: ${start}`); - if (!Number.isInteger(end)) throw new Error(`missing or invalid end: ${end}`); - const count = end - start; - if (count < 0 || count > 100) throw new Error(`invalid number of messages: ${count}`); - const {padId, author: authorId} = sessioninfos[socket.id]; - const pad = await padManager.getPad(padId, null, authorId); +const handleGetChatMessages = async ( + socket: any, + { data: { start, end } }: any, +) => { + if (!Number.isInteger(start)) + throw new Error(`missing or invalid start: ${start}`); + if (!Number.isInteger(end)) throw new Error(`missing or invalid end: ${end}`); + const count = end - start; + if (count < 0 || count > 100) + throw new Error(`invalid number of messages: ${count}`); + const { padId, author: authorId } = sessioninfos[socket.id]; + const pad = await padManager.getPad(padId, null, authorId); - const chatMessages = await pad.getChatMessages(start, end); - const infoMsg = { - type: 'COLLABROOM', - data: { - type: 'CHAT_MESSAGES', - messages: chatMessages, - }, - }; + const chatMessages = await pad.getChatMessages(start, end); + const infoMsg = { + type: "COLLABROOM", + data: { + type: "CHAT_MESSAGES", + messages: chatMessages, + }, + }; - // send the messages back to the client - socket.emit('message', infoMsg); + // send the messages back to the client + socket.emit("message", infoMsg); }; /** @@ -499,18 +569,18 @@ const handleGetChatMessages = async (socket:any, {data: {start, end}}:any) => { * @param socket the socket.io Socket object for the client * @param message the message from the client */ -const handleSuggestUserName = (socket:any, message: typeof ChatMessage) => { - const {newName, unnamedId} = message.data.payload; - if (newName == null) throw new Error('missing newName'); - if (unnamedId == null) throw new Error('missing unnamedId'); - const padId = sessioninfos[socket.id].padId; - // search the author and send him this message - _getRoomSockets(padId).forEach((socket) => { - const session = sessioninfos[socket.id]; - if (session && session.author === unnamedId) { - socket.emit('message', message); - } - }); +const handleSuggestUserName = (socket: any, message: typeof ChatMessage) => { + const { newName, unnamedId } = message.data.payload; + if (newName == null) throw new Error("missing newName"); + if (unnamedId == null) throw new Error("missing unnamedId"); + const padId = sessioninfos[socket.id].padId; + // search the author and send him this message + _getRoomSockets(padId).forEach((socket) => { + const session = sessioninfos[socket.id]; + if (session && session.author === unnamedId) { + socket.emit("message", message); + } + }); }; /** @@ -519,38 +589,46 @@ const handleSuggestUserName = (socket:any, message: typeof ChatMessage) => { * @param socket the socket.io Socket object for the client * @param message the message from the client */ -const handleUserInfoUpdate = async (socket:any, {data: {userInfo: {name, colorId}}}: PadUserInfo) => { - if (colorId == null) throw new Error('missing colorId'); - if (!name) name = null; - const session = sessioninfos[socket.id]; - if (!session || !session.author || !session.padId) throw new Error('session not ready'); - const author = session.author; - if (!/(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(colorId)) { - throw new Error(`malformed color: ${colorId}`); - } +const handleUserInfoUpdate = async ( + socket: any, + { + data: { + userInfo: { name, colorId }, + }, + }: PadUserInfo, +) => { + if (colorId == null) throw new Error("missing colorId"); + if (!name) name = null; + const session = sessioninfos[socket.id]; + if (!session || !session.author || !session.padId) + throw new Error("session not ready"); + const author = session.author; + if (!/(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(colorId)) { + throw new Error(`malformed color: ${colorId}`); + } - // Tell the authorManager about the new attributes - const p = Promise.all([ - authorManager.setAuthorColorId(author, colorId), - authorManager.setAuthorName(author, name), - ]); + // Tell the authorManager about the new attributes + const p = Promise.all([ + authorManager.setAuthorColorId(author, colorId), + authorManager.setAuthorName(author, name), + ]); - const padId = session.padId; + const padId = session.padId; - const infoMsg = { - type: 'COLLABROOM', - data: { - // The Client doesn't know about USERINFO_UPDATE, use USER_NEWINFO - type: 'USER_NEWINFO', - userInfo: {userId: author, name, colorId}, - }, - }; + const infoMsg = { + type: "COLLABROOM", + data: { + // The Client doesn't know about USERINFO_UPDATE, use USER_NEWINFO + type: "USER_NEWINFO", + userInfo: { userId: author, name, colorId }, + }, + }; - // Send the other clients on the pad the update message - socket.broadcast.to(padId).emit('message',infoMsg); + // Send the other clients on the pad the update message + socket.broadcast.to(padId).emit("message", infoMsg); - // Block until the authorManager has stored the new attributes. - await p; + // Block until the authorManager has stored the new attributes. + await p; }; /** @@ -567,215 +645,260 @@ const handleUserInfoUpdate = async (socket:any, {data: {userInfo: {name, colorId * @param socket the socket.io Socket object for the client * @param message the message from the client */ -const handleUserChanges = async (socket:any, message: typeof ChatMessage) => { - // This one's no longer pending, as we're gonna process it now - stats.counter('pendingEdits').dec(); +const handleUserChanges = async (socket: any, message: typeof ChatMessage) => { + // This one's no longer pending, as we're gonna process it now + stats.counter("pendingEdits").dec(); - // The client might disconnect between our callbacks. We should still - // finish processing the changeset, so keep a reference to the session. - const thisSession = sessioninfos[socket.id]; + // The client might disconnect between our callbacks. We should still + // finish processing the changeset, so keep a reference to the session. + const thisSession = sessioninfos[socket.id]; - // TODO: this might happen with other messages too => find one place to copy the session - // and always use the copy. atm a message will be ignored if the session is gone even - // if the session was valid when the message arrived in the first place - if (!thisSession) throw new Error('client disconnected'); + // TODO: this might happen with other messages too => find one place to copy the session + // and always use the copy. atm a message will be ignored if the session is gone even + // if the session was valid when the message arrived in the first place + if (!thisSession) throw new Error("client disconnected"); - // Measure time to process edit - const stopWatch = stats.timer('edits').start(); - try { - const {data: {baseRev, apool, changeset}} = message; - if (baseRev == null) throw new Error('missing baseRev'); - if (apool == null) throw new Error('missing apool'); - if (changeset == null) throw new Error('missing changeset'); - const wireApool = (new AttributePool()).fromJsonable(apool); - const pad = await padManager.getPad(thisSession.padId, null, thisSession.author); + // Measure time to process edit + const stopWatch = stats.timer("edits").start(); + try { + const { + data: { baseRev, apool, changeset }, + } = message; + if (baseRev == null) throw new Error("missing baseRev"); + if (apool == null) throw new Error("missing apool"); + if (changeset == null) throw new Error("missing changeset"); + const wireApool = new AttributePool().fromJsonable(apool); + const pad = await padManager.getPad( + thisSession.padId, + null, + thisSession.author, + ); - // Verify that the changeset has valid syntax and is in canonical form - Changeset.checkRep(changeset); + // Verify that the changeset has valid syntax and is in canonical form + Changeset.checkRep(changeset); - // Validate all added 'author' attribs to be the same value as the current user - for (const op of Changeset.deserializeOps(Changeset.unpack(changeset).ops)) { - // + can add text with attribs - // = can change or add attribs - // - can have attribs, but they are discarded and don't show up in the attribs - - // but do show up in the pool + // Validate all added 'author' attribs to be the same value as the current user + for (const op of Changeset.deserializeOps( + Changeset.unpack(changeset).ops, + )) { + // + can add text with attribs + // = can change or add attribs + // - can have attribs, but they are discarded and don't show up in the attribs - + // but do show up in the pool - // Besides verifying the author attribute, this serves a second purpose: - // AttributeMap.fromString() ensures that all attribute numbers are valid (it will throw if - // an attribute number isn't in the pool). - const opAuthorId = AttributeMap.fromString(op.attribs, wireApool).get('author'); - if (opAuthorId && opAuthorId !== thisSession.author) { - throw new Error(`Author ${thisSession.author} tried to submit changes as author ` + - `${opAuthorId} in changeset ${changeset}`); - } - } + // Besides verifying the author attribute, this serves a second purpose: + // AttributeMap.fromString() ensures that all attribute numbers are valid (it will throw if + // an attribute number isn't in the pool). + const opAuthorId = AttributeMap.fromString(op.attribs, wireApool).get( + "author", + ); + if (opAuthorId && opAuthorId !== thisSession.author) { + throw new Error( + `Author ${thisSession.author} tried to submit changes as author ` + + `${opAuthorId} in changeset ${changeset}`, + ); + } + } - // ex. adoptChangesetAttribs + // ex. adoptChangesetAttribs - // Afaik, it copies the new attributes from the changeset, to the global Attribute Pool - let rebasedChangeset = Changeset.moveOpsToNewPool(changeset, wireApool, pad.pool); + // Afaik, it copies the new attributes from the changeset, to the global Attribute Pool + let rebasedChangeset = Changeset.moveOpsToNewPool( + changeset, + wireApool, + pad.pool, + ); - // ex. applyUserChanges - let r = baseRev; + // ex. applyUserChanges + let r = baseRev; - // The client's changeset might not be based on the latest revision, - // since other clients are sending changes at the same time. - // Update the changeset so that it can be applied to the latest revision. - while (r < pad.getHeadRevisionNumber()) { - r++; - const {changeset: c, meta: {author: authorId}} = await pad.getRevision(r); - if (changeset === c && thisSession.author === authorId) { - // Assume this is a retransmission of an already applied changeset. - rebasedChangeset = Changeset.identity(Changeset.unpack(changeset).oldLen); - } - // At this point, both "c" (from the pad) and "changeset" (from the - // client) are relative to revision r - 1. The follow function - // rebases "changeset" so that it is relative to revision r - // and can be applied after "c". - rebasedChangeset = Changeset.follow(c, rebasedChangeset, false, pad.pool); - } + // The client's changeset might not be based on the latest revision, + // since other clients are sending changes at the same time. + // Update the changeset so that it can be applied to the latest revision. + while (r < pad.getHeadRevisionNumber()) { + r++; + const { + changeset: c, + meta: { author: authorId }, + } = await pad.getRevision(r); + if (changeset === c && thisSession.author === authorId) { + // Assume this is a retransmission of an already applied changeset. + rebasedChangeset = Changeset.identity( + Changeset.unpack(changeset).oldLen, + ); + } + // At this point, both "c" (from the pad) and "changeset" (from the + // client) are relative to revision r - 1. The follow function + // rebases "changeset" so that it is relative to revision r + // and can be applied after "c". + rebasedChangeset = Changeset.follow(c, rebasedChangeset, false, pad.pool); + } - const prevText = pad.text(); + const prevText = pad.text(); - if (Changeset.oldLen(rebasedChangeset) !== prevText.length) { - throw new Error( - `Can't apply changeset ${rebasedChangeset} with oldLen ` + - `${Changeset.oldLen(rebasedChangeset)} to document of length ${prevText.length}`); - } + if (Changeset.oldLen(rebasedChangeset) !== prevText.length) { + throw new Error( + `Can't apply changeset ${rebasedChangeset} with oldLen ` + + `${Changeset.oldLen(rebasedChangeset)} to document of length ${ + prevText.length + }`, + ); + } - const newRev = await pad.appendRevision(rebasedChangeset, thisSession.author); - // The head revision will either stay the same or increase by 1 depending on whether the - // changeset has a net effect. - assert([r, r + 1].includes(newRev)); + const newRev = await pad.appendRevision( + rebasedChangeset, + thisSession.author, + ); + // The head revision will either stay the same or increase by 1 depending on whether the + // changeset has a net effect. + assert([r, r + 1].includes(newRev)); - const correctionChangeset = _correctMarkersInPad(pad.atext, pad.pool); - if (correctionChangeset) { - await pad.appendRevision(correctionChangeset, thisSession.author); - } + const correctionChangeset = _correctMarkersInPad(pad.atext, pad.pool); + if (correctionChangeset) { + await pad.appendRevision(correctionChangeset, thisSession.author); + } - // Make sure the pad always ends with an empty line. - if (pad.text().lastIndexOf('\n') !== pad.text().length - 1) { - const nlChangeset = Changeset.makeSplice(pad.text(), pad.text().length - 1, 0, '\n'); - await pad.appendRevision(nlChangeset, thisSession.author); - } + // Make sure the pad always ends with an empty line. + if (pad.text().lastIndexOf("\n") !== pad.text().length - 1) { + const nlChangeset = Changeset.makeSplice( + pad.text(), + pad.text().length - 1, + 0, + "\n", + ); + await pad.appendRevision(nlChangeset, thisSession.author); + } - // The client assumes that ACCEPT_COMMIT and NEW_CHANGES messages arrive in order. Make sure we - // have already sent any previous ACCEPT_COMMIT and NEW_CHANGES messages. - assert.equal(thisSession.rev, r); - socket.emit('message', {type: 'COLLABROOM', data: {type: 'ACCEPT_COMMIT', newRev}}); - thisSession.rev = newRev; - if (newRev !== r) thisSession.time = await pad.getRevisionDate(newRev); - await exports.updatePadClients(pad); - } catch (err:any) { - socket.emit('message', {disconnect: 'badChangeset'}); - stats.meter('failedChangesets').mark(); - messageLogger.warn(`Failed to apply USER_CHANGES from author ${thisSession.author} ` + - `(socket ${socket.id}) on pad ${thisSession.padId}: ${err.stack || err}`); - } finally { - stopWatch.end(); - } + // The client assumes that ACCEPT_COMMIT and NEW_CHANGES messages arrive in order. Make sure we + // have already sent any previous ACCEPT_COMMIT and NEW_CHANGES messages. + assert.equal(thisSession.rev, r); + socket.emit("message", { + type: "COLLABROOM", + data: { type: "ACCEPT_COMMIT", newRev }, + }); + thisSession.rev = newRev; + if (newRev !== r) thisSession.time = await pad.getRevisionDate(newRev); + await exports.updatePadClients(pad); + } catch (err: any) { + socket.emit("message", { disconnect: "badChangeset" }); + stats.meter("failedChangesets").mark(); + messageLogger.warn( + `Failed to apply USER_CHANGES from author ${thisSession.author} ` + + `(socket ${socket.id}) on pad ${thisSession.padId}: ${ + err.stack || err + }`, + ); + } finally { + stopWatch.end(); + } }; exports.updatePadClients = async (pad: PadType) => { - // skip this if no-one is on this pad - const roomSockets = _getRoomSockets(pad.id); - if (roomSockets.length === 0) return; + // skip this if no-one is on this pad + const roomSockets = _getRoomSockets(pad.id); + if (roomSockets.length === 0) return; - // since all clients usually get the same set of changesets, store them in local cache - // to remove unnecessary roundtrip to the datalayer - // NB: note below possibly now accommodated via the change to promises/async - // TODO: in REAL world, if we're working without datalayer cache, - // all requests to revisions will be fired - // BEFORE first result will be landed to our cache object. - // The solution is to replace parallel processing - // via async.forEach with sequential for() loop. There is no real - // benefits of running this in parallel, - // but benefit of reusing cached revision object is HUGE - const revCache:MapArrayType = {}; + // since all clients usually get the same set of changesets, store them in local cache + // to remove unnecessary roundtrip to the datalayer + // NB: note below possibly now accommodated via the change to promises/async + // TODO: in REAL world, if we're working without datalayer cache, + // all requests to revisions will be fired + // BEFORE first result will be landed to our cache object. + // The solution is to replace parallel processing + // via async.forEach with sequential for() loop. There is no real + // benefits of running this in parallel, + // but benefit of reusing cached revision object is HUGE + const revCache: MapArrayType = {}; - await Promise.all(roomSockets.map(async (socket) => { - const sessioninfo = sessioninfos[socket.id]; - // The user might have disconnected since _getRoomSockets() was called. - if (sessioninfo == null) return; + await Promise.all( + roomSockets.map(async (socket) => { + const sessioninfo = sessioninfos[socket.id]; + // The user might have disconnected since _getRoomSockets() was called. + if (sessioninfo == null) return; - while (sessioninfo.rev < pad.getHeadRevisionNumber()) { - const r = sessioninfo.rev + 1; - let revision = revCache[r]; - if (!revision) { - revision = await pad.getRevision(r); - revCache[r] = revision; - } + while (sessioninfo.rev < pad.getHeadRevisionNumber()) { + const r = sessioninfo.rev + 1; + let revision = revCache[r]; + if (!revision) { + revision = await pad.getRevision(r); + revCache[r] = revision; + } - const author = revision.meta.author; - const revChangeset = revision.changeset; - const currentTime = revision.meta.timestamp; + const author = revision.meta.author; + const revChangeset = revision.changeset; + const currentTime = revision.meta.timestamp; - const forWire = Changeset.prepareForWire(revChangeset, pad.pool); - const msg = { - type: 'COLLABROOM', - data: { - type: 'NEW_CHANGES', - newRev: r, - changeset: forWire.translated, - apool: forWire.pool, - author, - currentTime, - timeDelta: currentTime - sessioninfo.time, - }, - }; - try { - socket.emit('message', msg); - } catch (err:any) { - messageLogger.error(`Failed to notify user of new revision: ${err.stack || err}`); - return; - } - sessioninfo.time = currentTime; - sessioninfo.rev = r; - } - })); + const forWire = Changeset.prepareForWire(revChangeset, pad.pool); + const msg = { + type: "COLLABROOM", + data: { + type: "NEW_CHANGES", + newRev: r, + changeset: forWire.translated, + apool: forWire.pool, + author, + currentTime, + timeDelta: currentTime - sessioninfo.time, + }, + }; + try { + socket.emit("message", msg); + } catch (err: any) { + messageLogger.error( + `Failed to notify user of new revision: ${err.stack || err}`, + ); + return; + } + sessioninfo.time = currentTime; + sessioninfo.rev = r; + } + }), + ); }; /** * Copied from the Etherpad Source Code. Don't know what this method does excatly... */ const _correctMarkersInPad = (atext: AText, apool: APool) => { - const text = atext.text; + const text = atext.text; - // collect char positions of line markers (e.g. bullets) in new atext - // that aren't at the start of a line - const badMarkers = []; - let offset = 0; - for (const op of Changeset.deserializeOps(atext.attribs)) { - const attribs = AttributeMap.fromString(op.attribs, apool); - const hasMarker = AttributeManager.lineAttributes.some((a: string) => attribs.has(a)); - if (hasMarker) { - for (let i = 0; i < op.chars; i++) { - if (offset > 0 && text.charAt(offset - 1) !== '\n') { - badMarkers.push(offset); - } - offset++; - } - } else { - offset += op.chars; - } - } + // collect char positions of line markers (e.g. bullets) in new atext + // that aren't at the start of a line + const badMarkers = []; + let offset = 0; + for (const op of Changeset.deserializeOps(atext.attribs)) { + const attribs = AttributeMap.fromString(op.attribs, apool); + const hasMarker = AttributeManager.lineAttributes.some((a: string) => + attribs.has(a), + ); + if (hasMarker) { + for (let i = 0; i < op.chars; i++) { + if (offset > 0 && text.charAt(offset - 1) !== "\n") { + badMarkers.push(offset); + } + offset++; + } + } else { + offset += op.chars; + } + } - if (badMarkers.length === 0) { - return null; - } + if (badMarkers.length === 0) { + return null; + } - // create changeset that removes these bad markers - offset = 0; + // create changeset that removes these bad markers + offset = 0; - const builder = Changeset.builder(text.length); + const builder = Changeset.builder(text.length); - badMarkers.forEach((pos) => { - builder.keepText(text.substring(offset, pos)); - builder.remove(1); - offset = pos + 1; - }); + badMarkers.forEach((pos) => { + builder.keepText(text.substring(offset, pos)); + builder.remove(1); + offset = pos + 1; + }); - return builder.toString(); + return builder.toString(); }; /** @@ -785,402 +908,482 @@ const _correctMarkersInPad = (atext: AText, apool: APool) => { * @param socket the socket.io Socket object for the client * @param message the message from the client */ -const handleClientReady = async (socket:any, message: typeof ChatMessage) => { - const sessionInfo = sessioninfos[socket.id]; - if (sessionInfo == null) throw new Error('client disconnected'); - assert(sessionInfo.author); +const handleClientReady = async (socket: any, message: typeof ChatMessage) => { + const sessionInfo = sessioninfos[socket.id]; + if (sessionInfo == null) throw new Error("client disconnected"); + assert(sessionInfo.author); - await hooks.aCallAll('clientReady', message); // Deprecated due to awkward context. + await hooks.aCallAll("clientReady", message); // Deprecated due to awkward context. - let {colorId: authorColorId, name: authorName} = message.userInfo || {}; - if (authorColorId && !/^#(?:[0-9A-F]{3}){1,2}$/i.test(authorColorId)) { - messageLogger.warn(`Ignoring invalid colorId in CLIENT_READY message: ${authorColorId}`); - authorColorId = null; - } - await Promise.all([ - authorName && authorManager.setAuthorName(sessionInfo.author, authorName), - authorColorId && authorManager.setAuthorColorId(sessionInfo.author, authorColorId), - ]); - ({colorId: authorColorId, name: authorName} = await authorManager.getAuthor(sessionInfo.author)); + let { colorId: authorColorId, name: authorName } = message.userInfo || {}; + if (authorColorId && !/^#(?:[0-9A-F]{3}){1,2}$/i.test(authorColorId)) { + messageLogger.warn( + `Ignoring invalid colorId in CLIENT_READY message: ${authorColorId}`, + ); + authorColorId = null; + } + await Promise.all([ + authorName && authorManager.setAuthorName(sessionInfo.author, authorName), + authorColorId && + authorManager.setAuthorColorId(sessionInfo.author, authorColorId), + ]); + ({ colorId: authorColorId, name: authorName } = await authorManager.getAuthor( + sessionInfo.author, + )); - // load the pad-object from the database - const pad = await padManager.getPad(sessionInfo.padId, null, sessionInfo.author); + // load the pad-object from the database + const pad = await padManager.getPad( + sessionInfo.padId, + null, + sessionInfo.author, + ); - // these db requests all need the pad object (timestamp of latest revision, author data) - const authors = pad.getAllAuthors(); + // these db requests all need the pad object (timestamp of latest revision, author data) + const authors = pad.getAllAuthors(); - // get timestamp of latest revision needed for timeslider - const currentTime = await pad.getRevisionDate(pad.getHeadRevisionNumber()); + // get timestamp of latest revision needed for timeslider + const currentTime = await pad.getRevisionDate(pad.getHeadRevisionNumber()); - // get all author data out of the database (in parallel) - const historicalAuthorData:MapArrayType<{ - name: string; - colorId: string; - }> = {}; - await Promise.all(authors.map(async (authorId: string) => { - const author = await authorManager.getAuthor(authorId); - if (!author) { - messageLogger.error(`There is no author for authorId: ${authorId}. ` + - 'This is possibly related to https://github.com/ether/etherpad-lite/issues/2802'); - } else { - // Filter author attribs (e.g. don't send author's pads to all clients) - historicalAuthorData[authorId] = {name: author.name, colorId: author.colorId}; - } - })); + // get all author data out of the database (in parallel) + const historicalAuthorData: MapArrayType<{ + name: string; + colorId: string; + }> = {}; + await Promise.all( + authors.map(async (authorId: string) => { + const author = await authorManager.getAuthor(authorId); + if (!author) { + messageLogger.error( + `There is no author for authorId: ${authorId}. ` + + "This is possibly related to https://github.com/ether/etherpad-lite/issues/2802", + ); + } else { + // Filter author attribs (e.g. don't send author's pads to all clients) + historicalAuthorData[authorId] = { + name: author.name, + colorId: author.colorId, + }; + } + }), + ); - // glue the clientVars together, send them and tell the other clients that a new one is there + // glue the clientVars together, send them and tell the other clients that a new one is there - // Check if the user has disconnected during any of the above awaits. - if (sessionInfo !== sessioninfos[socket.id]) throw new Error('client disconnected'); + // Check if the user has disconnected during any of the above awaits. + if (sessionInfo !== sessioninfos[socket.id]) + throw new Error("client disconnected"); - // Check if this author is already on the pad, if yes, kick the other sessions! - const roomSockets = _getRoomSockets(pad.id); + // Check if this author is already on the pad, if yes, kick the other sessions! + const roomSockets = _getRoomSockets(pad.id); - for (const otherSocket of roomSockets) { - // The user shouldn't have joined the room yet, but check anyway just in case. - if (otherSocket.id === socket.id) continue; - const sinfo = sessioninfos[otherSocket.id]; - if (sinfo && sinfo.author === sessionInfo.author) { - // fix user's counter, works on page refresh or if user closes browser window and then rejoins - sessioninfos[otherSocket.id] = {}; - otherSocket.leave(sessionInfo.padId); - otherSocket.emit('message', {disconnect: 'userdup'}); - } - } + for (const otherSocket of roomSockets) { + // The user shouldn't have joined the room yet, but check anyway just in case. + if (otherSocket.id === socket.id) continue; + const sinfo = sessioninfos[otherSocket.id]; + if (sinfo && sinfo.author === sessionInfo.author) { + // fix user's counter, works on page refresh or if user closes browser window and then rejoins + sessioninfos[otherSocket.id] = {}; + otherSocket.leave(sessionInfo.padId); + otherSocket.emit("message", { disconnect: "userdup" }); + } + } - const {session: {user} = {}} = socket.client.request as SocketClientRequest; - /* eslint-disable prefer-template -- it doesn't support breaking across multiple lines */ - accessLogger.info(`[${pad.head > 0 ? 'ENTER' : 'CREATE'}]` + - ` pad:${sessionInfo.padId}` + - ` socket:${socket.id}` + - ` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` + - ` authorID:${sessionInfo.author}` + - (user && user.username ? ` username:${user.username}` : '')); - /* eslint-enable prefer-template */ + const { + session: { user } = {}, + } = socket.client.request as SocketClientRequest; + /* eslint-disable prefer-template -- it doesn't support breaking across multiple lines */ + accessLogger.info( + `[${pad.head > 0 ? "ENTER" : "CREATE"}]` + + ` pad:${sessionInfo.padId}` + + ` socket:${socket.id}` + + ` IP:${settings.disableIPlogging ? "ANONYMOUS" : socket.request.ip}` + + ` authorID:${sessionInfo.author}` + + (user && user.username ? ` username:${user.username}` : ""), + ); + /* eslint-enable prefer-template */ - if (message.reconnect) { - // If this is a reconnect, we don't have to send the client the ClientVars again - // Join the pad and start receiving updates - socket.join(sessionInfo.padId); + if (message.reconnect) { + // If this is a reconnect, we don't have to send the client the ClientVars again + // Join the pad and start receiving updates + socket.join(sessionInfo.padId); - // Save the revision in sessioninfos, we take the revision from the info the client send to us - sessionInfo.rev = message.client_rev; + // Save the revision in sessioninfos, we take the revision from the info the client send to us + sessionInfo.rev = message.client_rev; - // During the client reconnect, client might miss some revisions from other clients. - // By using client revision, - // this below code sends all the revisions missed during the client reconnect - const revisionsNeeded = []; - const changesets:MapArrayType = {}; + // During the client reconnect, client might miss some revisions from other clients. + // By using client revision, + // this below code sends all the revisions missed during the client reconnect + const revisionsNeeded = []; + const changesets: MapArrayType = {}; - let startNum = message.client_rev + 1; - let endNum = pad.getHeadRevisionNumber() + 1; + let startNum = message.client_rev + 1; + let endNum = pad.getHeadRevisionNumber() + 1; - const headNum = pad.getHeadRevisionNumber(); + const headNum = pad.getHeadRevisionNumber(); - if (endNum > headNum + 1) { - endNum = headNum + 1; - } + if (endNum > headNum + 1) { + endNum = headNum + 1; + } - if (startNum < 0) { - startNum = 0; - } + if (startNum < 0) { + startNum = 0; + } - for (let r = startNum; r < endNum; r++) { - revisionsNeeded.push(r); - changesets[r] = {}; - } + for (let r = startNum; r < endNum; r++) { + revisionsNeeded.push(r); + changesets[r] = {}; + } - await Promise.all(revisionsNeeded.map(async (revNum) => { - const cs = changesets[revNum]; - [cs.changeset, cs.author, cs.timestamp] = await Promise.all([ - pad.getRevisionChangeset(revNum), - pad.getRevisionAuthor(revNum), - pad.getRevisionDate(revNum), - ]); - })); + await Promise.all( + revisionsNeeded.map(async (revNum) => { + const cs = changesets[revNum]; + [cs.changeset, cs.author, cs.timestamp] = await Promise.all([ + pad.getRevisionChangeset(revNum), + pad.getRevisionAuthor(revNum), + pad.getRevisionDate(revNum), + ]); + }), + ); - // return pending changesets - for (const r of revisionsNeeded) { - const forWire = Changeset.prepareForWire(changesets[r].changeset, pad.pool); - const wireMsg = {type: 'COLLABROOM', - data: {type: 'CLIENT_RECONNECT', - headRev: pad.getHeadRevisionNumber(), - newRev: r, - changeset: forWire.translated, - apool: forWire.pool, - author: changesets[r].author, - currentTime: changesets[r].timestamp}}; - socket.emit('message', wireMsg); - } + // return pending changesets + for (const r of revisionsNeeded) { + const forWire = Changeset.prepareForWire( + changesets[r].changeset, + pad.pool, + ); + const wireMsg = { + type: "COLLABROOM", + data: { + type: "CLIENT_RECONNECT", + headRev: pad.getHeadRevisionNumber(), + newRev: r, + changeset: forWire.translated, + apool: forWire.pool, + author: changesets[r].author, + currentTime: changesets[r].timestamp, + }, + }; + socket.emit("message", wireMsg); + } - if (startNum === endNum) { - const Msg = {type: 'COLLABROOM', - data: {type: 'CLIENT_RECONNECT', - noChanges: true, - newRev: pad.getHeadRevisionNumber()}}; - socket.emit('message', Msg); - } - } else { - // This is a normal first connect - let atext; - let apool; - // prepare all values for the wire, there's a chance that this throws, if the pad is corrupted - try { - atext = Changeset.cloneAText(pad.atext); - const attribsForWire = Changeset.prepareForWire(atext.attribs, pad.pool); - apool = attribsForWire.pool.toJsonable(); - atext.attribs = attribsForWire.translated; - } catch (e:any) { - messageLogger.error(e.stack || e); - socket.emit('message', {disconnect: 'corruptPad'}); // pull the brakes - throw new Error('corrupt pad'); - } + if (startNum === endNum) { + const Msg = { + type: "COLLABROOM", + data: { + type: "CLIENT_RECONNECT", + noChanges: true, + newRev: pad.getHeadRevisionNumber(), + }, + }; + socket.emit("message", Msg); + } + } else { + // This is a normal first connect + let atext; + let apool; + // prepare all values for the wire, there's a chance that this throws, if the pad is corrupted + try { + atext = Changeset.cloneAText(pad.atext); + const attribsForWire = Changeset.prepareForWire(atext.attribs, pad.pool); + apool = attribsForWire.pool.toJsonable(); + atext.attribs = attribsForWire.translated; + } catch (e: any) { + messageLogger.error(e.stack || e); + socket.emit("message", { disconnect: "corruptPad" }); // pull the brakes + throw new Error("corrupt pad"); + } - // Warning: never ever send sessionInfo.padId to the client. If the client is read only you - // would open a security hole 1 swedish mile wide... - const clientVars:MapArrayType = { - skinName: settings.skinName, - skinVariants: settings.skinVariants, - randomVersionString: settings.randomVersionString, - accountPrivs: { - maxRevisions: 100, - }, - automaticReconnectionTimeout: settings.automaticReconnectionTimeout, - initialRevisionList: [], - initialOptions: {}, - savedRevisions: pad.getSavedRevisions(), - collab_client_vars: { - initialAttributedText: atext, - clientIp: '127.0.0.1', - padId: sessionInfo.auth.padID, - historicalAuthorData, - apool, - rev: pad.getHeadRevisionNumber(), - time: currentTime, - }, - colorPalette: authorManager.getColorPalette(), - clientIp: '127.0.0.1', - userColor: authorColorId, - padId: sessionInfo.auth.padID, - padOptions: settings.padOptions, - padShortcutEnabled: settings.padShortcutEnabled, - initialTitle: `Pad: ${sessionInfo.auth.padID}`, - opts: {}, - // tell the client the number of the latest chat-message, which will be - // used to request the latest 100 chat-messages later (GET_CHAT_MESSAGES) - chatHead: pad.chatHead, - numConnectedUsers: roomSockets.length, - readOnlyId: sessionInfo.readOnlyPadId, - readonly: sessionInfo.readonly, - serverTimestamp: Date.now(), - sessionRefreshInterval: settings.cookie.sessionRefreshInterval, - userId: sessionInfo.author, - abiwordAvailable: settings.abiwordAvailable(), - sofficeAvailable: settings.sofficeAvailable(), - exportAvailable: settings.exportAvailable(), - plugins: { - plugins: plugins.plugins, - parts: plugins.parts, - }, - indentationOnNewLine: settings.indentationOnNewLine, - scrollWhenFocusLineIsOutOfViewport: { - percentage: { - editionAboveViewport: - settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionAboveViewport, - editionBelowViewport: - settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionBelowViewport, - }, - duration: settings.scrollWhenFocusLineIsOutOfViewport.duration, - scrollWhenCaretIsInTheLastLineOfViewport: - settings.scrollWhenFocusLineIsOutOfViewport.scrollWhenCaretIsInTheLastLineOfViewport, - percentageToScrollWhenUserPressesArrowUp: - settings.scrollWhenFocusLineIsOutOfViewport.percentageToScrollWhenUserPressesArrowUp, - }, - initialChangesets: [], // FIXME: REMOVE THIS SHIT - }; + // Warning: never ever send sessionInfo.padId to the client. If the client is read only you + // would open a security hole 1 swedish mile wide... + const clientVars: MapArrayType = { + skinName: settings.skinName, + skinVariants: settings.skinVariants, + randomVersionString: settings.randomVersionString, + accountPrivs: { + maxRevisions: 100, + }, + automaticReconnectionTimeout: settings.automaticReconnectionTimeout, + initialRevisionList: [], + initialOptions: {}, + savedRevisions: pad.getSavedRevisions(), + collab_client_vars: { + initialAttributedText: atext, + clientIp: "127.0.0.1", + padId: sessionInfo.auth.padID, + historicalAuthorData, + apool, + rev: pad.getHeadRevisionNumber(), + time: currentTime, + }, + colorPalette: authorManager.getColorPalette(), + clientIp: "127.0.0.1", + userColor: authorColorId, + padId: sessionInfo.auth.padID, + padOptions: settings.padOptions, + padShortcutEnabled: settings.padShortcutEnabled, + initialTitle: `Pad: ${sessionInfo.auth.padID}`, + opts: {}, + // tell the client the number of the latest chat-message, which will be + // used to request the latest 100 chat-messages later (GET_CHAT_MESSAGES) + chatHead: pad.chatHead, + numConnectedUsers: roomSockets.length, + readOnlyId: sessionInfo.readOnlyPadId, + readonly: sessionInfo.readonly, + serverTimestamp: Date.now(), + sessionRefreshInterval: settings.cookie.sessionRefreshInterval, + userId: sessionInfo.author, + abiwordAvailable: settings.abiwordAvailable(), + sofficeAvailable: settings.sofficeAvailable(), + exportAvailable: settings.exportAvailable(), + plugins: { + plugins: plugins.plugins, + parts: plugins.parts, + }, + indentationOnNewLine: settings.indentationOnNewLine, + scrollWhenFocusLineIsOutOfViewport: { + percentage: { + editionAboveViewport: + settings.scrollWhenFocusLineIsOutOfViewport.percentage + .editionAboveViewport, + editionBelowViewport: + settings.scrollWhenFocusLineIsOutOfViewport.percentage + .editionBelowViewport, + }, + duration: settings.scrollWhenFocusLineIsOutOfViewport.duration, + scrollWhenCaretIsInTheLastLineOfViewport: + settings.scrollWhenFocusLineIsOutOfViewport + .scrollWhenCaretIsInTheLastLineOfViewport, + percentageToScrollWhenUserPressesArrowUp: + settings.scrollWhenFocusLineIsOutOfViewport + .percentageToScrollWhenUserPressesArrowUp, + }, + initialChangesets: [], // FIXME: REMOVE THIS SHIT + }; - // Add a username to the clientVars if one avaiable - if (authorName != null) { - clientVars.userName = authorName; - } + // Add a username to the clientVars if one avaiable + if (authorName != null) { + clientVars.userName = authorName; + } - // call the clientVars-hook so plugins can modify them before they get sent to the client - const messages = await hooks.aCallAll('clientVars', {clientVars, pad, socket}); + // call the clientVars-hook so plugins can modify them before they get sent to the client + const messages = await hooks.aCallAll("clientVars", { + clientVars, + pad, + socket, + }); - // combine our old object with the new attributes from the hook - for (const msg of messages) { - Object.assign(clientVars, msg); - } + // combine our old object with the new attributes from the hook + for (const msg of messages) { + Object.assign(clientVars, msg); + } - // Join the pad and start receiving updates - socket.join(sessionInfo.padId); + // Join the pad and start receiving updates + socket.join(sessionInfo.padId); - // Send the clientVars to the Client - socket.emit('message', {type: 'CLIENT_VARS', data: clientVars}); + // Send the clientVars to the Client + socket.emit("message", { type: "CLIENT_VARS", data: clientVars }); - // Save the current revision in sessioninfos, should be the same as in clientVars - sessionInfo.rev = pad.getHeadRevisionNumber(); - } + // Save the current revision in sessioninfos, should be the same as in clientVars + sessionInfo.rev = pad.getHeadRevisionNumber(); + } - // Notify other users about this new user. - socket.broadcast.to(sessionInfo.padId).emit('message', { - type: 'COLLABROOM', - data: { - type: 'USER_NEWINFO', - userInfo: { - colorId: authorColorId, - name: authorName, - userId: sessionInfo.author, - }, - }, - }); + // Notify other users about this new user. + socket.broadcast.to(sessionInfo.padId).emit("message", { + type: "COLLABROOM", + data: { + type: "USER_NEWINFO", + userInfo: { + colorId: authorColorId, + name: authorName, + userId: sessionInfo.author, + }, + }, + }); - // Notify this new user about other users. - await Promise.all(_getRoomSockets(pad.id).map(async (roomSocket) => { - if (roomSocket.id === socket.id) return; + // Notify this new user about other users. + await Promise.all( + _getRoomSockets(pad.id).map(async (roomSocket) => { + if (roomSocket.id === socket.id) return; - // sessioninfos might change while enumerating, so check if the sessionID is still assigned to a - // valid session. - const sessionInfo = sessioninfos[roomSocket.id]; - if (sessionInfo == null) return; + // sessioninfos might change while enumerating, so check if the sessionID is still assigned to a + // valid session. + const sessionInfo = sessioninfos[roomSocket.id]; + if (sessionInfo == null) return; - // get the authorname & colorId - const authorId = sessionInfo.author; - // The authorId of this other user might be unknown if the other user just connected and has - // not yet sent a CLIENT_READY message. - if (authorId == null) return; + // get the authorname & colorId + const authorId = sessionInfo.author; + // The authorId of this other user might be unknown if the other user just connected and has + // not yet sent a CLIENT_READY message. + if (authorId == null) return; - // reuse previously created cache of author's data - const authorInfo = historicalAuthorData[authorId] || await authorManager.getAuthor(authorId); - if (authorInfo == null) { - messageLogger.error( - `Author ${authorId} connected via socket.io session ${roomSocket.id} is missing from ` + - 'the global author database. This should never happen because the author ID is ' + - 'generated by the same code that adds the author to the database.'); - // Don't bother telling the new user about this mystery author. - return; - } + // reuse previously created cache of author's data + const authorInfo = + historicalAuthorData[authorId] || + (await authorManager.getAuthor(authorId)); + if (authorInfo == null) { + messageLogger.error( + `Author ${authorId} connected via socket.io session ${roomSocket.id} is missing from ` + + "the global author database. This should never happen because the author ID is " + + "generated by the same code that adds the author to the database.", + ); + // Don't bother telling the new user about this mystery author. + return; + } - const msg = { - type: 'COLLABROOM', - data: { - type: 'USER_NEWINFO', - userInfo: { - colorId: authorInfo.colorId, - name: authorInfo.name, - userId: authorId, - }, - }, - }; + const msg = { + type: "COLLABROOM", + data: { + type: "USER_NEWINFO", + userInfo: { + colorId: authorInfo.colorId, + name: authorInfo.name, + userId: authorId, + }, + }, + }; - socket.emit('message', msg); - })); + socket.emit("message", msg); + }), + ); - await hooks.aCallAll('userJoin', { - authorId: sessionInfo.author, - displayName: authorName, - padId: sessionInfo.padId, - readOnly: sessionInfo.readonly, - readOnlyPadId: sessionInfo.readOnlyPadId, - socket, - }); + await hooks.aCallAll("userJoin", { + authorId: sessionInfo.author, + displayName: authorName, + padId: sessionInfo.padId, + readOnly: sessionInfo.readonly, + readOnlyPadId: sessionInfo.readOnlyPadId, + socket, + }); }; /** * Handles a request for a rough changeset, the timeslider client needs it */ -const handleChangesetRequest = async (socket:any, {data: {granularity, start, requestID}}: ChangesetRequest) => { - if (granularity == null) throw new Error('missing granularity'); - if (!Number.isInteger(granularity)) throw new Error('granularity is not an integer'); - if (start == null) throw new Error('missing start'); - start = checkValidRev(start); - if (requestID == null) throw new Error('mising requestID'); - const end = start + (100 * granularity); - const {padId, author: authorId} = sessioninfos[socket.id]; - const pad = await padManager.getPad(padId, null, authorId); - const headRev = pad.getHeadRevisionNumber(); - if (start > headRev) - start = headRev; - const data:MapArrayType = await getChangesetInfo(pad, start, end, granularity); - data.requestID = requestID; - socket.emit('message', {type: 'CHANGESET_REQ', data}); +const handleChangesetRequest = async ( + socket: any, + { data: { granularity, start, requestID } }: ChangesetRequest, +) => { + if (granularity == null) throw new Error("missing granularity"); + if (!Number.isInteger(granularity)) + throw new Error("granularity is not an integer"); + if (start == null) throw new Error("missing start"); + start = checkValidRev(start); + if (requestID == null) throw new Error("mising requestID"); + const end = start + 100 * granularity; + const { padId, author: authorId } = sessioninfos[socket.id]; + const pad = await padManager.getPad(padId, null, authorId); + const headRev = pad.getHeadRevisionNumber(); + if (start > headRev) start = headRev; + const data: MapArrayType = await getChangesetInfo( + pad, + start, + end, + granularity, + ); + data.requestID = requestID; + socket.emit("message", { type: "CHANGESET_REQ", data }); }; /** * Tries to rebuild the getChangestInfo function of the original Etherpad * https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L144 */ -const getChangesetInfo = async (pad: PadType, startNum: number, endNum:number, granularity: number) => { - const headRevision = pad.getHeadRevisionNumber(); +const getChangesetInfo = async ( + pad: PadType, + startNum: number, + endNum: number, + granularity: number, +) => { + const headRevision = pad.getHeadRevisionNumber(); - // calculate the last full endnum - if (endNum > headRevision + 1) endNum = headRevision + 1; - endNum = Math.floor(endNum / granularity) * granularity; + // calculate the last full endnum + if (endNum > headRevision + 1) endNum = headRevision + 1; + endNum = Math.floor(endNum / granularity) * granularity; - const compositesChangesetNeeded = []; - const revTimesNeeded = []; + const compositesChangesetNeeded = []; + const revTimesNeeded = []; - // figure out which composite Changeset and revTimes we need, to load them in bulk - for (let start = startNum; start < endNum; start += granularity) { - const end = start + granularity; + // figure out which composite Changeset and revTimes we need, to load them in bulk + for (let start = startNum; start < endNum; start += granularity) { + const end = start + granularity; - // add the composite Changeset we needed - compositesChangesetNeeded.push({start, end}); + // add the composite Changeset we needed + compositesChangesetNeeded.push({ start, end }); - // add the t1 time we need - revTimesNeeded.push(start === 0 ? 0 : start - 1); + // add the t1 time we need + revTimesNeeded.push(start === 0 ? 0 : start - 1); - // add the t2 time we need - revTimesNeeded.push(end - 1); - } + // add the t2 time we need + revTimesNeeded.push(end - 1); + } - // Get all needed db values in parallel. - const composedChangesets:MapArrayType = {}; - const revisionDate:number[] = []; - const [lines] = await Promise.all([ - getPadLines(pad, startNum - 1), - // Get all needed composite Changesets. - ...compositesChangesetNeeded.map(async (item) => { - const changeset = await composePadChangesets(pad, item.start, item.end); - composedChangesets[`${item.start}/${item.end}`] = changeset; - }), - // Get all needed revision Dates. - ...revTimesNeeded.map(async (revNum) => { - const revDate = await pad.getRevisionDate(revNum); - revisionDate[revNum] = Math.floor(revDate / 1000); - }), - ]); + // Get all needed db values in parallel. + const composedChangesets: MapArrayType = {}; + const revisionDate: number[] = []; + const [lines] = await Promise.all([ + getPadLines(pad, startNum - 1), + // Get all needed composite Changesets. + ...compositesChangesetNeeded.map(async (item) => { + const changeset = await composePadChangesets(pad, item.start, item.end); + composedChangesets[`${item.start}/${item.end}`] = changeset; + }), + // Get all needed revision Dates. + ...revTimesNeeded.map(async (revNum) => { + const revDate = await pad.getRevisionDate(revNum); + revisionDate[revNum] = Math.floor(revDate / 1000); + }), + ]); - // doesn't know what happens here exactly :/ - const timeDeltas = []; - const forwardsChangesets = []; - const backwardsChangesets = []; - const apool = new AttributePool(); + // doesn't know what happens here exactly :/ + const timeDeltas = []; + const forwardsChangesets = []; + const backwardsChangesets = []; + const apool = new AttributePool(); - for (let compositeStart = startNum; compositeStart < endNum; compositeStart += granularity) { - const compositeEnd = compositeStart + granularity; - if (compositeEnd > endNum || compositeEnd > headRevision + 1) break; + for ( + let compositeStart = startNum; + compositeStart < endNum; + compositeStart += granularity + ) { + const compositeEnd = compositeStart + granularity; + if (compositeEnd > endNum || compositeEnd > headRevision + 1) break; - const forwards = composedChangesets[`${compositeStart}/${compositeEnd}`]; - const backwards = Changeset.inverse(forwards, lines.textlines, lines.alines, pad.apool()); + const forwards = composedChangesets[`${compositeStart}/${compositeEnd}`]; + const backwards = Changeset.inverse( + forwards, + lines.textlines, + lines.alines, + pad.apool(), + ); - Changeset.mutateAttributionLines(forwards, lines.alines, pad.apool()); - Changeset.mutateTextLines(forwards, lines.textlines); + Changeset.mutateAttributionLines(forwards, lines.alines, pad.apool()); + Changeset.mutateTextLines(forwards, lines.textlines); - const forwards2 = Changeset.moveOpsToNewPool(forwards, pad.apool(), apool); - const backwards2 = Changeset.moveOpsToNewPool(backwards, pad.apool(), apool); + const forwards2 = Changeset.moveOpsToNewPool(forwards, pad.apool(), apool); + const backwards2 = Changeset.moveOpsToNewPool( + backwards, + pad.apool(), + apool, + ); - const t1 = (compositeStart === 0) ? revisionDate[0] : revisionDate[compositeStart - 1]; - const t2 = revisionDate[compositeEnd - 1]; + const t1 = + compositeStart === 0 ? revisionDate[0] : revisionDate[compositeStart - 1]; + const t2 = revisionDate[compositeEnd - 1]; - timeDeltas.push(t2 - t1); - forwardsChangesets.push(forwards2); - backwardsChangesets.push(backwards2); - } + timeDeltas.push(t2 - t1); + forwardsChangesets.push(forwards2); + backwardsChangesets.push(backwards2); + } - return {forwardsChangesets, backwardsChangesets, - apool: apool.toJsonable(), actualEndNum: endNum, - timeDeltas, start: startNum, granularity}; + return { + forwardsChangesets, + backwardsChangesets, + apool: apool.toJsonable(), + actualEndNum: endNum, + timeDeltas, + start: startNum, + granularity, + }; }; /** @@ -1188,105 +1391,116 @@ const getChangesetInfo = async (pad: PadType, startNum: number, endNum:number, g * https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L263 */ const getPadLines = async (pad: PadType, revNum: number) => { - // get the atext - let atext; + // get the atext + let atext; - if (revNum >= 0) { - atext = await pad.getInternalRevisionAText(revNum); - } else { - atext = Changeset.makeAText('\n'); - } + if (revNum >= 0) { + atext = await pad.getInternalRevisionAText(revNum); + } else { + atext = Changeset.makeAText("\n"); + } - return { - textlines: Changeset.splitTextLines(atext.text), - alines: Changeset.splitAttributionLines(atext.attribs, atext.text), - }; + return { + textlines: Changeset.splitTextLines(atext.text), + alines: Changeset.splitAttributionLines(atext.attribs, atext.text), + }; }; /** * Tries to rebuild the composePadChangeset function of the original Etherpad * https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L241 */ -const composePadChangesets = async (pad: PadType, startNum: number, endNum: number) => { - // fetch all changesets we need - const headNum = pad.getHeadRevisionNumber(); - endNum = Math.min(endNum, headNum + 1); - startNum = Math.max(startNum, 0); +const composePadChangesets = async ( + pad: PadType, + startNum: number, + endNum: number, +) => { + // fetch all changesets we need + const headNum = pad.getHeadRevisionNumber(); + endNum = Math.min(endNum, headNum + 1); + startNum = Math.max(startNum, 0); - // create an array for all changesets, we will - // replace the values with the changeset later - const changesetsNeeded = []; - for (let r = startNum; r < endNum; r++) { - changesetsNeeded.push(r); - } + // create an array for all changesets, we will + // replace the values with the changeset later + const changesetsNeeded = []; + for (let r = startNum; r < endNum; r++) { + changesetsNeeded.push(r); + } - // get all changesets - const changesets:MapArrayType = {}; - await Promise.all(changesetsNeeded.map( - (revNum) => pad.getRevisionChangeset(revNum) - .then((changeset) => changesets[revNum] = changeset))); + // get all changesets + const changesets: MapArrayType = {}; + await Promise.all( + changesetsNeeded.map((revNum) => + pad + .getRevisionChangeset(revNum) + .then((changeset) => (changesets[revNum] = changeset)), + ), + ); - // compose Changesets - let r; - try { - let changeset = changesets[startNum]; - const pool = pad.apool(); + // compose Changesets + let r; + try { + let changeset = changesets[startNum]; + const pool = pad.apool(); - for (r = startNum + 1; r < endNum; r++) { - const cs = changesets[r]; - changeset = Changeset.compose(changeset, cs, pool); - } - return changeset; - } catch (e) { - // r-1 indicates the rev that was build starting with startNum, applying startNum+1, +2, +3 - messageLogger.warn( - `failed to compose cs in pad: ${pad.id} startrev: ${startNum} current rev: ${r}`); - throw e; - } + for (r = startNum + 1; r < endNum; r++) { + const cs = changesets[r]; + changeset = Changeset.compose(changeset, cs, pool); + } + return changeset; + } catch (e) { + // r-1 indicates the rev that was build starting with startNum, applying startNum+1, +2, +3 + messageLogger.warn( + `failed to compose cs in pad: ${pad.id} startrev: ${startNum} current rev: ${r}`, + ); + throw e; + } }; const _getRoomSockets = (padID: string) => { - const ns = socketio.sockets; // Default namespace. - // We could call adapter.clients(), but that method is unnecessarily asynchronous. Replicate what - // it does here, but synchronously to avoid a race condition. This code will have to change when - // we update to socket.io v3. - const room = ns.adapter.rooms?.get(padID); + const ns = socketio.sockets; // Default namespace. + // We could call adapter.clients(), but that method is unnecessarily asynchronous. Replicate what + // it does here, but synchronously to avoid a race condition. This code will have to change when + // we update to socket.io v3. + const room = ns.adapter.rooms?.get(padID); - if (!room) return []; + if (!room) return []; - return Array.from(room) - .map(socketId => ns.sockets.get(socketId)) - .filter(socket => socket); + return Array.from(room) + .map((socketId) => ns.sockets.get(socketId)) + .filter((socket) => socket); }; /** * Get the number of users in a pad */ -exports.padUsersCount = (padID:string) => ({ - padUsersCount: _getRoomSockets(padID).length, +exports.padUsersCount = (padID: string) => ({ + padUsersCount: _getRoomSockets(padID).length, }); /** * Get the list of users in a pad */ exports.padUsers = async (padID: string) => { - const padUsers:PadAuthor[] = []; + const padUsers: PadAuthor[] = []; - // iterate over all clients (in parallel) - await Promise.all(_getRoomSockets(padID).map(async (roomSocket) => { - const s = sessioninfos[roomSocket.id]; - if (s) { - const author = await authorManager.getAuthor(s.author); - // Fixes: https://github.com/ether/etherpad-lite/issues/4120 - // On restart author might not be populated? - if (author) { - author.id = s.author; - padUsers.push(author); - } - } - })); + // iterate over all clients (in parallel) + await Promise.all( + _getRoomSockets(padID).map(async (roomSocket) => { + const s = sessioninfos[roomSocket.id]; + if (s) { + const author = await authorManager.getAuthor(s.author); + // Fixes: https://github.com/ether/etherpad-lite/issues/4120 + // On restart author might not be populated? + if (author) { + author.id = s.author; + padUsers.push(author); + } + } + }), + ); - return {padUsers}; + return { padUsers }; }; exports.sessioninfos = sessioninfos; diff --git a/src/node/handler/SocketIORouter.ts b/src/node/handler/SocketIORouter.ts index 482276834..53035aa2d 100644 --- a/src/node/handler/SocketIORouter.ts +++ b/src/node/handler/SocketIORouter.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * This is the Socket.IO Router. It routes the Messages between the * components of the Server. The components are at the moment: pad and timeslider @@ -20,87 +20,98 @@ * limitations under the License. */ -import {MapArrayType} from "../types/MapType"; -import {SocketModule} from "../types/SocketModule"; -const log4js = require('log4js'); -const settings = require('../utils/Settings'); -const stats = require('../../node/stats') +import { MapArrayType } from "../types/MapType"; +import { SocketModule } from "../types/SocketModule"; +const log4js = require("log4js"); +const settings = require("../utils/Settings"); +const stats = require("../../node/stats"); -const logger = log4js.getLogger('socket.io'); +const logger = log4js.getLogger("socket.io"); /** * Saves all components * key is the component name * value is the component module */ -const components:MapArrayType = {}; +const components: MapArrayType = {}; -let io:any; +let io: any; /** adds a component * @param {string} moduleName * @param {Module} module */ exports.addComponent = (moduleName: string, module: SocketModule) => { - if (module == null) return exports.deleteComponent(moduleName); - components[moduleName] = module; - module.setSocketIO(io); + if (module == null) return exports.deleteComponent(moduleName); + components[moduleName] = module; + module.setSocketIO(io); }; /** * removes a component * @param {Module} moduleName */ -exports.deleteComponent = (moduleName: string) => { delete components[moduleName]; }; +exports.deleteComponent = (moduleName: string) => { + delete components[moduleName]; +}; /** * sets the socket.io and adds event functions for routing * @param {Object} _io the socket.io instance */ -exports.setSocketIO = (_io:any) => { - io = _io; +exports.setSocketIO = (_io: any) => { + io = _io; - io.sockets.on('connection', (socket:any) => { - const ip = settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip; - logger.debug(`${socket.id} connected from IP ${ip}`); + io.sockets.on("connection", (socket: any) => { + const ip = settings.disableIPlogging ? "ANONYMOUS" : socket.request.ip; + logger.debug(`${socket.id} connected from IP ${ip}`); - // wrap the original send function to log the messages - socket._send = socket.send; - socket.send = (message: string) => { - logger.debug(`to ${socket.id}: ${JSON.stringify(message)}`); - socket._send(message); - }; + // wrap the original send function to log the messages + socket._send = socket.send; + socket.send = (message: string) => { + logger.debug(`to ${socket.id}: ${JSON.stringify(message)}`); + socket._send(message); + }; - // tell all components about this connect - for (const i of Object.keys(components)) { - components[i].handleConnect(socket); - } + // tell all components about this connect + for (const i of Object.keys(components)) { + components[i].handleConnect(socket); + } - socket.on('message', (message: any, ack: any = () => {}) => (async () => { - if (!message.component || !components[message.component]) { - throw new Error(`unknown message component: ${message.component}`); - } - logger.debug(`from ${socket.id}:`, message); - return await components[message.component].handleMessage(socket, message); - })().then( - (val) => ack(null, val), - (err) => { - logger.error( - `Error handling ${message.component} message from ${socket.id}: ${err.stack || err}`); - ack({name: err.name, message: err.message}); // socket.io can't handle Error objects. - })); + socket.on("message", (message: any, ack: any = () => {}) => + (async () => { + if (!message.component || !components[message.component]) { + throw new Error(`unknown message component: ${message.component}`); + } + logger.debug(`from ${socket.id}:`, message); + return await components[message.component].handleMessage( + socket, + message, + ); + })().then( + (val) => ack(null, val), + (err) => { + logger.error( + `Error handling ${message.component} message from ${socket.id}: ${ + err.stack || err + }`, + ); + ack({ name: err.name, message: err.message }); // socket.io can't handle Error objects. + }, + ), + ); - socket.on('disconnect', (reason: string) => { - logger.debug(`${socket.id} disconnected: ${reason}`); - // store the lastDisconnect as a timestamp, this is useful if you want to know - // when the last user disconnected. If your activePads is 0 and totalUsers is 0 - // you can say, if there has been no active pads or active users for 10 minutes - // this instance can be brought out of a scaling cluster. - stats.gauge('lastDisconnect', () => Date.now()); - // tell all components about this disconnect - for (const i of Object.keys(components)) { - components[i].handleDisconnect(socket); - } - }); - }); + socket.on("disconnect", (reason: string) => { + logger.debug(`${socket.id} disconnected: ${reason}`); + // store the lastDisconnect as a timestamp, this is useful if you want to know + // when the last user disconnected. If your activePads is 0 and totalUsers is 0 + // you can say, if there has been no active pads or active users for 10 minutes + // this instance can be brought out of a scaling cluster. + stats.gauge("lastDisconnect", () => Date.now()); + // tell all components about this disconnect + for (const i of Object.keys(components)) { + components[i].handleDisconnect(socket); + } + }); + }); }; diff --git a/src/node/hooks/express.ts b/src/node/hooks/express.ts index 29da71ac3..7573509a5 100644 --- a/src/node/hooks/express.ts +++ b/src/node/hooks/express.ts @@ -1,261 +1,296 @@ -'use strict'; +"use strict"; -import {Socket} from "node:net"; -import type {MapArrayType} from "../types/MapType"; +import { Socket } from "node:net"; +import type { MapArrayType } from "../types/MapType"; -import _ from 'underscore'; +import _ from "underscore"; // @ts-ignore -import cookieParser from 'cookie-parser'; -import events from 'events'; -import express from 'express'; +import cookieParser from "cookie-parser"; +import events from "events"; +import express from "express"; // @ts-ignore -import expressSession from 'express-session'; -import fs from 'fs'; -const hooks = require('../../static/js/pluginfw/hooks'); -import log4js from 'log4js'; -const SessionStore = require('../db/SessionStore'); -const settings = require('../utils/Settings'); -const stats = require('../stats') -import util from 'util'; -const webaccess = require('./express/webaccess'); +import expressSession from "express-session"; +import fs from "fs"; +const hooks = require("../../static/js/pluginfw/hooks"); +import log4js from "log4js"; +const SessionStore = require("../db/SessionStore"); +const settings = require("../utils/Settings"); +const stats = require("../stats"); +import util from "util"; +const webaccess = require("./express/webaccess"); -import SecretRotator from '../security/SecretRotator'; +import SecretRotator from "../security/SecretRotator"; -let secretRotator: SecretRotator|null = null; -const logger = log4js.getLogger('http'); -let serverName:string; -let sessionStore: { shutdown: () => void; } | null; -const sockets:Set = new Set(); +let secretRotator: SecretRotator | null = null; +const logger = log4js.getLogger("http"); +let serverName: string; +let sessionStore: { shutdown: () => void } | null; +const sockets: Set = new Set(); const socketsEvents = new events.EventEmitter(); -const startTime = stats.settableGauge('httpStartTime'); +const startTime = stats.settableGauge("httpStartTime"); exports.server = null; const closeServer = async () => { - if (exports.server != null) { - logger.info('Closing HTTP server...'); - // Call exports.server.close() to reject new connections but don't await just yet because the - // Promise won't resolve until all preexisting connections are closed. - const p = util.promisify(exports.server.close.bind(exports.server))(); - await hooks.aCallAll('expressCloseServer'); - // Give existing connections some time to close on their own before forcibly terminating. The - // time should be long enough to avoid interrupting most preexisting transmissions but short - // enough to avoid a noticeable outage. - const timeout = setTimeout(async () => { - logger.info(`Forcibly terminating remaining ${sockets.size} HTTP connections...`); - for (const socket of sockets) socket.destroy(new Error('HTTP server is closing')); - }, 5000); - let lastLogged = 0; - while (sockets.size > 0 && !settings.enableAdminUITests) { - if (Date.now() - lastLogged > 1000) { // Rate limit to avoid filling logs. - logger.info(`Waiting for ${sockets.size} HTTP clients to disconnect...`); - lastLogged = Date.now(); - } - await events.once(socketsEvents, 'updated'); - } - await p; - clearTimeout(timeout); - exports.server = null; - startTime.setValue(0); - logger.info('HTTP server closed'); - } - if (sessionStore) sessionStore.shutdown(); - sessionStore = null; - if (secretRotator) secretRotator.stop(); - secretRotator = null; + if (exports.server != null) { + logger.info("Closing HTTP server..."); + // Call exports.server.close() to reject new connections but don't await just yet because the + // Promise won't resolve until all preexisting connections are closed. + const p = util.promisify(exports.server.close.bind(exports.server))(); + await hooks.aCallAll("expressCloseServer"); + // Give existing connections some time to close on their own before forcibly terminating. The + // time should be long enough to avoid interrupting most preexisting transmissions but short + // enough to avoid a noticeable outage. + const timeout = setTimeout(async () => { + logger.info( + `Forcibly terminating remaining ${sockets.size} HTTP connections...`, + ); + for (const socket of sockets) + socket.destroy(new Error("HTTP server is closing")); + }, 5000); + let lastLogged = 0; + while (sockets.size > 0 && !settings.enableAdminUITests) { + if (Date.now() - lastLogged > 1000) { + // Rate limit to avoid filling logs. + logger.info( + `Waiting for ${sockets.size} HTTP clients to disconnect...`, + ); + lastLogged = Date.now(); + } + await events.once(socketsEvents, "updated"); + } + await p; + clearTimeout(timeout); + exports.server = null; + startTime.setValue(0); + logger.info("HTTP server closed"); + } + if (sessionStore) sessionStore.shutdown(); + sessionStore = null; + if (secretRotator) secretRotator.stop(); + secretRotator = null; }; exports.createServer = async () => { - console.log('Report bugs at https://github.com/ether/etherpad-lite/issues'); + console.log("Report bugs at https://github.com/ether/etherpad-lite/issues"); - serverName = `Etherpad ${settings.getGitCommit()} (https://etherpad.org)`; + serverName = `Etherpad ${settings.getGitCommit()} (https://etherpad.org)`; - console.log(`Your Etherpad version is ${settings.getEpVersion()} (${settings.getGitCommit()})`); + console.log( + `Your Etherpad version is ${settings.getEpVersion()} (${settings.getGitCommit()})`, + ); - await exports.restartServer(); + await exports.restartServer(); - if (settings.ip === '') { - // using Unix socket for connectivity - console.log(`You can access your Etherpad instance using the Unix socket at ${settings.port}`); - } else { - console.log(`You can access your Etherpad instance at http://${settings.ip}:${settings.port}/`); - } + if (settings.ip === "") { + // using Unix socket for connectivity + console.log( + `You can access your Etherpad instance using the Unix socket at ${settings.port}`, + ); + } else { + console.log( + `You can access your Etherpad instance at http://${settings.ip}:${settings.port}/`, + ); + } - if (!_.isEmpty(settings.users)) { - console.log(`The plugin admin page is at http://${settings.ip}:${settings.port}/admin/plugins`); - } else { - console.warn('Admin username and password not set in settings.json. ' + - 'To access admin please uncomment and edit "users" in settings.json'); - } + if (!_.isEmpty(settings.users)) { + console.log( + `The plugin admin page is at http://${settings.ip}:${settings.port}/admin/plugins`, + ); + } else { + console.warn( + "Admin username and password not set in settings.json. " + + 'To access admin please uncomment and edit "users" in settings.json', + ); + } - const env = process.env.NODE_ENV || 'development'; + const env = process.env.NODE_ENV || "development"; - if (env !== 'production') { - console.warn('Etherpad is running in Development mode. This mode is slower for users and ' + - 'less secure than production mode. You should set the NODE_ENV environment ' + - 'variable to production by using: export NODE_ENV=production'); - } + if (env !== "production") { + console.warn( + "Etherpad is running in Development mode. This mode is slower for users and " + + "less secure than production mode. You should set the NODE_ENV environment " + + "variable to production by using: export NODE_ENV=production", + ); + } }; exports.restartServer = async () => { - await closeServer(); + await closeServer(); - const app = express(); // New syntax for express v3 + const app = express(); // New syntax for express v3 - if (settings.ssl) { - console.log('SSL -- enabled'); - console.log(`SSL -- server key file: ${settings.ssl.key}`); - console.log(`SSL -- Certificate Authority's certificate file: ${settings.ssl.cert}`); + if (settings.ssl) { + console.log("SSL -- enabled"); + console.log(`SSL -- server key file: ${settings.ssl.key}`); + console.log( + `SSL -- Certificate Authority's certificate file: ${settings.ssl.cert}`, + ); - const options: MapArrayType = { - key: fs.readFileSync(settings.ssl.key), - cert: fs.readFileSync(settings.ssl.cert), - }; + const options: MapArrayType = { + key: fs.readFileSync(settings.ssl.key), + cert: fs.readFileSync(settings.ssl.cert), + }; - if (settings.ssl.ca) { - options.ca = []; - for (let i = 0; i < settings.ssl.ca.length; i++) { - const caFileName = settings.ssl.ca[i]; - options.ca.push(fs.readFileSync(caFileName)); - } - } + if (settings.ssl.ca) { + options.ca = []; + for (let i = 0; i < settings.ssl.ca.length; i++) { + const caFileName = settings.ssl.ca[i]; + options.ca.push(fs.readFileSync(caFileName)); + } + } - const https = require('https'); - exports.server = https.createServer(options, app); - } else { - const http = require('http'); - exports.server = http.createServer(app); - } + const https = require("https"); + exports.server = https.createServer(options, app); + } else { + const http = require("http"); + exports.server = http.createServer(app); + } - app.use((req, res, next) => { - // res.header("X-Frame-Options", "deny"); // breaks embedded pads - if (settings.ssl) { - // we use SSL - res.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); - } + app.use((req, res, next) => { + // res.header("X-Frame-Options", "deny"); // breaks embedded pads + if (settings.ssl) { + // we use SSL + res.header( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains", + ); + } - // Stop IE going into compatability mode - // https://github.com/ether/etherpad-lite/issues/2547 - res.header('X-UA-Compatible', 'IE=Edge,chrome=1'); + // Stop IE going into compatability mode + // https://github.com/ether/etherpad-lite/issues/2547 + res.header("X-UA-Compatible", "IE=Edge,chrome=1"); - // Enable a strong referrer policy. Same-origin won't drop Referers when - // loading local resources, but it will drop them when loading foreign resources. - // It's still a last bastion of referrer security. External URLs should be - // already marked with rel="noreferer" and user-generated content pages are already - // marked with - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy - // https://github.com/ether/etherpad-lite/pull/3636 - res.header('Referrer-Policy', 'same-origin'); + // Enable a strong referrer policy. Same-origin won't drop Referers when + // loading local resources, but it will drop them when loading foreign resources. + // It's still a last bastion of referrer security. External URLs should be + // already marked with rel="noreferer" and user-generated content pages are already + // marked with + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy + // https://github.com/ether/etherpad-lite/pull/3636 + res.header("Referrer-Policy", "same-origin"); - // send git version in the Server response header if exposeVersion is true. - if (settings.exposeVersion) { - res.header('Server', serverName); - } + // send git version in the Server response header if exposeVersion is true. + if (settings.exposeVersion) { + res.header("Server", serverName); + } - next(); - }); + next(); + }); - if (settings.trustProxy) { - /* - * If 'trust proxy' === true, the client’s IP address in req.ip will be the - * left-most entry in the X-Forwarded-* header. - * - * Source: https://expressjs.com/en/guide/behind-proxies.html - */ - app.enable('trust proxy'); - } + if (settings.trustProxy) { + /* + * If 'trust proxy' === true, the client’s IP address in req.ip will be the + * left-most entry in the X-Forwarded-* header. + * + * Source: https://expressjs.com/en/guide/behind-proxies.html + */ + app.enable("trust proxy"); + } - // Measure response time - app.use((req, res, next) => { - const stopWatch = stats.timer('httpRequests').start(); - const sendFn = res.send.bind(res); - res.send = (...args) => { stopWatch.end(); return sendFn(...args); }; - next(); - }); + // Measure response time + app.use((req, res, next) => { + const stopWatch = stats.timer("httpRequests").start(); + const sendFn = res.send.bind(res); + res.send = (...args) => { + stopWatch.end(); + return sendFn(...args); + }; + next(); + }); - // If the log level specified in the config file is WARN or ERROR the application server never - // starts listening to requests as reported in issue #158. Not installing the log4js connect - // logger when the log level has a higher severity than INFO since it would not log at that level - // anyway. - if (!(settings.loglevel === 'WARN' && settings.loglevel === 'ERROR')) { - app.use(log4js.connectLogger(logger, { - level: log4js.levels.DEBUG.levelStr, - format: ':status, :method :url', - })); - } + // If the log level specified in the config file is WARN or ERROR the application server never + // starts listening to requests as reported in issue #158. Not installing the log4js connect + // logger when the log level has a higher severity than INFO since it would not log at that level + // anyway. + if (!(settings.loglevel === "WARN" && settings.loglevel === "ERROR")) { + app.use( + log4js.connectLogger(logger, { + level: log4js.levels.DEBUG.levelStr, + format: ":status, :method :url", + }), + ); + } - 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'); + 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, {})); + app.use(cookieParser(secret, {})); - sessionStore = new SessionStore(settings.cookie.sessionRefreshInterval); - exports.sessionMiddleware = expressSession({ - propagateTouch: true, - rolling: true, - secret, - store: sessionStore, - resave: false, - saveUninitialized: false, - // Set the cookie name to a javascript identifier compatible string. Makes code handling it - // cleaner :) - name: 'express_sid', - cookie: { - maxAge: sessionLifetime || null, // Convert 0 to null. - sameSite: settings.cookie.sameSite, + sessionStore = new SessionStore(settings.cookie.sessionRefreshInterval); + exports.sessionMiddleware = expressSession({ + propagateTouch: true, + rolling: true, + secret, + store: sessionStore, + resave: false, + saveUninitialized: false, + // Set the cookie name to a javascript identifier compatible string. Makes code handling it + // cleaner :) + name: "express_sid", + cookie: { + maxAge: sessionLifetime || null, // Convert 0 to null. + sameSite: settings.cookie.sameSite, - // The automatic express-session mechanism for determining if the application is being served - // over ssl is similar to the one used for setting the language cookie, which check if one of - // these conditions is true: - // - // 1. we are directly serving the nodejs application over SSL, using the "ssl" options in - // settings.json - // - // 2. we are serving the nodejs application in plaintext, but we are using a reverse proxy - // that terminates SSL for us. In this case, the user has to set trustProxy = true in - // settings.json, and the information wheter the application is over SSL or not will be - // extracted from the X-Forwarded-Proto HTTP header - // - // Please note that this will not be compatible with applications being served over http and - // https at the same time. - // - // reference: https://github.com/expressjs/session/blob/v1.17.0/README.md#cookiesecure - secure: 'auto', - }, - }); + // The automatic express-session mechanism for determining if the application is being served + // over ssl is similar to the one used for setting the language cookie, which check if one of + // these conditions is true: + // + // 1. we are directly serving the nodejs application over SSL, using the "ssl" options in + // settings.json + // + // 2. we are serving the nodejs application in plaintext, but we are using a reverse proxy + // that terminates SSL for us. In this case, the user has to set trustProxy = true in + // settings.json, and the information wheter the application is over SSL or not will be + // extracted from the X-Forwarded-Proto HTTP header + // + // Please note that this will not be compatible with applications being served over http and + // https at the same time. + // + // reference: https://github.com/expressjs/session/blob/v1.17.0/README.md#cookiesecure + secure: "auto", + }, + }); - // Give plugins an opportunity to install handlers/middleware before the express-session - // middleware. This allows plugins to avoid creating an express-session record in the database - // when it is not needed (e.g., public static content). - await hooks.aCallAll('expressPreSession', {app}); - app.use(exports.sessionMiddleware); + // Give plugins an opportunity to install handlers/middleware before the express-session + // middleware. This allows plugins to avoid creating an express-session record in the database + // when it is not needed (e.g., public static content). + await hooks.aCallAll("expressPreSession", { app }); + app.use(exports.sessionMiddleware); - app.use(webaccess.checkAccess); + app.use(webaccess.checkAccess); - await Promise.all([ - hooks.aCallAll('expressConfigure', {app}), - hooks.aCallAll('expressCreateServer', {app, server: exports.server}), - ]); - exports.server.on('connection', (socket:Socket) => { - sockets.add(socket); - socketsEvents.emit('updated'); - socket.on('close', () => { - sockets.delete(socket); - socketsEvents.emit('updated'); - }); - }); - await util.promisify(exports.server.listen).bind(exports.server)(settings.port, settings.ip); - startTime.setValue(Date.now()); - logger.info('HTTP server listening for connections'); + await Promise.all([ + hooks.aCallAll("expressConfigure", { app }), + hooks.aCallAll("expressCreateServer", { app, server: exports.server }), + ]); + exports.server.on("connection", (socket: Socket) => { + sockets.add(socket); + socketsEvents.emit("updated"); + socket.on("close", () => { + sockets.delete(socket); + socketsEvents.emit("updated"); + }); + }); + await util.promisify(exports.server.listen).bind(exports.server)( + settings.port, + settings.ip, + ); + startTime.setValue(Date.now()); + logger.info("HTTP server listening for connections"); }; -exports.shutdown = async (hookName:string, context: any) => { - await closeServer(); +exports.shutdown = async (hookName: string, context: any) => { + await closeServer(); }; diff --git a/src/node/hooks/express/admin.ts b/src/node/hooks/express/admin.ts index 5a88379f2..a35390e20 100644 --- a/src/node/hooks/express/admin.ts +++ b/src/node/hooks/express/admin.ts @@ -1,11 +1,11 @@ -'use strict'; -import {ArgsExpressType} from "../../types/ArgsExpressType"; +"use strict"; +import { ArgsExpressType } from "../../types/ArgsExpressType"; import path from "path"; import fs from "fs"; import express from "express"; -const settings = require('ep_etherpad-lite/node/utils/Settings'); +const settings = require("ep_etherpad-lite/node/utils/Settings"); -const ADMIN_PATH = path.join(settings.root, 'src', 'templates', 'admin'); +const ADMIN_PATH = path.join(settings.root, "src", "templates", "admin"); /** * Add the admin navigation link @@ -14,13 +14,24 @@ const ADMIN_PATH = path.join(settings.root, 'src', 'templates', 'admin'); * @param {Function} cb the callback function * @return {*} */ -exports.expressCreateServer = (hookName:string, args: ArgsExpressType, cb:Function): any => { - args.app.use('/admin/', express.static(path.join(__dirname, '../../../templates/admin'), {maxAge: 1000 * 60 * 60 * 24})); - args.app.get('/admin/*', (_request:any, response:any)=>{ - response.sendFile(path.resolve(__dirname,'../../../templates/admin', 'index.html')); - } ) - args.app.get('/admin', (req:any, res:any, next:Function) => { - if ('/' !== req.path[req.path.length - 1]) return res.redirect('./admin/'); - }) - return cb(); +exports.expressCreateServer = ( + hookName: string, + args: ArgsExpressType, + cb: Function, +): any => { + args.app.use( + "/admin/", + express.static(path.join(__dirname, "../../../templates/admin"), { + maxAge: 1000 * 60 * 60 * 24, + }), + ); + args.app.get("/admin/*", (_request: any, response: any) => { + response.sendFile( + path.resolve(__dirname, "../../../templates/admin", "index.html"), + ); + }); + args.app.get("/admin", (req: any, res: any, next: Function) => { + if ("/" !== req.path[req.path.length - 1]) return res.redirect("./admin/"); + }); + return cb(); }; diff --git a/src/node/hooks/express/adminplugins.ts b/src/node/hooks/express/adminplugins.ts index 502799197..1a8ca2312 100644 --- a/src/node/hooks/express/adminplugins.ts +++ b/src/node/hooks/express/adminplugins.ts @@ -1,101 +1,118 @@ -'use strict'; +"use strict"; -import {ArgsExpressType} from "../../types/ArgsExpressType"; -import {ErrorCaused} from "../../types/ErrorCaused"; -import {QueryType} from "../../types/QueryType"; +import { ArgsExpressType } from "../../types/ArgsExpressType"; +import { ErrorCaused } from "../../types/ErrorCaused"; +import { QueryType } from "../../types/QueryType"; -import {getAvailablePlugins, install, search, uninstall} from "../../../static/js/pluginfw/installer"; -import {PackageData} from "../../types/PackageInfo"; +import { + getAvailablePlugins, + install, + search, + uninstall, +} from "../../../static/js/pluginfw/installer"; +import { PackageData } from "../../types/PackageInfo"; -const pluginDefs = require('../../../static/js/pluginfw/plugin_defs'); -import semver from 'semver'; +const pluginDefs = require("../../../static/js/pluginfw/plugin_defs"); +import semver from "semver"; +exports.socketio = (hookName: string, args: ArgsExpressType, cb: Function) => { + const io = args.io.of("/pluginfw/installer"); + io.on("connection", (socket: any) => { + // @ts-ignore + const { + session: { + user: { is_admin: isAdmin } = {}, + } = {}, + } = socket.conn.request; + if (!isAdmin) return; -exports.socketio = (hookName:string, args:ArgsExpressType, cb:Function) => { - const io = args.io.of('/pluginfw/installer'); - io.on('connection', (socket:any) => { - // @ts-ignore - const {session: {user: {is_admin: isAdmin} = {}} = {}} = socket.conn.request; - if (!isAdmin) return; + socket.on("getInstalled", (query: string) => { + // send currently installed plugins + const installed = Object.keys(pluginDefs.plugins).map( + (plugin) => pluginDefs.plugins[plugin].package, + ); - socket.on('getInstalled', (query:string) => { - // send currently installed plugins - const installed = - Object.keys(pluginDefs.plugins).map((plugin) => pluginDefs.plugins[plugin].package); + socket.emit("results:installed", { installed }); + }); - socket.emit('results:installed', {installed}); - }); + socket.on("checkUpdates", async () => { + // Check plugins for updates + try { + const results = await getAvailablePlugins(/* maxCacheAge:*/ 60 * 10); - socket.on('checkUpdates', async () => { - // Check plugins for updates - try { - const results = await getAvailablePlugins(/* maxCacheAge:*/ 60 * 10); + const updatable = Object.keys(pluginDefs.plugins).filter((plugin) => { + if (!results[plugin]) return false; - const updatable = Object.keys(pluginDefs.plugins).filter((plugin) => { - if (!results[plugin]) return false; + const latestVersion = results[plugin].version; + const currentVersion = pluginDefs.plugins[plugin].package.version; - const latestVersion = results[plugin].version; - const currentVersion = pluginDefs.plugins[plugin].package.version; + return semver.gt(latestVersion, currentVersion); + }); - return semver.gt(latestVersion, currentVersion); - }); + socket.emit("results:updatable", { updatable }); + } catch (err) { + const errc = err as ErrorCaused; + console.warn(errc.stack || errc.toString()); - socket.emit('results:updatable', {updatable}); - } catch (err) { - const errc = err as ErrorCaused - console.warn(errc.stack || errc.toString()); + socket.emit("results:updatable", { updatable: {} }); + } + }); - socket.emit('results:updatable', {updatable: {}}); - } - }); + socket.on("getAvailable", async (query: string) => { + try { + const results = await getAvailablePlugins(/* maxCacheAge:*/ false); + socket.emit("results:available", results); + } catch (er) { + console.error(er); + socket.emit("results:available", {}); + } + }); - socket.on('getAvailable', async (query:string) => { - try { - const results = await getAvailablePlugins(/* maxCacheAge:*/ false); - socket.emit('results:available', results); - } catch (er) { - console.error(er); - socket.emit('results:available', {}); - } - }); + socket.on("search", async (query: QueryType) => { + try { + const results = await search( + query.searchTerm, + /* maxCacheAge:*/ 60 * 10, + ); + let res = Object.keys(results) + .map((pluginName) => results[pluginName]) + .filter((plugin) => !pluginDefs.plugins[plugin.name]); + res = sortPluginList(res, query.sortBy, query.sortDir).slice( + query.offset, + query.offset + query.limit, + ); + socket.emit("results:search", { results: res, query }); + } catch (er) { + console.error(er); - socket.on('search', async (query: QueryType) => { - try { - const results = await search(query.searchTerm, /* maxCacheAge:*/ 60 * 10); - let res = Object.keys(results) - .map((pluginName) => results[pluginName]) - .filter((plugin) => !pluginDefs.plugins[plugin.name]); - res = sortPluginList(res, query.sortBy, query.sortDir) - .slice(query.offset, query.offset + query.limit); - socket.emit('results:search', {results: res, query}); - } catch (er) { - console.error(er); + socket.emit("results:search", { results: {}, query }); + } + }); - socket.emit('results:search', {results: {}, query}); - } - }); + socket.on("install", (pluginName: string) => { + install(pluginName, (err: ErrorCaused) => { + if (err) console.warn(err.stack || err.toString()); - socket.on('install', (pluginName: string) => { - install(pluginName, (err: ErrorCaused) => { - if (err) console.warn(err.stack || err.toString()); + socket.emit("finished:install", { + plugin: pluginName, + code: err ? err.code : null, + error: err ? err.message : null, + }); + }); + }); - socket.emit('finished:install', { - plugin: pluginName, - code: err ? err.code : null, - error: err ? err.message : null, - }); - }); - }); + socket.on("uninstall", (pluginName: string) => { + uninstall(pluginName, (err: ErrorCaused) => { + if (err) console.warn(err.stack || err.toString()); - socket.on('uninstall', (pluginName:string) => { - uninstall(pluginName, (err:ErrorCaused) => { - if (err) console.warn(err.stack || err.toString()); - - socket.emit('finished:uninstall', {plugin: pluginName, error: err ? err.message : null}); - }); - }); - }); - return cb(); + socket.emit("finished:uninstall", { + plugin: pluginName, + error: err ? err.message : null, + }); + }); + }); + }); + return cb(); }; /** @@ -105,17 +122,22 @@ exports.socketio = (hookName:string, args:ArgsExpressType, cb:Function) => { * @param {String} dir The directory of the plugin * @return {Object[]} */ -const sortPluginList = (plugins:PackageData[], property:string, /* ASC?*/dir:string): PackageData[] => plugins.sort((a, b) => { - // @ts-ignore - if (a[property] < b[property]) { - return dir ? -1 : 1; - } +const sortPluginList = ( + plugins: PackageData[], + property: string, + /* ASC?*/ dir: string, +): PackageData[] => + plugins.sort((a, b) => { + // @ts-ignore + if (a[property] < b[property]) { + return dir ? -1 : 1; + } - // @ts-ignore - if (a[property] > b[property]) { - return dir ? 1 : -1; - } + // @ts-ignore + if (a[property] > b[property]) { + return dir ? 1 : -1; + } - // a must be equal to b - return 0; -}); + // a must be equal to b + return 0; + }); diff --git a/src/node/hooks/express/adminsettings.ts b/src/node/hooks/express/adminsettings.ts index 03258c584..35ff801a1 100644 --- a/src/node/hooks/express/adminsettings.ts +++ b/src/node/hooks/express/adminsettings.ts @@ -1,211 +1,225 @@ -'use strict'; +"use strict"; +import { PadQueryResult, PadSearchQuery } from "../../types/PadSearchQuery"; +import { PadType } from "../../types/PadType"; -import {PadQueryResult, PadSearchQuery} from "../../types/PadSearchQuery"; -import {PadType} from "../../types/PadType"; - -const eejs = require('../../eejs'); -const fsp = require('fs').promises; -const hooks = require('../../../static/js/pluginfw/hooks'); -const plugins = require('../../../static/js/pluginfw/plugins'); -const settings = require('../../utils/Settings'); -const UpdateCheck = require('../../utils/UpdateCheck'); -const padManager = require('../../db/PadManager'); -const api = require('../../db/API'); - +const eejs = require("../../eejs"); +const fsp = require("fs").promises; +const hooks = require("../../../static/js/pluginfw/hooks"); +const plugins = require("../../../static/js/pluginfw/plugins"); +const settings = require("../../utils/Settings"); +const UpdateCheck = require("../../utils/UpdateCheck"); +const padManager = require("../../db/PadManager"); +const api = require("../../db/API"); const queryPadLimit = 12; +exports.socketio = (hookName: string, { io }: any) => { + io.of("/settings").on("connection", (socket: any) => { + // @ts-ignore + const { + session: { + user: { is_admin: isAdmin } = {}, + } = {}, + } = socket.conn.request; + if (!isAdmin) return; -exports.socketio = (hookName:string, {io}:any) => { - io.of('/settings').on('connection', (socket: any ) => { - // @ts-ignore - const {session: {user: {is_admin: isAdmin} = {}} = {}} = socket.conn.request; - if (!isAdmin) return; + socket.on("load", async (query: string): Promise => { + let data; + try { + data = await fsp.readFile(settings.settingsFilename, "utf8"); + } catch (err) { + return console.log(err); + } + // if showSettingsInAdminPage is set to false, then return NOT_ALLOWED in the result + if (settings.showSettingsInAdminPage === false) { + socket.emit("settings", { results: "NOT_ALLOWED" }); + } else { + socket.emit("settings", { results: data }); + } + }); - socket.on('load', async (query:string):Promise => { - let data; - try { - data = await fsp.readFile(settings.settingsFilename, 'utf8'); - } catch (err) { - return console.log(err); - } - // if showSettingsInAdminPage is set to false, then return NOT_ALLOWED in the result - if (settings.showSettingsInAdminPage === false) { - socket.emit('settings', {results: 'NOT_ALLOWED'}); - } else { - socket.emit('settings', {results: data}); - } - }); + socket.on("saveSettings", async (newSettings: string) => { + console.log( + "Admin request to save settings through a socket on /admin/settings", + ); + await fsp.writeFile(settings.settingsFilename, newSettings); + socket.emit("saveprogress", "saved"); + }); - socket.on('saveSettings', async (newSettings:string) => { - console.log('Admin request to save settings through a socket on /admin/settings'); - await fsp.writeFile(settings.settingsFilename, newSettings); - socket.emit('saveprogress', 'saved'); - }); + socket.on("help", () => { + const gitCommit = settings.getGitCommit(); + const epVersion = settings.getEpVersion(); + const hooks: Map> = plugins.getHooks( + "hooks", + false, + ); + const clientHooks: Map> = plugins.getHooks( + "client_hooks", + false, + ); - socket.on('help', ()=> { - const gitCommit = settings.getGitCommit(); - const epVersion = settings.getEpVersion(); + function mapToObject(map: Map) { + let obj = Object.create(null); + for (let [k, v] of map) { + if (v instanceof Map) { + obj[k] = mapToObject(v); + } else { + obj[k] = v; + } + } + return obj; + } - const hooks:Map> = plugins.getHooks('hooks', false); - const clientHooks:Map> = plugins.getHooks('client_hooks', false); + socket.emit("reply:help", { + gitCommit, + epVersion, + installedPlugins: plugins.getPlugins(), + installedParts: plugins.getParts(), + installedServerHooks: mapToObject(hooks), + installedClientHooks: mapToObject(clientHooks), + latestVersion: UpdateCheck.getLatestVersion(), + }); + }); - function mapToObject(map: Map) { - let obj = Object.create(null); - for (let [k,v] of map) { - if(v instanceof Map) { - obj[k] = mapToObject(v); - } else { - obj[k] = v; - } - } - return obj; - } + socket.on("padLoad", async (query: PadSearchQuery) => { + const { padIDs } = await padManager.listAllPads(); - socket.emit('reply:help', { - gitCommit, - epVersion, - installedPlugins: plugins.getPlugins(), - installedParts: plugins.getParts(), - installedServerHooks: mapToObject(hooks), - installedClientHooks: mapToObject(clientHooks), - latestVersion: UpdateCheck.getLatestVersion(), - }) - }); + const data: { + total: number; + results?: PadQueryResult[]; + } = { + total: padIDs.length, + }; + let result: string[] = padIDs; + let maxResult; + // Filter out matches + if (query.pattern) { + result = result.filter((padName: string) => + padName.includes(query.pattern), + ); + } - socket.on('padLoad', async (query: PadSearchQuery) => { - const {padIDs} = await padManager.listAllPads(); + data.total = result.length; - const data:{ - total: number, - results?: PadQueryResult[] - } = { - total: padIDs.length, - }; - let result: string[] = padIDs; - let maxResult; + maxResult = result.length - 1; + if (maxResult < 0) { + maxResult = 0; + } - // Filter out matches - if (query.pattern) { - result = result.filter((padName: string) => padName.includes(query.pattern)); - } + if (query.offset && query.offset < 0) { + query.offset = 0; + } else if (query.offset > maxResult) { + query.offset = maxResult; + } - data.total = result.length; + if (query.limit && query.limit < 0) { + query.limit = 0; + } else if (query.limit > queryPadLimit) { + query.limit = queryPadLimit; + } - maxResult = result.length - 1; - if (maxResult < 0) { - maxResult = 0; - } + if (query.sortBy === "padName") { + result = result + .sort((a, b) => { + if (a < b) return query.ascending ? -1 : 1; + if (a > b) return query.ascending ? 1 : -1; + return 0; + }) + .slice(query.offset, query.offset + query.limit); - if (query.offset && query.offset < 0) { - query.offset = 0; - } else if (query.offset > maxResult) { - query.offset = maxResult; - } + data.results = await Promise.all( + result.map(async (padName: string) => { + const pad = await padManager.getPad(padName); + const revisionNumber = pad.getHeadRevisionNumber(); + const userCount = api.padUsersCount(padName).padUsersCount; + const lastEdited = await pad.getLastEdit(); - if (query.limit && query.limit < 0) { - query.limit = 0; - } else if (query.limit > queryPadLimit) { - query.limit = queryPadLimit; - } + return { + padName, + lastEdited, + userCount, + revisionNumber, + }; + }), + ); + } else { + const currentWinners: PadQueryResult[] = []; + let queryOffsetCounter = 0; + for (let res of result) { + const pad = await padManager.getPad(res); + const padType = { + padName: res, + lastEdited: await pad.getLastEdit(), + userCount: api.padUsersCount(res).padUsersCount, + revisionNumber: pad.getHeadRevisionNumber(), + }; - if (query.sortBy === 'padName') { - result = result.sort((a,b)=>{ - if(a < b) return query.ascending ? -1 : 1; - if(a > b) return query.ascending ? 1 : -1; - return 0; - }).slice(query.offset, query.offset + query.limit); + if (currentWinners.length < query.limit) { + if (queryOffsetCounter < query.offset) { + queryOffsetCounter++; + continue; + } + currentWinners.push({ + padName: res, + lastEdited: await pad.getLastEdit(), + userCount: api.padUsersCount(res).padUsersCount, + revisionNumber: pad.getHeadRevisionNumber(), + }); + } else { + // Kick out worst pad and replace by current pad + let worstPad = currentWinners.sort((a, b) => { + if (a[query.sortBy] < b[query.sortBy]) + return query.ascending ? -1 : 1; + if (a[query.sortBy] > b[query.sortBy]) + return query.ascending ? 1 : -1; + return 0; + }); + if ( + worstPad[0] && + worstPad[0][query.sortBy] < padType[query.sortBy] + ) { + if (queryOffsetCounter < query.offset) { + queryOffsetCounter++; + continue; + } + currentWinners.splice(currentWinners.indexOf(worstPad[0]), 1); + currentWinners.push({ + padName: res, + lastEdited: await pad.getLastEdit(), + userCount: api.padUsersCount(res).padUsersCount, + revisionNumber: pad.getHeadRevisionNumber(), + }); + } + } + } + data.results = currentWinners; + } - data.results = await Promise.all(result.map(async (padName: string) => { - const pad = await padManager.getPad(padName); - const revisionNumber = pad.getHeadRevisionNumber() - const userCount = api.padUsersCount(padName).padUsersCount; - const lastEdited = await pad.getLastEdit(); + socket.emit("results:padLoad", data); + }); - return { - padName, - lastEdited, - userCount, - revisionNumber - }})); - } else { - const currentWinners: PadQueryResult[] = [] - let queryOffsetCounter = 0 - for (let res of result) { + socket.on("deletePad", async (padId: string) => { + const padExists = await padManager.doesPadExists(padId); + if (padExists) { + const pad = await padManager.getPad(padId); + await pad.remove(); + socket.emit("results:deletePad", padId); + } + }); - const pad = await padManager.getPad(res); - const padType = { - padName: res, - lastEdited: await pad.getLastEdit(), - userCount: api.padUsersCount(res).padUsersCount, - revisionNumber: pad.getHeadRevisionNumber() - }; - - if (currentWinners.length < query.limit) { - if(queryOffsetCounter < query.offset){ - queryOffsetCounter++ - continue - } - currentWinners.push({ - padName: res, - lastEdited: await pad.getLastEdit(), - userCount: api.padUsersCount(res).padUsersCount, - revisionNumber: pad.getHeadRevisionNumber() - }) - } else { - // Kick out worst pad and replace by current pad - let worstPad = currentWinners.sort((a, b) => { - if (a[query.sortBy] < b[query.sortBy]) return query.ascending ? -1 : 1; - if (a[query.sortBy] > b[query.sortBy]) return query.ascending ? 1 : -1; - return 0; - }) - if(worstPad[0]&&worstPad[0][query.sortBy] < padType[query.sortBy]){ - if(queryOffsetCounter < query.offset){ - queryOffsetCounter++ - continue - } - currentWinners.splice(currentWinners.indexOf(worstPad[0]), 1) - currentWinners.push({ - padName: res, - lastEdited: await pad.getLastEdit(), - userCount: api.padUsersCount(res).padUsersCount, - revisionNumber: pad.getHeadRevisionNumber() - }) - } - } - } - data.results = currentWinners; - } - - socket.emit('results:padLoad', data); - }) - - - socket.on('deletePad', async (padId: string) => { - const padExists = await padManager.doesPadExists(padId); - if (padExists) { - const pad = await padManager.getPad(padId); - await pad.remove(); - socket.emit('results:deletePad', padId); - } - }) - - socket.on('restartServer', async () => { - console.log('Admin request to restart server through a socket on /admin/settings'); - settings.reloadSettings(); - await plugins.update(); - await hooks.aCallAll('loadSettings', {settings}); - await hooks.aCallAll('restartServer'); - }); - }); + socket.on("restartServer", async () => { + console.log( + "Admin request to restart server through a socket on /admin/settings", + ); + settings.reloadSettings(); + await plugins.update(); + await hooks.aCallAll("loadSettings", { settings }); + await hooks.aCallAll("restartServer"); + }); + }); }; - - -const searchPad = async (query:PadSearchQuery) => { - -} - +const searchPad = async (query: PadSearchQuery) => {}; diff --git a/src/node/hooks/express/apicalls.ts b/src/node/hooks/express/apicalls.ts index 91c44e389..29be6a71c 100644 --- a/src/node/hooks/express/apicalls.ts +++ b/src/node/hooks/express/apicalls.ts @@ -1,44 +1,47 @@ -'use strict'; +"use strict"; -const log4js = require('log4js'); -const clientLogger = log4js.getLogger('client'); -const {Formidable} = require('formidable'); -const apiHandler = require('../../handler/APIHandler'); -const util = require('util'); +const log4js = require("log4js"); +const clientLogger = log4js.getLogger("client"); +const { Formidable } = require("formidable"); +const apiHandler = require("../../handler/APIHandler"); +const util = require("util"); -exports.expressPreSession = async (hookName:string, {app}:any) => { - // The Etherpad client side sends information about how a disconnect happened - app.post('/ep/pad/connection-diagnostic-info', async (req:any, res:any) => { - const [fields, files] = await (new Formidable({})).parse(req); - clientLogger.info(`DIAGNOSTIC-INFO: ${fields.diagnosticInfo}`); - res.end('OK'); - }); +exports.expressPreSession = async (hookName: string, { app }: any) => { + // The Etherpad client side sends information about how a disconnect happened + app.post("/ep/pad/connection-diagnostic-info", async (req: any, res: any) => { + const [fields, files] = await new Formidable({}).parse(req); + clientLogger.info(`DIAGNOSTIC-INFO: ${fields.diagnosticInfo}`); + res.end("OK"); + }); - const parseJserrorForm = async (req:any) => { - const form = new Formidable({ - maxFileSize: 1, // Files are not expected. Not sure if 0 means unlimited, so 1 is used. - }); - const [fields, files] = await form.parse(req); - return fields.errorInfo; - }; + const parseJserrorForm = async (req: any) => { + const form = new Formidable({ + maxFileSize: 1, // Files are not expected. Not sure if 0 means unlimited, so 1 is used. + }); + 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:any, res:any, next:Function) => { - (async () => { - const data = JSON.parse(await parseJserrorForm(req)); - clientLogger.warn(`${data.msg} --`, { - [util.inspect.custom]: (depth: number, options:any) => { - // Depth is forced to infinity to ensure that all of the provided data is logged. - options = Object.assign({}, options, {depth: Infinity, colors: true}); - return util.inspect(data, options); - }, - }); - res.end('OK'); - })().catch((err) => next(err || new Error(err))); - }); + // The Etherpad client side sends information about client side javscript errors + app.post("/jserror", (req: any, res: any, next: Function) => { + (async () => { + const data = JSON.parse(await parseJserrorForm(req)); + clientLogger.warn(`${data.msg} --`, { + [util.inspect.custom]: (depth: number, options: any) => { + // Depth is forced to infinity to ensure that all of the provided data is logged. + options = Object.assign({}, options, { + depth: Infinity, + colors: true, + }); + return util.inspect(data, options); + }, + }); + res.end("OK"); + })().catch((err) => next(err || new Error(err))); + }); - // Provide a possibility to query the latest available API version - app.get('/api', (req:any, res:any) => { - res.json({currentVersion: apiHandler.latestApiVersion}); - }); + // Provide a possibility to query the latest available API version + app.get("/api", (req: any, res: any) => { + res.json({ currentVersion: apiHandler.latestApiVersion }); + }); }; diff --git a/src/node/hooks/express/errorhandling.ts b/src/node/hooks/express/errorhandling.ts index 2de819b0e..98691f3d5 100644 --- a/src/node/hooks/express/errorhandling.ts +++ b/src/node/hooks/express/errorhandling.ts @@ -1,22 +1,26 @@ -'use strict'; +"use strict"; -import {ArgsExpressType} from "../../types/ArgsExpressType"; -import {ErrorCaused} from "../../types/ErrorCaused"; +import { ArgsExpressType } from "../../types/ArgsExpressType"; +import { ErrorCaused } from "../../types/ErrorCaused"; -const stats = require('../../stats') +const stats = require("../../stats"); -exports.expressCreateServer = (hook_name:string, args: ArgsExpressType, cb:Function) => { - exports.app = args.app; +exports.expressCreateServer = ( + hook_name: string, + args: ArgsExpressType, + cb: Function, +) => { + exports.app = args.app; - // Handle errors - args.app.use((err:ErrorCaused, req:any, res:any, next:Function) => { - // if an error occurs Connect will pass it down - // through these "error-handling" middleware - // allowing you to respond however you like - res.status(500).send({error: 'Sorry, something bad happened!'}); - console.error(err.stack ? err.stack : err.toString()); - stats.meter('http500').mark(); - }); + // Handle errors + args.app.use((err: ErrorCaused, req: any, res: any, next: Function) => { + // if an error occurs Connect will pass it down + // through these "error-handling" middleware + // allowing you to respond however you like + res.status(500).send({ error: "Sorry, something bad happened!" }); + console.error(err.stack ? err.stack : err.toString()); + stats.meter("http500").mark(); + }); - return cb(); + return cb(); }; diff --git a/src/node/hooks/express/importexport.ts b/src/node/hooks/express/importexport.ts index 898606e49..610a1d773 100644 --- a/src/node/hooks/express/importexport.ts +++ b/src/node/hooks/express/importexport.ts @@ -1,89 +1,124 @@ -'use strict'; +"use strict"; -import {ArgsExpressType} from "../../types/ArgsExpressType"; +import { ArgsExpressType } from "../../types/ArgsExpressType"; -const hasPadAccess = require('../../padaccess'); -const settings = require('../../utils/Settings'); -const exportHandler = require('../../handler/ExportHandler'); -const importHandler = require('../../handler/ImportHandler'); -const padManager = require('../../db/PadManager'); -const readOnlyManager = require('../../db/ReadOnlyManager'); -const rateLimit = require('express-rate-limit'); -const securityManager = require('../../db/SecurityManager'); -const webaccess = require('./webaccess'); +const hasPadAccess = require("../../padaccess"); +const settings = require("../../utils/Settings"); +const exportHandler = require("../../handler/ExportHandler"); +const importHandler = require("../../handler/ImportHandler"); +const padManager = require("../../db/PadManager"); +const readOnlyManager = require("../../db/ReadOnlyManager"); +const rateLimit = require("express-rate-limit"); +const securityManager = require("../../db/SecurityManager"); +const webaccess = require("./webaccess"); -exports.expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => { - const limiter = rateLimit({ - ...settings.importExportRateLimiting, - handler: (request:any) => { - if (request.rateLimit.current === request.rateLimit.limit + 1) { - // when the rate limiter triggers, write a warning in the logs - console.warn('Import/Export rate limiter triggered on ' + - `"${request.originalUrl}" for IP address ${request.ip}`); - } - }, - }); +exports.expressCreateServer = ( + hookName: string, + args: ArgsExpressType, + cb: Function, +) => { + const limiter = rateLimit({ + ...settings.importExportRateLimiting, + handler: (request: any) => { + if (request.rateLimit.current === request.rateLimit.limit + 1) { + // when the rate limiter triggers, write a warning in the logs + console.warn( + "Import/Export rate limiter triggered on " + + `"${request.originalUrl}" for IP address ${request.ip}`, + ); + } + }, + }); - // handle export requests - args.app.use('/p/:pad/:rev?/export/:type', limiter); - args.app.get('/p/:pad/:rev?/export/:type', (req:any, res:any, next:Function) => { - (async () => { - const types = ['pdf', 'doc', 'txt', 'html', 'odt', 'etherpad']; - // send a 404 if we don't support this filetype - if (types.indexOf(req.params.type) === -1) { - return next(); - } + // handle export requests + args.app.use("/p/:pad/:rev?/export/:type", limiter); + args.app.get( + "/p/:pad/:rev?/export/:type", + (req: any, res: any, next: Function) => { + (async () => { + const types = ["pdf", "doc", "txt", "html", "odt", "etherpad"]; + // send a 404 if we don't support this filetype + if (types.indexOf(req.params.type) === -1) { + return next(); + } - // if abiword is disabled, and this is a format we only support with abiword, output a message - if (settings.exportAvailable() === 'no' && - ['odt', 'pdf', 'doc'].indexOf(req.params.type) !== -1) { - console.error(`Impossible to export pad "${req.params.pad}" in ${req.params.type} format.` + - ' There is no converter configured'); + // if abiword is disabled, and this is a format we only support with abiword, output a message + if ( + settings.exportAvailable() === "no" && + ["odt", "pdf", "doc"].indexOf(req.params.type) !== -1 + ) { + console.error( + `Impossible to export pad "${req.params.pad}" in ${req.params.type} format.` + + " There is no converter configured", + ); - // ACHTUNG: do not include req.params.type in res.send() because there is - // no HTML escaping and it would lead to an XSS - res.send('This export is not enabled at this Etherpad instance. Set the path to Abiword' + - ' or soffice (LibreOffice) in settings.json to enable this feature'); - return; - } + // ACHTUNG: do not include req.params.type in res.send() because there is + // no HTML escaping and it would lead to an XSS + res.send( + "This export is not enabled at this Etherpad instance. Set the path to Abiword" + + " or soffice (LibreOffice) in settings.json to enable this feature", + ); + return; + } - res.header('Access-Control-Allow-Origin', '*'); + res.header("Access-Control-Allow-Origin", "*"); - if (await hasPadAccess(req, res)) { - let padId = req.params.pad; + if (await hasPadAccess(req, res)) { + let padId = req.params.pad; - let readOnlyId = null; - if (readOnlyManager.isReadOnlyId(padId)) { - readOnlyId = padId; - padId = await readOnlyManager.getPadId(readOnlyId); - } + let readOnlyId = null; + if (readOnlyManager.isReadOnlyId(padId)) { + readOnlyId = padId; + padId = await readOnlyManager.getPadId(readOnlyId); + } - const exists = await padManager.doesPadExists(padId); - if (!exists) { - console.warn(`Someone tried to export a pad that doesn't exist (${padId})`); - return next(); - } + const exists = await padManager.doesPadExists(padId); + if (!exists) { + console.warn( + `Someone tried to export a pad that doesn't exist (${padId})`, + ); + return next(); + } - console.log(`Exporting pad "${req.params.pad}" in ${req.params.type} format`); - await exportHandler.doExport(req, res, padId, readOnlyId, req.params.type); - } - })().catch((err) => next(err || new Error(err))); - }); + console.log( + `Exporting pad "${req.params.pad}" in ${req.params.type} format`, + ); + await exportHandler.doExport( + req, + res, + padId, + readOnlyId, + req.params.type, + ); + } + })().catch((err) => next(err || new Error(err))); + }, + ); - // handle import requests - args.app.use('/p/:pad/import', limiter); - args.app.post('/p/:pad/import', (req:any, res:any, next:Function) => { - (async () => { - // @ts-ignore - const {session: {user} = {}} = req; - const {accessStatus, authorID: authorId} = await securityManager.checkAccess( - req.params.pad, req.cookies.sessionID, req.cookies.token, user); - if (accessStatus !== 'grant' || !webaccess.userCanModify(req.params.pad, req)) { - return res.status(403).send('Forbidden'); - } - await importHandler.doImport(req, res, req.params.pad, authorId); - })().catch((err) => next(err || new Error(err))); - }); + // handle import requests + args.app.use("/p/:pad/import", limiter); + args.app.post("/p/:pad/import", (req: any, res: any, next: Function) => { + (async () => { + // @ts-ignore + const { + session: { user } = {}, + } = req; + const { accessStatus, authorID: authorId } = + await securityManager.checkAccess( + req.params.pad, + req.cookies.sessionID, + req.cookies.token, + user, + ); + if ( + accessStatus !== "grant" || + !webaccess.userCanModify(req.params.pad, req) + ) { + return res.status(403).send("Forbidden"); + } + await importHandler.doImport(req, res, req.params.pad, authorId); + })().catch((err) => next(err || new Error(err))); + }); - return cb(); + return cb(); }; diff --git a/src/node/hooks/express/openapi.ts b/src/node/hooks/express/openapi.ts index a55e67871..519826f32 100644 --- a/src/node/hooks/express/openapi.ts +++ b/src/node/hooks/express/openapi.ts @@ -1,8 +1,12 @@ -'use strict'; +"use strict"; -import {OpenAPIOperations, OpenAPISuccessResponse, SwaggerUIResource} from "../../types/SwaggerUIResource"; -import {MapArrayType} from "../../types/MapType"; -import {ErrorCaused} from "../../types/ErrorCaused"; +import { + OpenAPIOperations, + OpenAPISuccessResponse, + SwaggerUIResource, +} from "../../types/SwaggerUIResource"; +import { MapArrayType } from "../../types/MapType"; +import { ErrorCaused } from "../../types/ErrorCaused"; /** * node/hooks/express/openapi.js @@ -18,703 +22,738 @@ import {ErrorCaused} from "../../types/ErrorCaused"; * - /rest/{version}/openapi.json */ -const OpenAPIBackend = require('openapi-backend').default; -const IncomingForm = require('formidable').IncomingForm; -const cloneDeep = require('lodash.clonedeep'); -const createHTTPError = require('http-errors'); +const OpenAPIBackend = require("openapi-backend").default; +const IncomingForm = require("formidable").IncomingForm; +const cloneDeep = require("lodash.clonedeep"); +const createHTTPError = require("http-errors"); -const apiHandler = require('../../handler/APIHandler'); -const settings = require('../../utils/Settings'); +const apiHandler = require("../../handler/APIHandler"); +const settings = require("../../utils/Settings"); -const log4js = require('log4js'); -const logger = log4js.getLogger('API'); +const log4js = require("log4js"); +const logger = log4js.getLogger("API"); // https://github.com/OAI/OpenAPI-Specification/tree/master/schemas/v3.0 -const OPENAPI_VERSION = '3.0.2'; // Swagger/OAS version +const OPENAPI_VERSION = "3.0.2"; // Swagger/OAS version const info = { - title: 'Etherpad API', - description: - 'Etherpad is a real-time collaborative editor scalable to thousands of simultaneous ' + - 'real time users. It provides full data export capabilities, and runs on your server, ' + - 'under your control.', - termsOfService: 'https://etherpad.org/', - contact: { - name: 'The Etherpad Foundation', - url: 'https://etherpad.org/', - email: 'support@example.com', - }, - license: { - name: 'Apache 2.0', - url: 'https://www.apache.org/licenses/LICENSE-2.0.html', - }, - version: apiHandler.latestApiVersion, + title: "Etherpad API", + description: + "Etherpad is a real-time collaborative editor scalable to thousands of simultaneous " + + "real time users. It provides full data export capabilities, and runs on your server, " + + "under your control.", + termsOfService: "https://etherpad.org/", + contact: { + name: "The Etherpad Foundation", + url: "https://etherpad.org/", + email: "support@example.com", + }, + license: { + name: "Apache 2.0", + url: "https://www.apache.org/licenses/LICENSE-2.0.html", + }, + version: apiHandler.latestApiVersion, }; const APIPathStyle = { - FLAT: 'api', // flat paths e.g. /api/createGroup - REST: 'rest', // restful paths e.g. /rest/group/create + FLAT: "api", // flat paths e.g. /api/createGroup + REST: "rest", // restful paths e.g. /rest/group/create }; - // API resources - describe your API endpoints here -const resources:SwaggerUIResource = { - // Group - group: { - create: { - operationId: 'createGroup', - summary: 'creates a new group', - responseSchema: {groupID: {type: 'string'}}, - }, - createIfNotExistsFor: { - operationId: 'createGroupIfNotExistsFor', - summary: 'this functions helps you to map your application group ids to Etherpad group ids', - responseSchema: {groupID: {type: 'string'}}, - }, - delete: { - operationId: 'deleteGroup', - summary: 'deletes a group', - }, - listPads: { - operationId: 'listPads', - summary: 'returns all pads of this group', - responseSchema: {padIDs: {type: 'array', items: {type: 'string'}}}, - }, - createPad: { - operationId: 'createGroupPad', - summary: 'creates a new pad in this group', - }, - listSessions: { - operationId: 'listSessionsOfGroup', - summary: '', - responseSchema: { - sessions: {type: 'array', items: {$ref: '#/components/schemas/SessionInfo'}}, - }, - }, - list: { - operationId: 'listAllGroups', - summary: '', - responseSchema: {groupIDs: {type: 'array', items: {type: 'string'}}}, - }, - }, +const resources: SwaggerUIResource = { + // Group + group: { + create: { + operationId: "createGroup", + summary: "creates a new group", + responseSchema: { groupID: { type: "string" } }, + }, + createIfNotExistsFor: { + operationId: "createGroupIfNotExistsFor", + summary: + "this functions helps you to map your application group ids to Etherpad group ids", + responseSchema: { groupID: { type: "string" } }, + }, + delete: { + operationId: "deleteGroup", + summary: "deletes a group", + }, + listPads: { + operationId: "listPads", + summary: "returns all pads of this group", + responseSchema: { padIDs: { type: "array", items: { type: "string" } } }, + }, + createPad: { + operationId: "createGroupPad", + summary: "creates a new pad in this group", + }, + listSessions: { + operationId: "listSessionsOfGroup", + summary: "", + responseSchema: { + sessions: { + type: "array", + items: { $ref: "#/components/schemas/SessionInfo" }, + }, + }, + }, + list: { + operationId: "listAllGroups", + summary: "", + responseSchema: { + groupIDs: { type: "array", items: { type: "string" } }, + }, + }, + }, - // Author - author: { - create: { - operationId: 'createAuthor', - summary: 'creates a new author', - responseSchema: {authorID: {type: 'string'}}, - }, - createIfNotExistsFor: { - operationId: 'createAuthorIfNotExistsFor', - summary: 'this functions helps you to map your application author ids to Etherpad author ids', - responseSchema: {authorID: {type: 'string'}}, - }, - listPads: { - operationId: 'listPadsOfAuthor', - summary: 'returns an array of all pads this author contributed to', - responseSchema: {padIDs: {type: 'array', items: {type: 'string'}}}, - }, - listSessions: { - operationId: 'listSessionsOfAuthor', - summary: 'returns all sessions of an author', - responseSchema: { - sessions: {type: 'array', items: {$ref: '#/components/schemas/SessionInfo'}}, - }, - }, - // We need an operation that return a UserInfo so it can be picked up by the codegen :( - getName: { - operationId: 'getAuthorName', - summary: 'Returns the Author Name of the author', - responseSchema: {info: {$ref: '#/components/schemas/UserInfo'}}, - }, - }, + // Author + author: { + create: { + operationId: "createAuthor", + summary: "creates a new author", + responseSchema: { authorID: { type: "string" } }, + }, + createIfNotExistsFor: { + operationId: "createAuthorIfNotExistsFor", + summary: + "this functions helps you to map your application author ids to Etherpad author ids", + responseSchema: { authorID: { type: "string" } }, + }, + listPads: { + operationId: "listPadsOfAuthor", + summary: "returns an array of all pads this author contributed to", + responseSchema: { padIDs: { type: "array", items: { type: "string" } } }, + }, + listSessions: { + operationId: "listSessionsOfAuthor", + summary: "returns all sessions of an author", + responseSchema: { + sessions: { + type: "array", + items: { $ref: "#/components/schemas/SessionInfo" }, + }, + }, + }, + // We need an operation that return a UserInfo so it can be picked up by the codegen :( + getName: { + operationId: "getAuthorName", + summary: "Returns the Author Name of the author", + responseSchema: { info: { $ref: "#/components/schemas/UserInfo" } }, + }, + }, - // Session - session: { - create: { - operationId: 'createSession', - summary: 'creates a new session. validUntil is an unix timestamp in seconds', - responseSchema: {sessionID: {type: 'string'}}, - }, - delete: { - operationId: 'deleteSession', - summary: 'deletes a session', - }, - // We need an operation that returns a SessionInfo so it can be picked up by the codegen :( - info: { - operationId: 'getSessionInfo', - summary: 'returns information about a session', - responseSchema: {info: {$ref: '#/components/schemas/SessionInfo'}}, - }, - }, + // Session + session: { + create: { + operationId: "createSession", + summary: + "creates a new session. validUntil is an unix timestamp in seconds", + responseSchema: { sessionID: { type: "string" } }, + }, + delete: { + operationId: "deleteSession", + summary: "deletes a session", + }, + // We need an operation that returns a SessionInfo so it can be picked up by the codegen :( + info: { + operationId: "getSessionInfo", + summary: "returns information about a session", + responseSchema: { info: { $ref: "#/components/schemas/SessionInfo" } }, + }, + }, - // Pad - pad: { - listAll: { - operationId: 'listAllPads', - summary: 'list all the pads', - responseSchema: {padIDs: {type: 'array', items: {type: 'string'}}}, - }, - createDiffHTML: { - operationId: 'createDiffHTML', - summary: '', - responseSchema: {}, - }, - create: { - operationId: 'createPad', - description: - 'creates a new (non-group) pad. Note that if you need to create a group Pad, ' + - 'you should call createGroupPad', - }, - getText: { - operationId: 'getText', - summary: 'returns the text of a pad', - responseSchema: {text: {type: 'string'}}, - }, - setText: { - operationId: 'setText', - summary: 'sets the text of a pad', - }, - getHTML: { - operationId: 'getHTML', - summary: 'returns the text of a pad formatted as HTML', - responseSchema: {html: {type: 'string'}}, - }, - setHTML: { - operationId: 'setHTML', - summary: 'sets the text of a pad with HTML', - }, - getRevisionsCount: { - operationId: 'getRevisionsCount', - summary: 'returns the number of revisions of this pad', - responseSchema: {revisions: {type: 'integer'}}, - }, - getLastEdited: { - operationId: 'getLastEdited', - summary: 'returns the timestamp of the last revision of the pad', - responseSchema: {lastEdited: {type: 'integer'}}, - }, - delete: { - operationId: 'deletePad', - summary: 'deletes a pad', - }, - getReadOnlyID: { - operationId: 'getReadOnlyID', - summary: 'returns the read only link of a pad', - responseSchema: {readOnlyID: {type: 'string'}}, - }, - setPublicStatus: { - operationId: 'setPublicStatus', - summary: 'sets a boolean for the public status of a pad', - }, - getPublicStatus: { - operationId: 'getPublicStatus', - summary: 'return true of false', - responseSchema: {publicStatus: {type: 'boolean'}}, - }, - authors: { - operationId: 'listAuthorsOfPad', - summary: 'returns an array of authors who contributed to this pad', - responseSchema: {authorIDs: {type: 'array', items: {type: 'string'}}}, - }, - usersCount: { - operationId: 'padUsersCount', - summary: 'returns the number of user that are currently editing this pad', - responseSchema: {padUsersCount: {type: 'integer'}}, - }, - users: { - operationId: 'padUsers', - summary: 'returns the list of users that are currently editing this pad', - responseSchema: {padUsers: {type: 'array', items: {$ref: '#/components/schemas/UserInfo'}}}, - }, - sendClientsMessage: { - operationId: 'sendClientsMessage', - summary: 'sends a custom message of type msg to the pad', - }, - checkToken: { - operationId: 'checkToken', - summary: 'returns ok when the current api token is valid', - }, - getChatHistory: { - operationId: 'getChatHistory', - summary: 'returns the chat history', - responseSchema: {messages: {type: 'array', items: {$ref: '#/components/schemas/Message'}}}, - }, - // We need an operation that returns a Message so it can be picked up by the codegen :( - getChatHead: { - operationId: 'getChatHead', - summary: 'returns the chatHead (chat-message) of the pad', - responseSchema: {chatHead: {$ref: '#/components/schemas/Message'}}, - }, - appendChatMessage: { - operationId: 'appendChatMessage', - summary: 'appends a chat message', - }, - }, + // Pad + pad: { + listAll: { + operationId: "listAllPads", + summary: "list all the pads", + responseSchema: { padIDs: { type: "array", items: { type: "string" } } }, + }, + createDiffHTML: { + operationId: "createDiffHTML", + summary: "", + responseSchema: {}, + }, + create: { + operationId: "createPad", + description: + "creates a new (non-group) pad. Note that if you need to create a group Pad, " + + "you should call createGroupPad", + }, + getText: { + operationId: "getText", + summary: "returns the text of a pad", + responseSchema: { text: { type: "string" } }, + }, + setText: { + operationId: "setText", + summary: "sets the text of a pad", + }, + getHTML: { + operationId: "getHTML", + summary: "returns the text of a pad formatted as HTML", + responseSchema: { html: { type: "string" } }, + }, + setHTML: { + operationId: "setHTML", + summary: "sets the text of a pad with HTML", + }, + getRevisionsCount: { + operationId: "getRevisionsCount", + summary: "returns the number of revisions of this pad", + responseSchema: { revisions: { type: "integer" } }, + }, + getLastEdited: { + operationId: "getLastEdited", + summary: "returns the timestamp of the last revision of the pad", + responseSchema: { lastEdited: { type: "integer" } }, + }, + delete: { + operationId: "deletePad", + summary: "deletes a pad", + }, + getReadOnlyID: { + operationId: "getReadOnlyID", + summary: "returns the read only link of a pad", + responseSchema: { readOnlyID: { type: "string" } }, + }, + setPublicStatus: { + operationId: "setPublicStatus", + summary: "sets a boolean for the public status of a pad", + }, + getPublicStatus: { + operationId: "getPublicStatus", + summary: "return true of false", + responseSchema: { publicStatus: { type: "boolean" } }, + }, + authors: { + operationId: "listAuthorsOfPad", + summary: "returns an array of authors who contributed to this pad", + responseSchema: { + authorIDs: { type: "array", items: { type: "string" } }, + }, + }, + usersCount: { + operationId: "padUsersCount", + summary: "returns the number of user that are currently editing this pad", + responseSchema: { padUsersCount: { type: "integer" } }, + }, + users: { + operationId: "padUsers", + summary: "returns the list of users that are currently editing this pad", + responseSchema: { + padUsers: { + type: "array", + items: { $ref: "#/components/schemas/UserInfo" }, + }, + }, + }, + sendClientsMessage: { + operationId: "sendClientsMessage", + summary: "sends a custom message of type msg to the pad", + }, + checkToken: { + operationId: "checkToken", + summary: "returns ok when the current api token is valid", + }, + getChatHistory: { + operationId: "getChatHistory", + summary: "returns the chat history", + responseSchema: { + messages: { + type: "array", + items: { $ref: "#/components/schemas/Message" }, + }, + }, + }, + // We need an operation that returns a Message so it can be picked up by the codegen :( + getChatHead: { + operationId: "getChatHead", + summary: "returns the chatHead (chat-message) of the pad", + responseSchema: { chatHead: { $ref: "#/components/schemas/Message" } }, + }, + appendChatMessage: { + operationId: "appendChatMessage", + summary: "appends a chat message", + }, + }, }; const defaultResponses = { - Success: { - description: 'ok (code 0)', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - code: { - type: 'integer', - example: 0, - }, - message: { - type: 'string', - example: 'ok', - }, - data: { - type: 'object', - example: null, - }, - }, - }, - }, - }, - }, - ApiError: { - description: 'generic api error (code 1)', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - code: { - type: 'integer', - example: 1, - }, - message: { - type: 'string', - example: 'error message', - }, - data: { - type: 'object', - example: null, - }, - }, - }, - }, - }, - }, - InternalError: { - description: 'internal api error (code 2)', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - code: { - type: 'integer', - example: 2, - }, - message: { - type: 'string', - example: 'internal error', - }, - data: { - type: 'object', - example: null, - }, - }, - }, - }, - }, - }, - NotFound: { - description: 'no such function (code 4)', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - code: { - type: 'integer', - example: 3, - }, - message: { - type: 'string', - example: 'no such function', - }, - data: { - type: 'object', - example: null, - }, - }, - }, - }, - }, - }, - Unauthorized: { - description: 'no or wrong API key (code 4)', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - code: { - type: 'integer', - example: 4, - }, - message: { - type: 'string', - example: 'no or wrong API key', - }, - data: { - type: 'object', - example: null, - }, - }, - }, - }, - }, - }, + Success: { + description: "ok (code 0)", + content: { + "application/json": { + schema: { + type: "object", + properties: { + code: { + type: "integer", + example: 0, + }, + message: { + type: "string", + example: "ok", + }, + data: { + type: "object", + example: null, + }, + }, + }, + }, + }, + }, + ApiError: { + description: "generic api error (code 1)", + content: { + "application/json": { + schema: { + type: "object", + properties: { + code: { + type: "integer", + example: 1, + }, + message: { + type: "string", + example: "error message", + }, + data: { + type: "object", + example: null, + }, + }, + }, + }, + }, + }, + InternalError: { + description: "internal api error (code 2)", + content: { + "application/json": { + schema: { + type: "object", + properties: { + code: { + type: "integer", + example: 2, + }, + message: { + type: "string", + example: "internal error", + }, + data: { + type: "object", + example: null, + }, + }, + }, + }, + }, + }, + NotFound: { + description: "no such function (code 4)", + content: { + "application/json": { + schema: { + type: "object", + properties: { + code: { + type: "integer", + example: 3, + }, + message: { + type: "string", + example: "no such function", + }, + data: { + type: "object", + example: null, + }, + }, + }, + }, + }, + }, + Unauthorized: { + description: "no or wrong API key (code 4)", + content: { + "application/json": { + schema: { + type: "object", + properties: { + code: { + type: "integer", + example: 4, + }, + message: { + type: "string", + example: "no or wrong API key", + }, + data: { + type: "object", + example: null, + }, + }, + }, + }, + }, + }, }; -const defaultResponseRefs:OpenAPISuccessResponse = { - 200: { - $ref: '#/components/responses/Success', - }, - 400: { - $ref: '#/components/responses/ApiError', - }, - 401: { - $ref: '#/components/responses/Unauthorized', - }, - 500: { - $ref: '#/components/responses/InternalError', - }, +const defaultResponseRefs: OpenAPISuccessResponse = { + 200: { + $ref: "#/components/responses/Success", + }, + 400: { + $ref: "#/components/responses/ApiError", + }, + 401: { + $ref: "#/components/responses/Unauthorized", + }, + 500: { + $ref: "#/components/responses/InternalError", + }, }; // convert to a dictionary of operation objects const operations: OpenAPIOperations = {}; for (const [resource, actions] of Object.entries(resources)) { - for (const [action, spec] of Object.entries(actions)) { - const {operationId,responseSchema, ...operation} = spec; + for (const [action, spec] of Object.entries(actions)) { + const { operationId, responseSchema, ...operation } = spec; - // add response objects - const responses:OpenAPISuccessResponse = {...defaultResponseRefs}; - if (responseSchema) { - responses[200] = cloneDeep(defaultResponses.Success); - responses[200].content!['application/json'].schema.properties.data = { - type: 'object', - properties: responseSchema, - }; - } + // add response objects + const responses: OpenAPISuccessResponse = { ...defaultResponseRefs }; + if (responseSchema) { + responses[200] = cloneDeep(defaultResponses.Success); + responses[200].content!["application/json"].schema.properties.data = { + type: "object", + properties: responseSchema, + }; + } - // add final operation object to dictionary - operations[operationId] = { - operationId, - ...operation, - responses, - tags: [resource], - _restPath: `/${resource}/${action}`, - }; - } + // add final operation object to dictionary + operations[operationId] = { + operationId, + ...operation, + responses, + tags: [resource], + _restPath: `/${resource}/${action}`, + }; + } } -const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT) => { - const definition = { - openapi: OPENAPI_VERSION, - info, - paths: {}, - components: { - parameters: {}, - schemas: { - SessionInfo: { - type: 'object', - properties: { - id: { - type: 'string', - }, - authorID: { - type: 'string', - }, - groupID: { - type: 'string', - }, - validUntil: { - type: 'integer', - }, - }, - }, - UserInfo: { - type: 'object', - properties: { - id: { - type: 'string', - }, - colorId: { - type: 'string', - }, - name: { - type: 'string', - }, - timestamp: { - type: 'integer', - }, - }, - }, - Message: { - type: 'object', - properties: { - text: { - type: 'string', - }, - userId: { - type: 'string', - }, - userName: { - type: 'string', - }, - time: { - type: 'integer', - }, - }, - }, - }, - responses: { - ...defaultResponses, - }, - securitySchemes: { - openid: { - type: "oauth2", - flows: { - authorizationCode: { - authorizationUrl: settings.sso.issuer+"/oidc/auth", - tokenUrl: settings.sso.issuer+"/oidc/token", - scopes: { - openid: "openid", - profile: "profile", - email: "email", - admin: "admin" - } - } - }, - }, - }, - }, - security: [{openid: []}], - }; +const generateDefinitionForVersion = ( + version: string, + style = APIPathStyle.FLAT, +) => { + const definition = { + openapi: OPENAPI_VERSION, + info, + paths: {}, + components: { + parameters: {}, + schemas: { + SessionInfo: { + type: "object", + properties: { + id: { + type: "string", + }, + authorID: { + type: "string", + }, + groupID: { + type: "string", + }, + validUntil: { + type: "integer", + }, + }, + }, + UserInfo: { + type: "object", + properties: { + id: { + type: "string", + }, + colorId: { + type: "string", + }, + name: { + type: "string", + }, + timestamp: { + type: "integer", + }, + }, + }, + Message: { + type: "object", + properties: { + text: { + type: "string", + }, + userId: { + type: "string", + }, + userName: { + type: "string", + }, + time: { + type: "integer", + }, + }, + }, + }, + responses: { + ...defaultResponses, + }, + securitySchemes: { + openid: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: settings.sso.issuer + "/oidc/auth", + tokenUrl: settings.sso.issuer + "/oidc/token", + scopes: { + openid: "openid", + profile: "profile", + email: "email", + admin: "admin", + }, + }, + }, + }, + }, + }, + security: [{ openid: [] }], + }; - // build operations - for (const funcName of Object.keys(apiHandler.version[version])) { - let operation:OpenAPIOperations = {}; - if (operations[funcName]) { - operation = {...operations[funcName]}; - } else { - // console.warn(`No operation found for function: ${funcName}`); - operation = { - operationId: funcName, - responses: defaultResponseRefs, - }; - } + // build operations + for (const funcName of Object.keys(apiHandler.version[version])) { + let operation: OpenAPIOperations = {}; + if (operations[funcName]) { + operation = { ...operations[funcName] }; + } else { + // console.warn(`No operation found for function: ${funcName}`); + operation = { + operationId: funcName, + responses: defaultResponseRefs, + }; + } - // set parameters - operation.parameters = operation.parameters || []; - for (const paramName of apiHandler.version[version][funcName]) { - operation.parameters.push({$ref: `#/components/parameters/${paramName}`}); - // @ts-ignore - if (!definition.components.parameters[paramName]) { - // @ts-ignore - definition.components.parameters[paramName] = { - name: paramName, - in: 'query', - schema: { - type: 'string', - }, - }; - } - } + // set parameters + operation.parameters = operation.parameters || []; + for (const paramName of apiHandler.version[version][funcName]) { + operation.parameters.push({ + $ref: `#/components/parameters/${paramName}`, + }); + // @ts-ignore + if (!definition.components.parameters[paramName]) { + // @ts-ignore + definition.components.parameters[paramName] = { + name: paramName, + in: "query", + schema: { + type: "string", + }, + }; + } + } - // set path - let path = `/${operation.operationId}`; // APIPathStyle.FLAT - if (style === APIPathStyle.REST && operation._restPath) { - path = operation._restPath; - } - delete operation._restPath; + // set path + let path = `/${operation.operationId}`; // APIPathStyle.FLAT + if (style === APIPathStyle.REST && operation._restPath) { + path = operation._restPath; + } + delete operation._restPath; - // add to definition - // NOTE: It may be confusing that every operation can be called with both GET and POST - // @ts-ignore - definition.paths[path] = { - get: { - ...operation, - operationId: `${operation.operationId}UsingGET`, - }, - post: { - ...operation, - operationId: `${operation.operationId}UsingPOST`, - }, - }; - } - return definition; + // add to definition + // NOTE: It may be confusing that every operation can be called with both GET and POST + // @ts-ignore + definition.paths[path] = { + get: { + ...operation, + operationId: `${operation.operationId}UsingGET`, + }, + post: { + ...operation, + operationId: `${operation.operationId}UsingPOST`, + }, + }; + } + return definition; }; -exports.expressPreSession = async (hookName:string, {app}:any) => { - // create openapi-backend handlers for each api version under /api/{version}/* - for (const version of Object.keys(apiHandler.version)) { - // we support two different styles of api: flat + rest - // TODO: do we really want to support both? +exports.expressPreSession = async (hookName: string, { app }: any) => { + // create openapi-backend handlers for each api version under /api/{version}/* + for (const version of Object.keys(apiHandler.version)) { + // we support two different styles of api: flat + rest + // TODO: do we really want to support both? - for (const style of [APIPathStyle.FLAT, APIPathStyle.REST]) { - const apiRoot = getApiRootForVersion(version, style); + for (const style of [APIPathStyle.FLAT, APIPathStyle.REST]) { + const apiRoot = getApiRootForVersion(version, style); - // generate openapi definition for this API version - const definition = generateDefinitionForVersion(version, style); + // generate openapi definition for this API version + const definition = generateDefinitionForVersion(version, style); - // serve version specific openapi definition - app.get(`${apiRoot}/openapi.json`, (req:any, res:any) => { - // For openapi definitions, wide CORS is probably fine - res.header('Access-Control-Allow-Origin', '*'); - res.json({...definition, servers: [generateServerForApiVersion(apiRoot, req)]}); - }); + // serve version specific openapi definition + app.get(`${apiRoot}/openapi.json`, (req: any, res: any) => { + // For openapi definitions, wide CORS is probably fine + res.header("Access-Control-Allow-Origin", "*"); + res.json({ + ...definition, + servers: [generateServerForApiVersion(apiRoot, req)], + }); + }); - // serve latest openapi definition file under /api/openapi.json - const isLatestAPIVersion = version === apiHandler.latestApiVersion; - if (isLatestAPIVersion) { - app.get(`/${style}/openapi.json`, (req:any, res:any) => { - res.header('Access-Control-Allow-Origin', '*'); - res.json({...definition, servers: [generateServerForApiVersion(apiRoot, req)]}); - }); - } + // serve latest openapi definition file under /api/openapi.json + const isLatestAPIVersion = version === apiHandler.latestApiVersion; + if (isLatestAPIVersion) { + app.get(`/${style}/openapi.json`, (req: any, res: any) => { + res.header("Access-Control-Allow-Origin", "*"); + res.json({ + ...definition, + servers: [generateServerForApiVersion(apiRoot, req)], + }); + }); + } - // build openapi-backend instance for this api version - const api = new OpenAPIBackend({ - definition, - validate: false, - // for a small optimisation, we can run the quick startup for older - // API versions since they are subsets of the latest api definition - quick: !isLatestAPIVersion, - }); + // build openapi-backend instance for this api version + const api = new OpenAPIBackend({ + definition, + validate: false, + // for a small optimisation, we can run the quick startup for older + // API versions since they are subsets of the latest api definition + quick: !isLatestAPIVersion, + }); - // register default handlers - api.register({ - notFound: () => { - throw new createHTTPError.NotFound('no such function'); - }, - notImplemented: () => { - throw new createHTTPError.NotImplemented('function not implemented'); - }, - }); + // register default handlers + api.register({ + notFound: () => { + throw new createHTTPError.NotFound("no such function"); + }, + notImplemented: () => { + throw new createHTTPError.NotImplemented("function not implemented"); + }, + }); - // register operation handlers - for (const funcName of Object.keys(apiHandler.version[version])) { - const handler = async (c: any, req:any, res:any) => { - // parse fields from request - const {header, params, query} = c.request; + // register operation handlers + for (const funcName of Object.keys(apiHandler.version[version])) { + const handler = async (c: any, req: any, res: any) => { + // parse fields from request + const { header, params, query } = c.request; - // read form data if method was POST - let formData:MapArrayType = {}; - if (c.request.method === 'post') { - 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]; - } - } - } + // read form data if method was POST + let formData: MapArrayType = {}; + if (c.request.method === "post") { + 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); + const fields = Object.assign({}, header, params, query, formData); - if (logger.isDebugEnabled()) { - logger.debug(`REQUEST, v${version}:${funcName}, ${JSON.stringify(fields)}`); - } + if (logger.isDebugEnabled()) { + logger.debug( + `REQUEST, v${version}:${funcName}, ${JSON.stringify(fields)}`, + ); + } - // pass to api handler - let data; - try { - data = await apiHandler.handle(version, funcName, fields, req, res); - } catch (err) { - const errCaused = err as ErrorCaused - // convert all errors to http errors - if (createHTTPError.isHttpError(err)) { - // pass http errors thrown by handler forward - throw err; - } else if (errCaused.name === 'apierror') { - // parameters were wrong and the api stopped execution, pass the error - // convert to http error - throw new createHTTPError.BadRequest(errCaused.message); - } else { - // an unknown error happened - // log it and throw internal error - logger.error(errCaused.stack || errCaused.toString()); - throw new createHTTPError.InternalError('internal error'); - } - } + // pass to api handler + let data; + try { + data = await apiHandler.handle(version, funcName, fields, req, res); + } catch (err) { + const errCaused = err as ErrorCaused; + // convert all errors to http errors + if (createHTTPError.isHttpError(err)) { + // pass http errors thrown by handler forward + throw err; + } else if (errCaused.name === "apierror") { + // parameters were wrong and the api stopped execution, pass the error + // convert to http error + throw new createHTTPError.BadRequest(errCaused.message); + } else { + // an unknown error happened + // log it and throw internal error + logger.error(errCaused.stack || errCaused.toString()); + throw new createHTTPError.InternalError("internal error"); + } + } - // return in common format - const response = {code: 0, message: 'ok', data: data || null}; + // return in common format + const response = { code: 0, message: "ok", data: data || null }; - if (logger.isDebugEnabled()) { - logger.debug(`RESPONSE, ${funcName}, ${JSON.stringify(response)}`); - } + if (logger.isDebugEnabled()) { + logger.debug(`RESPONSE, ${funcName}, ${JSON.stringify(response)}`); + } - // return the response data - return response; - }; + // return the response data + return response; + }; - // each operation can be called with either GET or POST - api.register(`${funcName}UsingGET`, handler); - api.register(`${funcName}UsingPOST`, handler); - } + // each operation can be called with either GET or POST + api.register(`${funcName}UsingGET`, handler); + api.register(`${funcName}UsingPOST`, handler); + } - // start and bind to express - await api.init(); - app.use(apiRoot, async (req:any, res:any) => { - let response = null; - try { - if (style === APIPathStyle.REST) { - // @TODO: Don't allow CORS from everywhere - // This is purely to maintain compatibility with old swagger-node-express - res.header('Access-Control-Allow-Origin', '*'); - } - // pass to openapi-backend handler - response = await api.handleRequest(req, req, res); - } catch (err) { - const errCaused = err as ErrorCaused - // handle http errors - // @ts-ignore - res.statusCode = errCaused.statusCode || 500; + // start and bind to express + await api.init(); + app.use(apiRoot, async (req: any, res: any) => { + let response = null; + try { + if (style === APIPathStyle.REST) { + // @TODO: Don't allow CORS from everywhere + // This is purely to maintain compatibility with old swagger-node-express + res.header("Access-Control-Allow-Origin", "*"); + } + // pass to openapi-backend handler + response = await api.handleRequest(req, req, res); + } catch (err) { + const errCaused = err as ErrorCaused; + // handle http errors + // @ts-ignore + res.statusCode = errCaused.statusCode || 500; - // convert to our json response format - // https://github.com/ether/etherpad-lite/tree/master/doc/api/http_api.md#response-format - switch (res.statusCode) { - case 403: // forbidden - response = {code: 4, message: errCaused.message, data: null}; - break; - case 401: // unauthorized (no or wrong api key) - response = {code: 4, message: errCaused.message, data: null}; - break; - case 404: // not found (no such function) - response = {code: 3, message: errCaused.message, data: null}; - break; - case 500: // server error (internal error) - response = {code: 2, message: errCaused.message, data: null}; - break; - case 400: // bad request (wrong parameters) - // respond with 200 OK to keep old behavior and pass tests - res.statusCode = 200; // @TODO: this is bad api design - response = {code: 1, message: errCaused.message, data: null}; - break; - default: - response = {code: 1, message: errCaused.message, data: null}; - break; - } - } + // convert to our json response format + // https://github.com/ether/etherpad-lite/tree/master/doc/api/http_api.md#response-format + switch (res.statusCode) { + case 403: // forbidden + response = { code: 4, message: errCaused.message, data: null }; + break; + case 401: // unauthorized (no or wrong api key) + response = { code: 4, message: errCaused.message, data: null }; + break; + case 404: // not found (no such function) + response = { code: 3, message: errCaused.message, data: null }; + break; + case 500: // server error (internal error) + response = { code: 2, message: errCaused.message, data: null }; + break; + case 400: // bad request (wrong parameters) + // respond with 200 OK to keep old behavior and pass tests + res.statusCode = 200; // @TODO: this is bad api design + response = { code: 1, message: errCaused.message, data: null }; + break; + default: + response = { code: 1, message: errCaused.message, data: null }; + break; + } + } - // send response - return res.send(response); - }); - } - } + // send response + return res.send(response); + }); + } + } }; /** @@ -723,7 +762,10 @@ exports.expressPreSession = async (hookName:string, {app}:any) => { * @param {APIPathStyle} style The style of the API path * @return {String} The root path for the API version */ -const getApiRootForVersion = (version:string, style:any = APIPathStyle.FLAT): string => `/${style}/${version}`; +const getApiRootForVersion = ( + version: string, + style: any = APIPathStyle.FLAT, +): string => `/${style}/${version}`; /** * Helper to generate an OpenAPI server object when serving definitions @@ -731,8 +773,11 @@ const getApiRootForVersion = (version:string, style:any = APIPathStyle.FLAT): st * @param {Request} req The express request object * @return {url: String} The server object for the OpenAPI definition location */ -const generateServerForApiVersion = (apiRoot:string, req:any): { - url:string +const generateServerForApiVersion = ( + apiRoot: string, + req: any, +): { + url: string; } => ({ - url: `${settings.ssl ? 'https' : 'http'}://${req.headers.host}${apiRoot}`, + url: `${settings.ssl ? "https" : "http"}://${req.headers.host}${apiRoot}`, }); diff --git a/src/node/hooks/express/padurlsanitize.ts b/src/node/hooks/express/padurlsanitize.ts index 8679bcfe3..606ef29d0 100644 --- a/src/node/hooks/express/padurlsanitize.ts +++ b/src/node/hooks/express/padurlsanitize.ts @@ -1,32 +1,41 @@ -'use strict'; +"use strict"; -import {ArgsExpressType} from "../../types/ArgsExpressType"; +import { ArgsExpressType } from "../../types/ArgsExpressType"; -const padManager = require('../../db/PadManager'); +const padManager = require("../../db/PadManager"); -exports.expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => { - // redirects browser to the pad's sanitized url if needed. otherwise, renders the html - args.app.param('pad', (req:any, res:any, next:Function, padId:string) => { - (async () => { - // ensure the padname is valid and the url doesn't end with a / - if (!padManager.isValidPadId(padId) || /\/$/.test(req.url)) { - res.status(404).send('Such a padname is forbidden'); - return; - } +exports.expressCreateServer = ( + hookName: string, + args: ArgsExpressType, + cb: Function, +) => { + // redirects browser to the pad's sanitized url if needed. otherwise, renders the html + args.app.param("pad", (req: any, res: any, next: Function, padId: string) => { + (async () => { + // ensure the padname is valid and the url doesn't end with a / + if (!padManager.isValidPadId(padId) || /\/$/.test(req.url)) { + res.status(404).send("Such a padname is forbidden"); + return; + } - const sanitizedPadId = await padManager.sanitizePadId(padId); + const sanitizedPadId = await padManager.sanitizePadId(padId); - if (sanitizedPadId === padId) { - // the pad id was fine, so just render it - next(); - } else { - // the pad id was sanitized, so we redirect to the sanitized version - const realURL = - encodeURIComponent(sanitizedPadId) + new URL(req.url, 'http://invalid.invalid').search; - res.header('Location', realURL); - res.status(302).send(`You should be redirected to ${realURL}`); - } - })().catch((err) => next(err || new Error(err))); - }); - return cb(); + if (sanitizedPadId === padId) { + // the pad id was fine, so just render it + next(); + } else { + // the pad id was sanitized, so we redirect to the sanitized version + const realURL = + encodeURIComponent(sanitizedPadId) + + new URL(req.url, "http://invalid.invalid").search; + res.header("Location", realURL); + res + .status(302) + .send( + `You should be redirected to ${realURL}`, + ); + } + })().catch((err) => next(err || new Error(err))); + }); + return cb(); }; diff --git a/src/node/hooks/express/socketio.ts b/src/node/hooks/express/socketio.ts index bbdec1c1c..a10176917 100644 --- a/src/node/hooks/express/socketio.ts +++ b/src/node/hooks/express/socketio.ts @@ -1,142 +1,148 @@ -'use strict'; +"use strict"; -import {ArgsExpressType} from "../../types/ArgsExpressType"; +import { ArgsExpressType } from "../../types/ArgsExpressType"; -import events from 'events'; -const express = require('../express'); -import log4js from 'log4js'; -const proxyaddr = require('proxy-addr'); -const settings = require('../../utils/Settings'); -import {Server, Socket} from 'socket.io' -const socketIORouter = require('../../handler/SocketIORouter'); -const hooks = require('../../../static/js/pluginfw/hooks'); -const padMessageHandler = require('../../handler/PadMessageHandler'); +import events from "events"; +const express = require("../express"); +import log4js from "log4js"; +const proxyaddr = require("proxy-addr"); +const settings = require("../../utils/Settings"); +import { Server, Socket } from "socket.io"; +const socketIORouter = require("../../handler/SocketIORouter"); +const hooks = require("../../../static/js/pluginfw/hooks"); +const padMessageHandler = require("../../handler/PadMessageHandler"); -let io:any; -const logger = log4js.getLogger('socket.io'); +let io: any; +const logger = log4js.getLogger("socket.io"); const sockets = new Set(); const socketsEvents = new events.EventEmitter(); export const expressCloseServer = async () => { - if (io == null) return; - logger.info('Closing socket.io engine...'); - // Close the socket.io engine to disconnect existing clients and reject new clients. Don't call - // io.close() because that closes the underlying HTTP server, which is already done elsewhere. - // (Closing an HTTP server twice throws an exception.) The `engine` property of socket.io Server - // objects is undocumented, but I don't see any other way to shut down socket.io without also - // closing the HTTP server. - io.engine.close(); - // Closing the socket.io engine should disconnect all clients but it is not documented. Wait for - // all of the connections to close to make sure, and log the progress so that we can troubleshoot - // if socket.io's behavior ever changes. - // - // Note: `io.sockets.clients()` should not be used here to track the remaining clients. - // `io.sockets.clients()` works with socket.io 2.x, but not with 3.x: With socket.io 2.x all - // clients are always added to the default namespace (`io.sockets`) even if they specified a - // different namespace upon connection, but with socket.io 3.x clients are NOT added to the - // default namespace if they have specified a different namespace. With socket.io 3.x there does - // not appear to be a way to get all clients across all namespaces without tracking them - // ourselves, so that is what we do. - let lastLogged = 0; - while (sockets.size > 0 && !settings.enableAdminUITests) { - if (Date.now() - lastLogged > 1000) { // Rate limit to avoid filling logs. - logger.info(`Waiting for ${sockets.size} socket.io clients to disconnect...`); - lastLogged = Date.now(); - } - await events.once(socketsEvents, 'updated'); - } - logger.info('All socket.io clients have disconnected'); + if (io == null) return; + logger.info("Closing socket.io engine..."); + // Close the socket.io engine to disconnect existing clients and reject new clients. Don't call + // io.close() because that closes the underlying HTTP server, which is already done elsewhere. + // (Closing an HTTP server twice throws an exception.) The `engine` property of socket.io Server + // objects is undocumented, but I don't see any other way to shut down socket.io without also + // closing the HTTP server. + io.engine.close(); + // Closing the socket.io engine should disconnect all clients but it is not documented. Wait for + // all of the connections to close to make sure, and log the progress so that we can troubleshoot + // if socket.io's behavior ever changes. + // + // Note: `io.sockets.clients()` should not be used here to track the remaining clients. + // `io.sockets.clients()` works with socket.io 2.x, but not with 3.x: With socket.io 2.x all + // clients are always added to the default namespace (`io.sockets`) even if they specified a + // different namespace upon connection, but with socket.io 3.x clients are NOT added to the + // default namespace if they have specified a different namespace. With socket.io 3.x there does + // not appear to be a way to get all clients across all namespaces without tracking them + // ourselves, so that is what we do. + let lastLogged = 0; + while (sockets.size > 0 && !settings.enableAdminUITests) { + if (Date.now() - lastLogged > 1000) { + // Rate limit to avoid filling logs. + logger.info( + `Waiting for ${sockets.size} socket.io clients to disconnect...`, + ); + lastLogged = Date.now(); + } + await events.once(socketsEvents, "updated"); + } + logger.info("All socket.io clients have disconnected"); }; -const socketSessionMiddleware = (args: any) => (socket: any, next: Function) => { - const req = socket.request; - // Express sets req.ip but socket.io does not. Replicate Express's behavior here. - if (req.ip == null) { - if (settings.trustProxy) { - req.ip = proxyaddr(req, args.app.get('trust proxy fn')); - } else { - req.ip = socket.handshake.address; - } - } - if (!req.headers.cookie) { - // socketio.js-client on node.js doesn't support cookies, so pass them via a query parameter. - req.headers.cookie = socket.handshake.query.cookie; - } - express.sessionMiddleware(req, {}, next); -}; - -export const expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => { - // init socket.io and redirect all requests to the MessageHandler - // there shouldn't be a browser that isn't compatible to all - // transports in this list at once - // e.g. XHR is disabled in IE by default, so in IE it should use jsonp-polling - io = new Server(args.server,{ - transports: settings.socketTransportProtocols, - cookie: false, - maxHttpBufferSize: settings.socketIo.maxHttpBufferSize, - }) - - - const handleConnection = (socket:Socket) => { - sockets.add(socket); - socketsEvents.emit('updated'); - // https://socket.io/docs/v3/faq/index.html - // @ts-ignore - const session = socket.request.session; - session.connections++; - session.save(); - socket.on('disconnect', () => { - sockets.delete(socket); - socketsEvents.emit('updated'); - }); - } - - const renewSession = (socket:any, next:Function) => { - socket.conn.on('packet', (packet:string) => { - // Tell express-session that the session is still active. The session store can use these - // touch events to defer automatic session cleanup, and if express-session is configured with - // rolling=true the cookie's expiration time will be renewed. (Note that WebSockets does not - // have a standard mechanism for periodically updating the browser's cookies, so the browser - // will not see the new cookie expiration time unless it makes a new HTTP request or the new - // cookie value is sent to the client in a custom socket.io message.) - if (socket.request.session != null) socket.request.session.touch(); - }); - next(); - } - - - io.on('connection', handleConnection); - - io.use(socketSessionMiddleware(args)); - - // Temporary workaround so all clients go through middleware and handle connection - io.of('/pluginfw/installer') - .on('connection',handleConnection) - .use(socketSessionMiddleware(args)) - .use(renewSession) - io.of('/settings') - .on('connection',handleConnection) - .use(socketSessionMiddleware(args)) - .use(renewSession) - - io.use(renewSession); - - // var socketIOLogger = log4js.getLogger("socket.io"); - // Debug logging now has to be set at an environment level, this is stupid. - // https://github.com/Automattic/socket.io/wiki/Migrating-to-1.0 - // This debug logging environment is set in Settings.js - - // minify socket.io javascript - // Due to a shitty decision by the SocketIO team minification is - // no longer available, details available at: - // http://stackoverflow.com/questions/23981741/minify-socket-io-socket-io-js-with-1-0 - // if(settings.minify) io.enable('browser client minification'); - - // Initialize the Socket.IO Router - socketIORouter.setSocketIO(io); - socketIORouter.addComponent('pad', padMessageHandler); - - hooks.callAll('socketio', {app: args.app, io, server: args.server}); - - return cb(); +const socketSessionMiddleware = + (args: any) => (socket: any, next: Function) => { + const req = socket.request; + // Express sets req.ip but socket.io does not. Replicate Express's behavior here. + if (req.ip == null) { + if (settings.trustProxy) { + req.ip = proxyaddr(req, args.app.get("trust proxy fn")); + } else { + req.ip = socket.handshake.address; + } + } + if (!req.headers.cookie) { + // socketio.js-client on node.js doesn't support cookies, so pass them via a query parameter. + req.headers.cookie = socket.handshake.query.cookie; + } + express.sessionMiddleware(req, {}, next); + }; + +export const expressCreateServer = ( + hookName: string, + args: ArgsExpressType, + cb: Function, +) => { + // init socket.io and redirect all requests to the MessageHandler + // there shouldn't be a browser that isn't compatible to all + // transports in this list at once + // e.g. XHR is disabled in IE by default, so in IE it should use jsonp-polling + io = new Server(args.server, { + transports: settings.socketTransportProtocols, + cookie: false, + maxHttpBufferSize: settings.socketIo.maxHttpBufferSize, + }); + + const handleConnection = (socket: Socket) => { + sockets.add(socket); + socketsEvents.emit("updated"); + // https://socket.io/docs/v3/faq/index.html + // @ts-ignore + const session = socket.request.session; + session.connections++; + session.save(); + socket.on("disconnect", () => { + sockets.delete(socket); + socketsEvents.emit("updated"); + }); + }; + + const renewSession = (socket: any, next: Function) => { + socket.conn.on("packet", (packet: string) => { + // Tell express-session that the session is still active. The session store can use these + // touch events to defer automatic session cleanup, and if express-session is configured with + // rolling=true the cookie's expiration time will be renewed. (Note that WebSockets does not + // have a standard mechanism for periodically updating the browser's cookies, so the browser + // will not see the new cookie expiration time unless it makes a new HTTP request or the new + // cookie value is sent to the client in a custom socket.io message.) + if (socket.request.session != null) socket.request.session.touch(); + }); + next(); + }; + + io.on("connection", handleConnection); + + io.use(socketSessionMiddleware(args)); + + // Temporary workaround so all clients go through middleware and handle connection + io.of("/pluginfw/installer") + .on("connection", handleConnection) + .use(socketSessionMiddleware(args)) + .use(renewSession); + io.of("/settings") + .on("connection", handleConnection) + .use(socketSessionMiddleware(args)) + .use(renewSession); + + io.use(renewSession); + + // var socketIOLogger = log4js.getLogger("socket.io"); + // Debug logging now has to be set at an environment level, this is stupid. + // https://github.com/Automattic/socket.io/wiki/Migrating-to-1.0 + // This debug logging environment is set in Settings.js + + // minify socket.io javascript + // Due to a shitty decision by the SocketIO team minification is + // no longer available, details available at: + // http://stackoverflow.com/questions/23981741/minify-socket-io-socket-io-js-with-1-0 + // if(settings.minify) io.enable('browser client minification'); + + // Initialize the Socket.IO Router + socketIORouter.setSocketIO(io); + socketIORouter.addComponent("pad", padMessageHandler); + + hooks.callAll("socketio", { app: args.app, io, server: args.server }); + + return cb(); }; diff --git a/src/node/hooks/express/specialpages.ts b/src/node/hooks/express/specialpages.ts index 85a23479f..1cc5bb53d 100644 --- a/src/node/hooks/express/specialpages.ts +++ b/src/node/hooks/express/specialpages.ts @@ -1,121 +1,141 @@ -'use strict'; +"use strict"; -const path = require('path'); -const eejs = require('../../eejs'); -const fs = require('fs'); +const path = require("path"); +const eejs = require("../../eejs"); +const fs = require("fs"); const fsp = fs.promises; -const toolbar = require('../../utils/toolbar'); -const hooks = require('../../../static/js/pluginfw/hooks'); -const settings = require('../../utils/Settings'); -const util = require('util'); -const webaccess = require('./webaccess'); +const toolbar = require("../../utils/toolbar"); +const hooks = require("../../../static/js/pluginfw/hooks"); +const settings = require("../../utils/Settings"); +const util = require("util"); +const webaccess = require("./webaccess"); -exports.expressPreSession = async (hookName:string, {app}:any) => { - // This endpoint is intended to conform to: - // https://www.ietf.org/archive/id/draft-inadarei-api-health-check-06.html - app.get('/health', (req:any, res:any) => { - res.set('Content-Type', 'application/health+json'); - res.json({ - status: 'pass', - releaseId: settings.getEpVersion(), - }); - }); +exports.expressPreSession = async (hookName: string, { app }: any) => { + // This endpoint is intended to conform to: + // https://www.ietf.org/archive/id/draft-inadarei-api-health-check-06.html + app.get("/health", (req: any, res: any) => { + res.set("Content-Type", "application/health+json"); + res.json({ + status: "pass", + releaseId: settings.getEpVersion(), + }); + }); - app.get('/stats', (req:any, res:any) => { - res.json(require('../../stats').toJSON()); - }); + app.get("/stats", (req: any, res: any) => { + res.json(require("../../stats").toJSON()); + }); - app.get('/javascript', (req:any, res:any) => { - res.send(eejs.require('ep_etherpad-lite/templates/javascript.html', {req})); - }); + app.get("/javascript", (req: any, res: any) => { + res.send( + eejs.require("ep_etherpad-lite/templates/javascript.html", { req }), + ); + }); - app.get('/robots.txt', (req:any, res:any) => { - let filePath = - path.join(settings.root, 'src', 'static', 'skins', settings.skinName, 'robots.txt'); - res.sendFile(filePath, (err:any) => { - // there is no custom robots.txt, send the default robots.txt which dissallows all - if (err) { - filePath = path.join(settings.root, 'src', 'static', 'robots.txt'); - res.sendFile(filePath); - } - }); - }); + app.get("/robots.txt", (req: any, res: any) => { + let filePath = path.join( + settings.root, + "src", + "static", + "skins", + settings.skinName, + "robots.txt", + ); + res.sendFile(filePath, (err: any) => { + // there is no custom robots.txt, send the default robots.txt which dissallows all + if (err) { + filePath = path.join(settings.root, "src", "static", "robots.txt"); + res.sendFile(filePath); + } + }); + }); - app.get('/favicon.ico', (req:any, res:any, next:Function) => { - (async () => { - /* + app.get("/favicon.ico", (req: any, res: any, next: Function) => { + (async () => { + /* If this is a url we simply redirect to that one. */ - if (settings.favicon && settings.favicon.startsWith('http')) { - res.redirect(settings.favicon); - res.send(); - return; - } + if (settings.favicon && settings.favicon.startsWith("http")) { + res.redirect(settings.favicon); + res.send(); + return; + } - - const fns = [ - ...(settings.favicon ? [path.resolve(settings.root, settings.favicon)] : []), - path.join(settings.root, 'src', 'static', 'skins', settings.skinName, 'favicon.ico'), - path.join(settings.root, 'src', 'static', 'favicon.ico'), - ]; - for (const fn of fns) { - try { - await fsp.access(fn, fs.constants.R_OK); - } catch (err) { - continue; - } - res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`); - await util.promisify(res.sendFile.bind(res))(fn); - return; - } - next(); - })().catch((err) => next(err || new Error(err))); - }); + const fns = [ + ...(settings.favicon + ? [path.resolve(settings.root, settings.favicon)] + : []), + path.join( + settings.root, + "src", + "static", + "skins", + settings.skinName, + "favicon.ico", + ), + path.join(settings.root, "src", "static", "favicon.ico"), + ]; + for (const fn of fns) { + try { + await fsp.access(fn, fs.constants.R_OK); + } catch (err) { + continue; + } + res.setHeader("Cache-Control", `public, max-age=${settings.maxAge}`); + await util.promisify(res.sendFile.bind(res))(fn); + return; + } + next(); + })().catch((err) => next(err || new Error(err))); + }); }; -exports.expressCreateServer = (hookName:string, args:any, cb:Function) => { - // serve index.html under / - args.app.get('/', (req:any, res:any) => { - res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req})); - }); +exports.expressCreateServer = (hookName: string, args: any, cb: Function) => { + // serve index.html under / + args.app.get("/", (req: any, res: any) => { + res.send(eejs.require("ep_etherpad-lite/templates/index.html", { req })); + }); - // serve pad.html under /p - args.app.get('/p/:pad', (req:any, res:any, next:Function) => { - // The below might break for pads being rewritten - const isReadOnly = !webaccess.userCanModify(req.params.pad, req); + // serve pad.html under /p + args.app.get("/p/:pad", (req: any, res: any, next: Function) => { + // The below might break for pads being rewritten + const isReadOnly = !webaccess.userCanModify(req.params.pad, req); - hooks.callAll('padInitToolbar', { - toolbar, - isReadOnly, - }); + hooks.callAll("padInitToolbar", { + toolbar, + isReadOnly, + }); - // can be removed when require-kernel is dropped - res.header('Feature-Policy', 'sync-xhr \'self\''); - res.send(eejs.require('ep_etherpad-lite/templates/pad.html', { - req, - toolbar, - isReadOnly, - })); - }); + // can be removed when require-kernel is dropped + res.header("Feature-Policy", "sync-xhr 'self'"); + res.send( + eejs.require("ep_etherpad-lite/templates/pad.html", { + req, + toolbar, + isReadOnly, + }), + ); + }); - // serve timeslider.html under /p/$padname/timeslider - args.app.get('/p/:pad/timeslider', (req:any, res:any, next:Function) => { - hooks.callAll('padInitToolbar', { - toolbar, - }); + // serve timeslider.html under /p/$padname/timeslider + args.app.get("/p/:pad/timeslider", (req: any, res: any, next: Function) => { + hooks.callAll("padInitToolbar", { + toolbar, + }); - res.send(eejs.require('ep_etherpad-lite/templates/timeslider.html', { - req, - toolbar, - })); - }); + res.send( + eejs.require("ep_etherpad-lite/templates/timeslider.html", { + req, + toolbar, + }), + ); + }); - // The client occasionally polls this endpoint to get an updated expiration for the express_sid - // cookie. This handler must be installed after the express-session middleware. - args.app.put('/_extendExpressSessionLifetime', (req:any, res:any) => { - // express-session automatically calls req.session.touch() so we don't need to do it here. - res.json({status: 'ok'}); - }); + // The client occasionally polls this endpoint to get an updated expiration for the express_sid + // cookie. This handler must be installed after the express-session middleware. + args.app.put("/_extendExpressSessionLifetime", (req: any, res: any) => { + // express-session automatically calls req.session.touch() so we don't need to do it here. + res.json({ status: "ok" }); + }); - return cb(); + return cb(); }; diff --git a/src/node/hooks/express/static.ts b/src/node/hooks/express/static.ts index 1d9ba01e9..d2d3607df 100644 --- a/src/node/hooks/express/static.ts +++ b/src/node/hooks/express/static.ts @@ -1,81 +1,96 @@ -'use strict'; +"use strict"; -import {MapArrayType} from "../../types/MapType"; -import {PartType} from "../../types/PartType"; +import { MapArrayType } from "../../types/MapType"; +import { PartType } from "../../types/PartType"; -const fs = require('fs').promises; -const minify = require('../../utils/Minify'); -const path = require('path'); -const plugins = require('../../../static/js/pluginfw/plugin_defs'); -const settings = require('../../utils/Settings'); -import CachingMiddleware from '../../utils/caching_middleware'; -const Yajsml = require('etherpad-yajsml'); +const fs = require("fs").promises; +const minify = require("../../utils/Minify"); +const path = require("path"); +const plugins = require("../../../static/js/pluginfw/plugin_defs"); +const settings = require("../../utils/Settings"); +import CachingMiddleware from "../../utils/caching_middleware"; +const Yajsml = require("etherpad-yajsml"); // Rewrite tar to include modules with no extensions and proper rooted paths. const getTar = async () => { - const prefixLocalLibraryPath = (path:string) => { - if (path.charAt(0) === '$') { - return path.slice(1); - } else { - return `ep_etherpad-lite/static/js/${path}`; - } - }; + const prefixLocalLibraryPath = (path: string) => { + if (path.charAt(0) === "$") { + return path.slice(1); + } else { + return `ep_etherpad-lite/static/js/${path}`; + } + }; - const tarJson = await fs.readFile(path.join(settings.root, 'src/node/utils/tar.json'), 'utf8'); - const tar:MapArrayType = {}; - for (const [key, relativeFiles] of Object.entries(JSON.parse(tarJson)) as [string, string[]][]) { - const files = relativeFiles.map(prefixLocalLibraryPath); - tar[prefixLocalLibraryPath(key)] = files - .concat(files.map((p) => p.replace(/\.js$/, ''))) - .concat(files.map((p) => `${p.replace(/\.js$/, '')}/index.js`)); - } - return tar; + const tarJson = await fs.readFile( + path.join(settings.root, "src/node/utils/tar.json"), + "utf8", + ); + const tar: MapArrayType = {}; + for (const [key, relativeFiles] of Object.entries(JSON.parse(tarJson)) as [ + string, + string[], + ][]) { + const files = relativeFiles.map(prefixLocalLibraryPath); + tar[prefixLocalLibraryPath(key)] = files + .concat(files.map((p) => p.replace(/\.js$/, ""))) + .concat(files.map((p) => `${p.replace(/\.js$/, "")}/index.js`)); + } + return tar; }; -exports.expressPreSession = async (hookName:string, {app}:any) => { - // Cache both minified and static. - const assetCache = new CachingMiddleware(); - // Cache static assets - app.all(/\/js\/(.*)/, assetCache.handle.bind(assetCache)); - app.all(/\/css\/(.*)/, assetCache.handle.bind(assetCache)); +exports.expressPreSession = async (hookName: string, { app }: any) => { + // Cache both minified and static. + const assetCache = new CachingMiddleware(); + // Cache static assets + app.all(/\/js\/(.*)/, assetCache.handle.bind(assetCache)); + app.all(/\/css\/(.*)/, assetCache.handle.bind(assetCache)); - // Minify will serve static files compressed (minify enabled). It also has - // file-specific hacks for ace/require-kernel/etc. - app.all('/static/:filename(*)', minify.minify); + // Minify will serve static files compressed (minify enabled). It also has + // file-specific hacks for ace/require-kernel/etc. + app.all("/static/:filename(*)", minify.minify); - // Setup middleware that will package JavaScript files served by minify for - // CommonJS loader on the client-side. - // Hostname "invalid.invalid" is a dummy value to allow parsing as a URI. - const jsServer = new (Yajsml.Server)({ - rootPath: 'javascripts/src/', - rootURI: 'http://invalid.invalid/static/js/', - libraryPath: 'javascripts/lib/', - libraryURI: 'http://invalid.invalid/static/plugins/', - requestURIs: minify.requestURIs, // Loop-back is causing problems, this is a workaround. - }); + // Setup middleware that will package JavaScript files served by minify for + // CommonJS loader on the client-side. + // Hostname "invalid.invalid" is a dummy value to allow parsing as a URI. + const jsServer = new Yajsml.Server({ + rootPath: "javascripts/src/", + rootURI: "http://invalid.invalid/static/js/", + libraryPath: "javascripts/lib/", + libraryURI: "http://invalid.invalid/static/plugins/", + requestURIs: minify.requestURIs, // Loop-back is causing problems, this is a workaround. + }); - const StaticAssociator = Yajsml.associators.StaticAssociator; - const associations = Yajsml.associators.associationsForSimpleMapping(await getTar()); - const associator = new StaticAssociator(associations); - jsServer.setAssociator(associator); + const StaticAssociator = Yajsml.associators.StaticAssociator; + const associations = Yajsml.associators.associationsForSimpleMapping( + await getTar(), + ); + const associator = new StaticAssociator(associations); + jsServer.setAssociator(associator); - app.use(jsServer.handle.bind(jsServer)); + app.use(jsServer.handle.bind(jsServer)); - // serve plugin definitions - // not very static, but served here so that client can do - // require("pluginfw/static/js/plugin-definitions.js"); - app.get('/pluginfw/plugin-definitions.json', (req: any, res:any, next:Function) => { - const clientParts = plugins.parts.filter((part: PartType) => part.client_hooks != null); - const clientPlugins:MapArrayType = {}; - for (const name of new Set(clientParts.map((part: PartType) => part.plugin))) { - // @ts-ignore - clientPlugins[name] = {...plugins.plugins[name]}; - // @ts-ignore - delete clientPlugins[name].package; - } - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`); - res.write(JSON.stringify({plugins: clientPlugins, parts: clientParts})); - res.end(); - }); + // serve plugin definitions + // not very static, but served here so that client can do + // require("pluginfw/static/js/plugin-definitions.js"); + app.get( + "/pluginfw/plugin-definitions.json", + (req: any, res: any, next: Function) => { + const clientParts = plugins.parts.filter( + (part: PartType) => part.client_hooks != null, + ); + const clientPlugins: MapArrayType = {}; + for (const name of new Set( + clientParts.map((part: PartType) => part.plugin), + )) { + // @ts-ignore + clientPlugins[name] = { ...plugins.plugins[name] }; + // @ts-ignore + delete clientPlugins[name].package; + } + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", `public, max-age=${settings.maxAge}`); + res.write(JSON.stringify({ plugins: clientPlugins, parts: clientParts })); + res.end(); + }, + ); }; diff --git a/src/node/hooks/express/tests.ts b/src/node/hooks/express/tests.ts index f8a1417ef..2acd7372b 100644 --- a/src/node/hooks/express/tests.ts +++ b/src/node/hooks/express/tests.ts @@ -1,83 +1,103 @@ -'use strict'; +"use strict"; -import {Dirent} from "node:fs"; -import {PluginDef} from "../../types/PartType"; +import { Dirent } from "node:fs"; +import { PluginDef } from "../../types/PartType"; -const path = require('path'); -const fsp = require('fs').promises; -const plugins = require('../../../static/js/pluginfw/plugin_defs'); -const sanitizePathname = require('../../utils/sanitizePathname'); -const settings = require('../../utils/Settings'); +const path = require("path"); +const fsp = require("fs").promises; +const plugins = require("../../../static/js/pluginfw/plugin_defs"); +const sanitizePathname = require("../../utils/sanitizePathname"); +const settings = require("../../utils/Settings"); // Returns all *.js files under specDir (recursively) as relative paths to specDir, using '/' // instead of path.sep to separate pathname components. const findSpecs = async (specDir: string) => { - let dirents: Dirent[]; - try { - dirents = await fsp.readdir(specDir, {withFileTypes: true}); - } catch (err:any) { - if (['ENOENT', 'ENOTDIR'].includes(err.code)) return []; - throw err; - } - const specs: string[] = []; - await Promise.all(dirents.map(async (dirent) => { - if (dirent.isDirectory()) { - const subdirSpecs = await findSpecs(path.join(specDir, dirent.name)); - specs.push(...subdirSpecs.map((spec) => `${dirent.name}/${spec}`)); - return; - } - if (!dirent.name.endsWith('.js')) return; - specs.push(dirent.name); - })); - return specs; + let dirents: Dirent[]; + try { + dirents = await fsp.readdir(specDir, { withFileTypes: true }); + } catch (err: any) { + if (["ENOENT", "ENOTDIR"].includes(err.code)) return []; + throw err; + } + const specs: string[] = []; + await Promise.all( + dirents.map(async (dirent) => { + if (dirent.isDirectory()) { + const subdirSpecs = await findSpecs(path.join(specDir, dirent.name)); + specs.push(...subdirSpecs.map((spec) => `${dirent.name}/${spec}`)); + return; + } + if (!dirent.name.endsWith(".js")) return; + specs.push(dirent.name); + }), + ); + return specs; }; -exports.expressPreSession = async (hookName:string, {app}:any) => { - app.get('/tests/frontend/frontendTestSpecs.json', (req:any, res:any, next:Function) => { - (async () => { - const modules:string[] = []; - await Promise.all(Object.entries(plugins.plugins).map(async ([plugin, def]) => { - let {package: {path: pluginPath}} = def as PluginDef; - if (!pluginPath.endsWith(path.sep)) pluginPath += path.sep; - const specDir = `${plugin === 'ep_etherpad-lite' ? '' : 'static/'}tests/frontend/specs`; - for (const spec of await findSpecs(path.join(pluginPath, specDir))) { - if (plugin === 'ep_etherpad-lite' && !settings.enableAdminUITests && - spec.startsWith('admin')) continue; - modules.push(`${plugin}/${specDir}/${spec.replace(/\.js$/, '')}`); - } - })); - // Sort plugin tests before core tests. - modules.sort((a, b) => { - a = String(a); - b = String(b); - const aCore = a.startsWith('ep_etherpad-lite/'); - const bCore = b.startsWith('ep_etherpad-lite/'); - if (aCore === bCore) return a.localeCompare(b); - return aCore ? 1 : -1; - }); - console.debug('Sent browser the following test spec modules:', modules); - res.json(modules); - })().catch((err) => next(err || new Error(err))); - }); +exports.expressPreSession = async (hookName: string, { app }: any) => { + app.get( + "/tests/frontend/frontendTestSpecs.json", + (req: any, res: any, next: Function) => { + (async () => { + const modules: string[] = []; + await Promise.all( + Object.entries(plugins.plugins).map(async ([plugin, def]) => { + let { + package: { path: pluginPath }, + } = def as PluginDef; + if (!pluginPath.endsWith(path.sep)) pluginPath += path.sep; + const specDir = `${ + plugin === "ep_etherpad-lite" ? "" : "static/" + }tests/frontend/specs`; + for (const spec of await findSpecs( + path.join(pluginPath, specDir), + )) { + if ( + plugin === "ep_etherpad-lite" && + !settings.enableAdminUITests && + spec.startsWith("admin") + ) + continue; + modules.push(`${plugin}/${specDir}/${spec.replace(/\.js$/, "")}`); + } + }), + ); + // Sort plugin tests before core tests. + modules.sort((a, b) => { + a = String(a); + b = String(b); + const aCore = a.startsWith("ep_etherpad-lite/"); + const bCore = b.startsWith("ep_etherpad-lite/"); + if (aCore === bCore) return a.localeCompare(b); + return aCore ? 1 : -1; + }); + console.debug("Sent browser the following test spec modules:", modules); + res.json(modules); + })().catch((err) => next(err || new Error(err))); + }, + ); - const rootTestFolder = path.join(settings.root, 'src/tests/frontend/'); + const rootTestFolder = path.join(settings.root, "src/tests/frontend/"); - app.get('/tests/frontend/index.html', (req:any, res:any) => { - res.redirect(['./', ...req.url.split('?').slice(1)].join('?')); - }); + app.get("/tests/frontend/index.html", (req: any, res: any) => { + res.redirect(["./", ...req.url.split("?").slice(1)].join("?")); + }); - // The regexp /[\d\D]{0,}/ is equivalent to the regexp /.*/. The Express route path used here - // uses the more verbose /[\d\D]{0,}/ pattern instead of /.*/ because path-to-regexp v0.1.7 (the - // version used with Express v4.x) interprets '.' and '*' differently than regexp. - app.get('/tests/frontend/:file([\\d\\D]{0,})', (req:any, res:any, next:Function) => { - (async () => { - let file = sanitizePathname(req.params.file); - if (['', '.', './'].includes(file)) file = 'index.html'; - res.sendFile(path.join(rootTestFolder, file)); - })().catch((err) => next(err || new Error(err))); - }); + // The regexp /[\d\D]{0,}/ is equivalent to the regexp /.*/. The Express route path used here + // uses the more verbose /[\d\D]{0,}/ pattern instead of /.*/ because path-to-regexp v0.1.7 (the + // version used with Express v4.x) interprets '.' and '*' differently than regexp. + app.get( + "/tests/frontend/:file([\\d\\D]{0,})", + (req: any, res: any, next: Function) => { + (async () => { + let file = sanitizePathname(req.params.file); + if (["", ".", "./"].includes(file)) file = "index.html"; + res.sendFile(path.join(rootTestFolder, file)); + })().catch((err) => next(err || new Error(err))); + }, + ); - app.get('/tests/frontend', (req:any, res:any) => { - res.redirect(['./frontend/', ...req.url.split('?').slice(1)].join('?')); - }); + app.get("/tests/frontend", (req: any, res: any) => { + res.redirect(["./frontend/", ...req.url.split("?").slice(1)].join("?")); + }); }; diff --git a/src/node/hooks/express/webaccess.ts b/src/node/hooks/express/webaccess.ts index cb6884dc3..9d3be038e 100644 --- a/src/node/hooks/express/webaccess.ts +++ b/src/node/hooks/express/webaccess.ts @@ -1,218 +1,256 @@ -'use strict'; +"use strict"; -import {strict as assert} from "assert"; -import log4js from 'log4js'; -import {SocketClientRequest} from "../../types/SocketClientRequest"; -import {WebAccessTypes} from "../../types/WebAccessTypes"; -import {SettingsUser} from "../../types/SettingsUser"; -const httpLogger = log4js.getLogger('http'); -const settings = require('../../utils/Settings'); -const hooks = require('../../../static/js/pluginfw/hooks'); -const readOnlyManager = require('../../db/ReadOnlyManager'); +import { strict as assert } from "assert"; +import log4js from "log4js"; +import { SocketClientRequest } from "../../types/SocketClientRequest"; +import { WebAccessTypes } from "../../types/WebAccessTypes"; +import { SettingsUser } from "../../types/SettingsUser"; +const httpLogger = log4js.getLogger("http"); +const settings = require("../../utils/Settings"); +const hooks = require("../../../static/js/pluginfw/hooks"); +const readOnlyManager = require("../../db/ReadOnlyManager"); -hooks.deprecationNotices.authFailure = 'use the authnFailure and authzFailure hooks instead'; +hooks.deprecationNotices.authFailure = + "use the authnFailure and authzFailure hooks instead"; // Promisified wrapper around hooks.aCallFirst. -const aCallFirst = (hookName: string, context:any, pred = null) => new Promise((resolve, reject) => { - hooks.aCallFirst(hookName, context, (err:any, r: unknown) => err != null ? reject(err) : resolve(r), pred); -}); +const aCallFirst = (hookName: string, context: any, pred = null) => + new Promise((resolve, reject) => { + hooks.aCallFirst( + hookName, + context, + (err: any, r: unknown) => (err != null ? reject(err) : resolve(r)), + pred, + ); + }); const aCallFirst0 = - // @ts-ignore - async (hookName: string, context:any, pred = null) => (await aCallFirst(hookName, context, pred))[0]; + // @ts-ignore + async (hookName: string, context: any, pred = null) => + (await aCallFirst(hookName, context, pred))[0]; -exports.normalizeAuthzLevel = (level: string|boolean) => { - if (!level) return false; - switch (level) { - case true: - return 'create'; - case 'readOnly': - case 'modify': - case 'create': - return level; - default: - httpLogger.warn(`Unknown authorization level '${level}', denying access`); - } - return false; +exports.normalizeAuthzLevel = (level: string | boolean) => { + if (!level) return false; + switch (level) { + case true: + return "create"; + case "readOnly": + case "modify": + case "create": + return level; + default: + httpLogger.warn(`Unknown authorization level '${level}', denying access`); + } + return false; }; exports.userCanModify = (padId: string, req: SocketClientRequest) => { - if (readOnlyManager.isReadOnlyId(padId)) return false; - if (!settings.requireAuthentication) return true; - const {session: {user} = {}} = req; - if (!user || user.readOnly) return false; - assert(user.padAuthorizations); // This is populated even if !settings.requireAuthorization. - const level = exports.normalizeAuthzLevel(user.padAuthorizations[padId]); - return level && level !== 'readOnly'; + if (readOnlyManager.isReadOnlyId(padId)) return false; + if (!settings.requireAuthentication) return true; + const { + session: { user } = {}, + } = req; + if (!user || user.readOnly) return false; + assert(user.padAuthorizations); // This is populated even if !settings.requireAuthorization. + const level = exports.normalizeAuthzLevel(user.padAuthorizations[padId]); + return level && level !== "readOnly"; }; // Exported so that tests can set this to 0 to avoid unnecessary test slowness. exports.authnFailureDelayMs = 1000; -const checkAccess = async (req:any, res:any, next: Function) => { - const requireAdmin = req.path.toLowerCase().startsWith('/admin-auth'); +const checkAccess = async (req: any, res: any, next: Function) => { + const requireAdmin = req.path.toLowerCase().startsWith("/admin-auth"); - // /////////////////////////////////////////////////////////////////////////////////////////////// - // Step 1: Check the preAuthorize hook for early permit/deny (permit is only allowed for non-admin - // pages). If any plugin explicitly grants or denies access, skip the remaining steps. Plugins can - // use the preAuthzFailure hook to override the default 403 error. - // /////////////////////////////////////////////////////////////////////////////////////////////// + // /////////////////////////////////////////////////////////////////////////////////////////////// + // Step 1: Check the preAuthorize hook for early permit/deny (permit is only allowed for non-admin + // pages). If any plugin explicitly grants or denies access, skip the remaining steps. Plugins can + // use the preAuthzFailure hook to override the default 403 error. + // /////////////////////////////////////////////////////////////////////////////////////////////// - let results: null|boolean[]; - let skip = false; - const preAuthorizeNext = (...args:any) => { skip = true; next(...args); }; - try { - results = await aCallFirst('preAuthorize', {req, res, next: preAuthorizeNext}, - // This predicate will cause aCallFirst to call the hook functions one at a time until one - // of them returns a non-empty list, with an exception: If the request is for an /admin - // page, truthy entries are filtered out before checking to see whether the list is empty. - // This prevents plugin authors from accidentally granting admin privileges to the general - // public. - // @ts-ignore - (r) => (skip || (r != null && r.filter((x) => (!requireAdmin || !x)).length > 0))) as boolean[]; - } catch (err:any) { - httpLogger.error(`Error in preAuthorize hook: ${err.stack || err.toString()}`); - if (!skip) res.status(500).send('Internal Server Error'); - return; - } - if (skip) return; - if (requireAdmin) { - // Filter out all 'true' entries to prevent plugin authors from accidentally granting admin - // privileges to the general public. - results = results.filter((x) => !x); - } - if (results.length > 0) { - // Access was explicitly granted or denied. If any value is false then access is denied. - if (results.every((x) => x)) return next(); - if (await aCallFirst0('preAuthzFailure', {req, res})) return; - // No plugin handled the pre-authentication authorization failure. - return res.status(403).send('Forbidden'); - } + let results: null | boolean[]; + let skip = false; + const preAuthorizeNext = (...args: any) => { + skip = true; + next(...args); + }; + try { + results = (await aCallFirst( + "preAuthorize", + { req, res, next: preAuthorizeNext }, + // This predicate will cause aCallFirst to call the hook functions one at a time until one + // of them returns a non-empty list, with an exception: If the request is for an /admin + // page, truthy entries are filtered out before checking to see whether the list is empty. + // This prevents plugin authors from accidentally granting admin privileges to the general + // public. + // @ts-ignore + (r) => + skip || (r != null && r.filter((x) => !requireAdmin || !x).length > 0), + )) as boolean[]; + } catch (err: any) { + httpLogger.error( + `Error in preAuthorize hook: ${err.stack || err.toString()}`, + ); + if (!skip) res.status(500).send("Internal Server Error"); + return; + } + if (skip) return; + if (requireAdmin) { + // Filter out all 'true' entries to prevent plugin authors from accidentally granting admin + // privileges to the general public. + results = results.filter((x) => !x); + } + if (results.length > 0) { + // Access was explicitly granted or denied. If any value is false then access is denied. + if (results.every((x) => x)) return next(); + if (await aCallFirst0("preAuthzFailure", { req, res })) return; + // No plugin handled the pre-authentication authorization failure. + return res.status(403).send("Forbidden"); + } - // This helper is used in steps 2 and 4 below, so it may be called twice per access: once before - // authentication is checked and once after (if settings.requireAuthorization is true). - const authorize = async () => { - const grant = async (level: string|false) => { - level = exports.normalizeAuthzLevel(level); - if (!level) return false; - const user = req.session.user; - if (user == null) return true; // This will happen if authentication is not required. - const encodedPadId = (req.path.match(/^\/p\/([^/]*)/) || [])[1]; - if (encodedPadId == null) return true; - let padId = decodeURIComponent(encodedPadId); - if (readOnlyManager.isReadOnlyId(padId)) { - // pad is read-only, first get the real pad ID - padId = await readOnlyManager.getPadId(padId); - if (padId == null) return false; - } - // The user was granted access to a pad. Remember the authorization level in the user's - // settings so that SecurityManager can approve or deny specific actions. - if (user.padAuthorizations == null) user.padAuthorizations = {}; - user.padAuthorizations[padId] = level; - return true; - }; - const isAuthenticated = req.session && req.session.user; - if (isAuthenticated && req.session.user.is_admin) return await grant('create'); - const requireAuthn = requireAdmin || settings.requireAuthentication; - if (!requireAuthn) return await grant('create'); - if (!isAuthenticated) return await grant(false); - if (requireAdmin && !req.session.user.is_admin) return await grant(false); - if (!settings.requireAuthorization) return await grant('create'); - return await grant(await aCallFirst0('authorize', {req, res, next, resource: req.path})); - }; + // This helper is used in steps 2 and 4 below, so it may be called twice per access: once before + // authentication is checked and once after (if settings.requireAuthorization is true). + const authorize = async () => { + const grant = async (level: string | false) => { + level = exports.normalizeAuthzLevel(level); + if (!level) return false; + const user = req.session.user; + if (user == null) return true; // This will happen if authentication is not required. + const encodedPadId = (req.path.match(/^\/p\/([^/]*)/) || [])[1]; + if (encodedPadId == null) return true; + let padId = decodeURIComponent(encodedPadId); + if (readOnlyManager.isReadOnlyId(padId)) { + // pad is read-only, first get the real pad ID + padId = await readOnlyManager.getPadId(padId); + if (padId == null) return false; + } + // The user was granted access to a pad. Remember the authorization level in the user's + // settings so that SecurityManager can approve or deny specific actions. + if (user.padAuthorizations == null) user.padAuthorizations = {}; + user.padAuthorizations[padId] = level; + return true; + }; + const isAuthenticated = req.session && req.session.user; + if (isAuthenticated && req.session.user.is_admin) + return await grant("create"); + const requireAuthn = requireAdmin || settings.requireAuthentication; + if (!requireAuthn) return await grant("create"); + if (!isAuthenticated) return await grant(false); + if (requireAdmin && !req.session.user.is_admin) return await grant(false); + if (!settings.requireAuthorization) return await grant("create"); + return await grant( + await aCallFirst0("authorize", { req, res, next, resource: req.path }), + ); + }; - // /////////////////////////////////////////////////////////////////////////////////////////////// - // Step 2: Try to just access the thing. If access fails (perhaps authentication has not yet - // completed, or maybe different credentials are required), go to the next step. - // /////////////////////////////////////////////////////////////////////////////////////////////// + // /////////////////////////////////////////////////////////////////////////////////////////////// + // Step 2: Try to just access the thing. If access fails (perhaps authentication has not yet + // completed, or maybe different credentials are required), go to the next step. + // /////////////////////////////////////////////////////////////////////////////////////////////// - if (await authorize()) { - if(requireAdmin) { - res.status(200).send('Authorized') - return - } - return next(); - } + if (await authorize()) { + if (requireAdmin) { + res.status(200).send("Authorized"); + return; + } + return next(); + } - // /////////////////////////////////////////////////////////////////////////////////////////////// - // Step 3: Authenticate the user. (Or, if already logged in, reauthenticate with different - // credentials if supported by the authn scheme.) If authentication fails, give the user a 401 - // error to request new credentials. Otherwise, go to the next step. Plugins can use the - // authnFailure hook to override the default error handling behavior (e.g., to redirect to a login - // page). - // /////////////////////////////////////////////////////////////////////////////////////////////// + // /////////////////////////////////////////////////////////////////////////////////////////////// + // Step 3: Authenticate the user. (Or, if already logged in, reauthenticate with different + // credentials if supported by the authn scheme.) If authentication fails, give the user a 401 + // error to request new credentials. Otherwise, go to the next step. Plugins can use the + // authnFailure hook to override the default error handling behavior (e.g., to redirect to a login + // page). + // /////////////////////////////////////////////////////////////////////////////////////////////// - if (settings.users == null) settings.users = {}; - const ctx:WebAccessTypes = {req, res, users: settings.users, next}; - // If the HTTP basic auth header is present, extract the username and password so it can be given - // to authn plugins. - const httpBasicAuth = req.headers.authorization && req.headers.authorization.startsWith('Basic '); - if (httpBasicAuth) { - const userpass = - Buffer.from(req.headers.authorization.split(' ')[1], 'base64').toString().split(':'); - ctx.username = userpass.shift(); - // Prevent prototype pollution vulnerabilities in plugins. This also silences a prototype - // pollution warning below (when setting settings.users[ctx.username]) that isn't actually a - // problem unless the attacker can also set Object.prototype.password. - if (ctx.username === '__proto__') ctx.username = null; - ctx.password = userpass.join(':'); - } - if (!(await aCallFirst0('authenticate', ctx))) { - // Fall back to HTTP basic auth. - // @ts-ignore - const {[ctx.username]: {password} = {}} = settings.users as SettingsUser; + if (settings.users == null) settings.users = {}; + const ctx: WebAccessTypes = { req, res, users: settings.users, next }; + // If the HTTP basic auth header is present, extract the username and password so it can be given + // to authn plugins. + const httpBasicAuth = + req.headers.authorization && req.headers.authorization.startsWith("Basic "); + if (httpBasicAuth) { + const userpass = Buffer.from( + req.headers.authorization.split(" ")[1], + "base64", + ) + .toString() + .split(":"); + ctx.username = userpass.shift(); + // Prevent prototype pollution vulnerabilities in plugins. This also silences a prototype + // pollution warning below (when setting settings.users[ctx.username]) that isn't actually a + // problem unless the attacker can also set Object.prototype.password. + if (ctx.username === "__proto__") ctx.username = null; + ctx.password = userpass.join(":"); + } + if (!(await aCallFirst0("authenticate", ctx))) { + // Fall back to HTTP basic auth. + // @ts-ignore + const { + [ctx.username]: { password } = {}, + } = settings.users as SettingsUser; - 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; - // No plugin handled the authentication failure. Fall back to basic authentication. - if (!requireAdmin) { - res.header('WWW-Authenticate', 'Basic realm="Protected Area"'); - } - // Delay the error response for 1s to slow down brute force attacks. - await new Promise((resolve) => setTimeout(resolve, exports.authnFailureDelayMs)); - res.status(401).send('Authentication Required'); - return; - } - settings.users[ctx.username].username = ctx.username; - // Make a shallow copy so that the password property can be deleted (to prevent it from - // appearing in logs or in the database) without breaking future authentication attempts. - req.session.user = {...settings.users[ctx.username]}; - delete req.session.user.password; - } - if (req.session.user == null) { - httpLogger.error('authenticate hook failed to add user settings to session'); - return res.status(500).send('Internal Server Error'); - } - const {username = ''} = req.session.user; - httpLogger.info(`Successful authentication from IP ${req.ip} for user ${username}`); + 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; + // No plugin handled the authentication failure. Fall back to basic authentication. + if (!requireAdmin) { + res.header("WWW-Authenticate", 'Basic realm="Protected Area"'); + } + // Delay the error response for 1s to slow down brute force attacks. + await new Promise((resolve) => + setTimeout(resolve, exports.authnFailureDelayMs), + ); + res.status(401).send("Authentication Required"); + return; + } + settings.users[ctx.username].username = ctx.username; + // Make a shallow copy so that the password property can be deleted (to prevent it from + // appearing in logs or in the database) without breaking future authentication attempts. + req.session.user = { ...settings.users[ctx.username] }; + delete req.session.user.password; + } + if (req.session.user == null) { + httpLogger.error( + "authenticate hook failed to add user settings to session", + ); + return res.status(500).send("Internal Server Error"); + } + const { username = "" } = req.session.user; + httpLogger.info( + `Successful authentication from IP ${req.ip} for user ${username}`, + ); - // /////////////////////////////////////////////////////////////////////////////////////////////// - // Step 4: Try to access the thing again. If this fails, give the user a 403 error. Plugins can - // use the authzFailure hook to override the default error handling behavior (e.g., to redirect to - // a login page). - // /////////////////////////////////////////////////////////////////////////////////////////////// + // /////////////////////////////////////////////////////////////////////////////////////////////// + // Step 4: Try to access the thing again. If this fails, give the user a 403 error. Plugins can + // use the authzFailure hook to override the default error handling behavior (e.g., to redirect to + // a login page). + // /////////////////////////////////////////////////////////////////////////////////////////////// - const auth = await authorize() - if (auth && !requireAdmin) return next(); - if(auth && requireAdmin) { - res.status(200).send('Authorized') - return - } + const auth = await authorize(); + if (auth && !requireAdmin) return next(); + if (auth && requireAdmin) { + res.status(200).send("Authorized"); + return; + } - if (await aCallFirst0('authzFailure', {req, res})) return; - if (await aCallFirst0('authFailure', {req, res, next})) return; - // No plugin handled the authorization failure. - res.status(403).send('Forbidden'); + if (await aCallFirst0("authzFailure", { req, res })) return; + if (await aCallFirst0("authFailure", { req, res, next })) return; + // No plugin handled the authorization failure. + res.status(403).send("Forbidden"); }; /** * Express middleware to authenticate the user and check authorization. Must be installed after the * express-session middleware. */ -exports.checkAccess = (req:any, res:any, next:Function) => { - checkAccess(req, res, next).catch((err) => next(err || new Error(err))); +exports.checkAccess = (req: any, res: any, next: Function) => { + checkAccess(req, res, next).catch((err) => next(err || new Error(err))); }; diff --git a/src/node/hooks/i18n.ts b/src/node/hooks/i18n.ts index 500f1f887..41bf814a3 100644 --- a/src/node/hooks/i18n.ts +++ b/src/node/hooks/i18n.ts @@ -1,149 +1,156 @@ -'use strict'; +"use strict"; -import type {MapArrayType} from "../types/MapType"; -import {I18nPluginDefs} from "../types/I18nPluginDefs"; +import type { MapArrayType } from "../types/MapType"; +import { I18nPluginDefs } from "../types/I18nPluginDefs"; -const languages = require('languages4translatewiki'); -const fs = require('fs'); -const path = require('path'); -const _ = require('underscore'); -const pluginDefs = require('../../static/js/pluginfw/plugin_defs.js'); -const existsSync = require('../utils/path_exists'); -const settings = require('../utils/Settings'); +const languages = require("languages4translatewiki"); +const fs = require("fs"); +const path = require("path"); +const _ = require("underscore"); +const pluginDefs = require("../../static/js/pluginfw/plugin_defs.js"); +const existsSync = require("../utils/path_exists"); +const settings = require("../utils/Settings"); // returns all existing messages merged together and grouped by langcode // {es: {"foo": "string"}, en:...} const getAllLocales = () => { - const locales2paths:MapArrayType = {}; + const locales2paths: MapArrayType = {}; - // Puts the paths of all locale files contained in a given directory - // into `locales2paths` (files from various dirs are grouped by lang code) - // (only json files with valid language code as name) - const extractLangs = (dir: string) => { - if (!existsSync(dir)) return; - let stat = fs.lstatSync(dir); - if (!stat.isDirectory() || stat.isSymbolicLink()) return; + // Puts the paths of all locale files contained in a given directory + // into `locales2paths` (files from various dirs are grouped by lang code) + // (only json files with valid language code as name) + const extractLangs = (dir: string) => { + if (!existsSync(dir)) return; + let stat = fs.lstatSync(dir); + if (!stat.isDirectory() || stat.isSymbolicLink()) return; - fs.readdirSync(dir).forEach((file:string) => { - file = path.resolve(dir, file); - stat = fs.lstatSync(file); - if (stat.isDirectory() || stat.isSymbolicLink()) return; + fs.readdirSync(dir).forEach((file: string) => { + file = path.resolve(dir, file); + stat = fs.lstatSync(file); + if (stat.isDirectory() || stat.isSymbolicLink()) return; - const ext = path.extname(file); - const locale = path.basename(file, ext).toLowerCase(); + const ext = path.extname(file); + const locale = path.basename(file, ext).toLowerCase(); - if ((ext === '.json') && languages.isValid(locale)) { - if (!locales2paths[locale]) locales2paths[locale] = []; - locales2paths[locale].push(file); - } - }); - }; + if (ext === ".json" && languages.isValid(locale)) { + if (!locales2paths[locale]) locales2paths[locale] = []; + locales2paths[locale].push(file); + } + }); + }; - // add core supported languages first - extractLangs(path.join(settings.root, 'src/locales')); + // add core supported languages first + extractLangs(path.join(settings.root, "src/locales")); - // add plugins languages (if any) - for (const {package: {path: pluginPath}} of Object.values(pluginDefs.plugins)) { - // plugin locales should overwrite etherpad's core locales - if (pluginPath.endsWith('/ep_etherpad-lite')) continue; - extractLangs(path.join(pluginPath, 'locales')); - } + // add plugins languages (if any) + for (const { + package: { path: pluginPath }, + } of Object.values(pluginDefs.plugins)) { + // plugin locales should overwrite etherpad's core locales + if (pluginPath.endsWith("/ep_etherpad-lite")) continue; + extractLangs(path.join(pluginPath, "locales")); + } - // Build a locale index (merge all locale data other than user-supplied overrides) - const locales:MapArrayType = {}; - _.each(locales2paths, (files: string[], langcode: string) => { - locales[langcode] = {}; + // Build a locale index (merge all locale data other than user-supplied overrides) + const locales: MapArrayType = {}; + _.each(locales2paths, (files: string[], langcode: string) => { + locales[langcode] = {}; - files.forEach((file) => { - let fileContents; - try { - fileContents = JSON.parse(fs.readFileSync(file, 'utf8')); - } catch (err) { - console.error(`failed to read JSON file ${file}: ${err}`); - throw err; - } - _.extend(locales[langcode], fileContents); - }); - }); + files.forEach((file) => { + let fileContents; + try { + fileContents = JSON.parse(fs.readFileSync(file, "utf8")); + } catch (err) { + console.error(`failed to read JSON file ${file}: ${err}`); + throw err; + } + _.extend(locales[langcode], fileContents); + }); + }); - // Add custom strings from settings.json - // Since this is user-supplied, we'll do some extra sanity checks - const wrongFormatErr = Error( - 'customLocaleStrings in wrong format. See documentation ' + - 'for Customization for Administrators, under Localization.'); - if (settings.customLocaleStrings) { - if (typeof settings.customLocaleStrings !== 'object') throw wrongFormatErr; - _.each(settings.customLocaleStrings, (overrides:MapArrayType , langcode:string) => { - if (typeof overrides !== 'object') throw wrongFormatErr; - _.each(overrides, (localeString:string|object, key:string) => { - if (typeof localeString !== 'string') throw wrongFormatErr; - const locale = locales[langcode]; + // Add custom strings from settings.json + // Since this is user-supplied, we'll do some extra sanity checks + const wrongFormatErr = Error( + "customLocaleStrings in wrong format. See documentation " + + "for Customization for Administrators, under Localization.", + ); + if (settings.customLocaleStrings) { + if (typeof settings.customLocaleStrings !== "object") throw wrongFormatErr; + _.each( + settings.customLocaleStrings, + (overrides: MapArrayType, langcode: string) => { + if (typeof overrides !== "object") throw wrongFormatErr; + _.each(overrides, (localeString: string | object, key: string) => { + if (typeof localeString !== "string") throw wrongFormatErr; + const locale = locales[langcode]; - // Handles the error if an unknown language code is entered - if (locale === undefined) { - const possibleMatches = []; - let strippedLangcode = ''; - if (langcode.includes('-')) { - strippedLangcode = langcode.split('-')[0]; - } - for (const localeInEtherPad of Object.keys(locales)) { - if (localeInEtherPad.includes(strippedLangcode)) { - possibleMatches.push(localeInEtherPad); - } - } - throw new Error(`Language code ${langcode} is unknown. ` + - `Maybe you meant: ${possibleMatches}`); - } + // Handles the error if an unknown language code is entered + if (locale === undefined) { + const possibleMatches = []; + let strippedLangcode = ""; + if (langcode.includes("-")) { + strippedLangcode = langcode.split("-")[0]; + } + for (const localeInEtherPad of Object.keys(locales)) { + if (localeInEtherPad.includes(strippedLangcode)) { + possibleMatches.push(localeInEtherPad); + } + } + throw new Error( + `Language code ${langcode} is unknown. ` + + `Maybe you meant: ${possibleMatches}`, + ); + } - locales[langcode][key] = localeString; - }); - }); - } + locales[langcode][key] = localeString; + }); + }, + ); + } - return locales; + return locales; }; // returns a hash of all available languages availables with nativeName and direction // e.g. { es: {nativeName: "español", direction: "ltr"}, ... } -const getAvailableLangs = (locales:MapArrayType) => { - const result:MapArrayType = {}; - for (const langcode of Object.keys(locales)) { - result[langcode] = languages.getLanguageInfo(langcode); - } - return result; +const getAvailableLangs = (locales: MapArrayType) => { + const result: MapArrayType = {}; + for (const langcode of Object.keys(locales)) { + result[langcode] = languages.getLanguageInfo(langcode); + } + return result; }; // returns locale index that will be served in /locales.json -const generateLocaleIndex = (locales:MapArrayType) => { - const result = _.clone(locales); // keep English strings - for (const langcode of Object.keys(locales)) { - if (langcode !== 'en') result[langcode] = `locales/${langcode}.json`; - } - return JSON.stringify(result); +const generateLocaleIndex = (locales: MapArrayType) => { + const result = _.clone(locales); // keep English strings + for (const langcode of Object.keys(locales)) { + if (langcode !== "en") result[langcode] = `locales/${langcode}.json`; + } + return JSON.stringify(result); }; +exports.expressPreSession = async (hookName: string, { app }: any) => { + // regenerate locales on server restart + const locales = getAllLocales(); + const localeIndex = generateLocaleIndex(locales); + exports.availableLangs = getAvailableLangs(locales); -exports.expressPreSession = async (hookName:string, {app}:any) => { - // regenerate locales on server restart - const locales = getAllLocales(); - const localeIndex = generateLocaleIndex(locales); - exports.availableLangs = getAvailableLangs(locales); + app.get("/locales/:locale", (req: any, res: any) => { + // works with /locale/en and /locale/en.json requests + const locale = req.params.locale.split(".")[0]; + if (Object.prototype.hasOwnProperty.call(exports.availableLangs, locale)) { + res.setHeader("Cache-Control", `public, max-age=${settings.maxAge}`); + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.send(`{"${locale}":${JSON.stringify(locales[locale])}}`); + } else { + res.status(404).send("Language not available"); + } + }); - app.get('/locales/:locale', (req:any, res:any) => { - // works with /locale/en and /locale/en.json requests - const locale = req.params.locale.split('.')[0]; - if (Object.prototype.hasOwnProperty.call(exports.availableLangs, locale)) { - res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`); - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.send(`{"${locale}":${JSON.stringify(locales[locale])}}`); - } else { - res.status(404).send('Language not available'); - } - }); - - app.get('/locales.json', (req: any, res:any) => { - res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`); - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.send(localeIndex); - }); + app.get("/locales.json", (req: any, res: any) => { + res.setHeader("Cache-Control", `public, max-age=${settings.maxAge}`); + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.send(localeIndex); + }); }; diff --git a/src/node/padaccess.ts b/src/node/padaccess.ts index ce3cf9ddd..cd52f23c4 100644 --- a/src/node/padaccess.ts +++ b/src/node/padaccess.ts @@ -1,18 +1,33 @@ -'use strict'; -const securityManager = require('./db/SecurityManager'); +"use strict"; +const securityManager = require("./db/SecurityManager"); // checks for padAccess -module.exports = async (req: { params?: any; cookies?: any; session?: any; }, res: { status: (arg0: number) => { (): any; new(): any; send: { (arg0: string): void; new(): any; }; }; }) => { - const {session: {user} = {}} = req; - const accessObj = await securityManager.checkAccess( - req.params.pad, req.cookies.sessionID, req.cookies.token, user); +module.exports = async ( + req: { params?: any; cookies?: any; session?: any }, + res: { + status: (arg0: number) => { + (): any; + new (): any; + send: { (arg0: string): void; new (): any }; + }; + }, +) => { + const { + session: { user } = {}, + } = req; + const accessObj = await securityManager.checkAccess( + req.params.pad, + req.cookies.sessionID, + req.cookies.token, + user, + ); - if (accessObj.accessStatus === 'grant') { - // there is access, continue - return true; - } else { - // no access - res.status(403).send("403 - Can't touch this"); - return false; - } + if (accessObj.accessStatus === "grant") { + // there is access, continue + return true; + } else { + // no access + res.status(403).send("403 - Can't touch this"); + return false; + } }; diff --git a/src/node/security/OAuth2Provider.ts b/src/node/security/OAuth2Provider.ts index e34926d5b..cf5d3c20d 100644 --- a/src/node/security/OAuth2Provider.ts +++ b/src/node/security/OAuth2Provider.ts @@ -1,288 +1,340 @@ -import {ArgsExpressType} from "../types/ArgsExpressType"; -import Provider, {Account, Configuration} from 'oidc-provider'; -import {generateKeyPair, exportJWK, KeyLike} from 'jose' +import { ArgsExpressType } from "../types/ArgsExpressType"; +import Provider, { Account, Configuration } from "oidc-provider"; +import { generateKeyPair, exportJWK, KeyLike } from "jose"; import MemoryAdapter from "./OIDCAdapter"; import path from "path"; -const settings = require('../utils/Settings'); -import {IncomingForm} from 'formidable' -import express, {Request, Response} from 'express'; -import {format} from 'url' -import {ParsedUrlQuery} from "node:querystring"; -import {Http2ServerRequest, Http2ServerResponse} from "node:http2"; -import {MapArrayType} from "../types/MapType"; +const settings = require("../utils/Settings"); +import { IncomingForm } from "formidable"; +import express, { Request, Response } from "express"; +import { format } from "url"; +import { ParsedUrlQuery } from "node:querystring"; +import { Http2ServerRequest, Http2ServerResponse } from "node:http2"; +import { MapArrayType } from "../types/MapType"; const configuration: Configuration = { - scopes: ['openid', 'profile', 'email'], - findAccount: async (ctx, id) => { - const users = settings.users as { - [username: string]: { - password: string; - is_admin: boolean; - } - } - const usersArray1 = Object.keys(users).map((username) => ({ - username, - ...users[username] - })); + scopes: ["openid", "profile", "email"], + findAccount: async (ctx, id) => { + const users = settings.users as { + [username: string]: { + password: string; + is_admin: boolean; + }; + }; + const usersArray1 = Object.keys(users).map((username) => ({ + username, + ...users[username], + })); - const account = usersArray1.find((user) => user.username === id); + const account = usersArray1.find((user) => user.username === id); - if(account === undefined) { - return undefined - } - if (account.is_admin) { - return { - accountId: id, - claims: () => ({ - sub: id, - admin: true - }) - } as Account - } else { - return { - accountId: id, - claims: () => ({ - sub: id, - }) - } as Account - } - }, - ttl:{ - AccessToken: 1 * 60 * 60, // 1 hour in seconds - AuthorizationCode: 10 * 60, // 10 minutes in seconds - ClientCredentials: 1 * 60 * 60, // 1 hour in seconds - IdToken: 1 * 60 * 60, // 1 hour in seconds - RefreshToken: 1 * 24 * 60 * 60, // 1 day in seconds - }, - claims: { - openid: ['sub'], - email: ['email'], - profile: ['name'], - admin: ['admin'] - }, - cookies: { - keys: ['oidc'], - }, - features:{ - devInteractions: {enabled: false}, - }, - adapter: MemoryAdapter + if (account === undefined) { + return undefined; + } + if (account.is_admin) { + return { + accountId: id, + claims: () => ({ + sub: id, + admin: true, + }), + } as Account; + } else { + return { + accountId: id, + claims: () => ({ + sub: id, + }), + } as Account; + } + }, + ttl: { + AccessToken: 1 * 60 * 60, // 1 hour in seconds + AuthorizationCode: 10 * 60, // 10 minutes in seconds + ClientCredentials: 1 * 60 * 60, // 1 hour in seconds + IdToken: 1 * 60 * 60, // 1 hour in seconds + RefreshToken: 1 * 24 * 60 * 60, // 1 day in seconds + }, + claims: { + openid: ["sub"], + email: ["email"], + profile: ["name"], + admin: ["admin"], + }, + cookies: { + keys: ["oidc"], + }, + features: { + devInteractions: { enabled: false }, + }, + adapter: MemoryAdapter, }; - -export let publicKeyExported: KeyLike|null -export let privateKeyExported: KeyLike|null +export let publicKeyExported: KeyLike | null; +export let privateKeyExported: KeyLike | null; /* This function is used to initialize the OAuth2 provider */ -export const expressCreateServer = async (hookName: string, args: ArgsExpressType, cb: Function) => { - const {privateKey, publicKey} = await generateKeyPair('RS256'); - const privateKeyJWK = await exportJWK(privateKey); - publicKeyExported = publicKey - privateKeyExported = privateKey +export const expressCreateServer = async ( + hookName: string, + args: ArgsExpressType, + cb: Function, +) => { + const { privateKey, publicKey } = await generateKeyPair("RS256"); + const privateKeyJWK = await exportJWK(privateKey); + publicKeyExported = publicKey; + privateKeyExported = privateKey; - const oidc = new Provider(settings.sso.issuer, { - ...configuration, jwks: { - keys: [ - privateKeyJWK - ], - }, - conformIdTokenClaims: false, - claims: { - address: ['address'], - email: ['email', 'email_verified'], - phone: ['phone_number', 'phone_number_verified'], - profile: ['birthdate', 'family_name', 'gender', 'given_name', 'locale', 'middle_name', 'name', - 'nickname', 'picture', 'preferred_username', 'profile', 'updated_at', 'website', 'zoneinfo'], - }, - features:{ - userinfo: {enabled: true}, - claimsParameter: {enabled: true}, - clientCredentials: {enabled: true}, - devInteractions: {enabled: false}, - resourceIndicators: {enabled: true, defaultResource(ctx) { - return ctx.origin; - }, - getResourceServerInfo(ctx, resourceIndicator, client) { - return { - scope: "openid", - audience: 'account', - accessTokenFormat: 'jwt', - }; - }, - useGrantedResource(ctx, model) { - return true; - }, - }, - jwtResponseModes: {enabled: true}, - }, - clientBasedCORS: (ctx, origin, client) => { - return true - }, - extraParams: [], - extraTokenClaims: async (ctx, token) => { - if(token.kind === 'AccessToken') { - // Add your custom claims here. For example: - const users = settings.users as { - [username: string]: { - password: string; - is_admin: boolean; - } - } + const oidc = new Provider(settings.sso.issuer, { + ...configuration, + jwks: { + keys: [privateKeyJWK], + }, + conformIdTokenClaims: false, + claims: { + address: ["address"], + email: ["email", "email_verified"], + phone: ["phone_number", "phone_number_verified"], + profile: [ + "birthdate", + "family_name", + "gender", + "given_name", + "locale", + "middle_name", + "name", + "nickname", + "picture", + "preferred_username", + "profile", + "updated_at", + "website", + "zoneinfo", + ], + }, + features: { + userinfo: { enabled: true }, + claimsParameter: { enabled: true }, + clientCredentials: { enabled: true }, + devInteractions: { enabled: false }, + resourceIndicators: { + enabled: true, + defaultResource(ctx) { + return ctx.origin; + }, + getResourceServerInfo(ctx, resourceIndicator, client) { + return { + scope: "openid", + audience: "account", + accessTokenFormat: "jwt", + }; + }, + useGrantedResource(ctx, model) { + return true; + }, + }, + jwtResponseModes: { enabled: true }, + }, + clientBasedCORS: (ctx, origin, client) => { + return true; + }, + extraParams: [], + extraTokenClaims: async (ctx, token) => { + if (token.kind === "AccessToken") { + // Add your custom claims here. For example: + const users = settings.users as { + [username: string]: { + password: string; + is_admin: boolean; + }; + }; - const usersArray1 = Object.keys(users).map((username) => ({ - username, - ...users[username] - })); + const usersArray1 = Object.keys(users).map((username) => ({ + username, + ...users[username], + })); - const account = usersArray1.find((user) => user.username === token.accountId); - return { - admin: account?.is_admin - }; - } else if (token.kind === "ClientCredentials") { - let extraParams: MapArrayType = {} + const account = usersArray1.find( + (user) => user.username === token.accountId, + ); + return { + admin: account?.is_admin, + }; + } else if (token.kind === "ClientCredentials") { + let extraParams: MapArrayType = {}; - settings.sso.clients - .filter((client:any) => client.client_id === token.clientId) - .forEach((client:any) => { - if(client.extraParams !== undefined) { - client.extraParams.forEach((param:any) => { - extraParams[param.name] = param.value - }) - } - }) - return extraParams - } - }, - clients: settings.sso.clients - }); + settings.sso.clients + .filter((client: any) => client.client_id === token.clientId) + .forEach((client: any) => { + if (client.extraParams !== undefined) { + client.extraParams.forEach((param: any) => { + extraParams[param.name] = param.value; + }); + } + }); + return extraParams; + } + }, + clients: settings.sso.clients, + }); + args.app.post( + "/interaction/:uid", + async ( + req: Http2ServerRequest, + res: Http2ServerResponse, + next: Function, + ) => { + const formid = new IncomingForm(); + try { + // @ts-ignore + const { login, password } = (await formid.parse(req))[0]; + const { prompt, jti, session, cid, params, grantId } = + await oidc.interactionDetails(req, res); - args.app.post('/interaction/:uid', async (req: Http2ServerRequest, res: Http2ServerResponse, next:Function) => { - const formid = new IncomingForm(); - try { - // @ts-ignore - const {login, password} = (await formid.parse(req))[0] - const {prompt, jti, session,cid, params, grantId} = await oidc.interactionDetails(req, res); + const client = await oidc.Client.find(params.client_id as string); - const client = await oidc.Client.find(params.client_id as string); + switch (prompt.name) { + case "login": { + const users = settings.users as { + [username: string]: { + password: string; + admin: boolean; + }; + }; + const usersArray1 = Object.keys(users).map((username) => ({ + username, + ...users[username], + })); + const account = usersArray1.find( + (user) => + user.username === (login as unknown as string) && + user.password === (password as unknown as string), + ); + if (!account) { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Invalid login" })); + } - switch (prompt.name) { - case 'login': { - const users = settings.users as { - [username: string]: { - password: string; - admin: boolean; - } - } - const usersArray1 = Object.keys(users).map((username) => ({ - username, - ...users[username] - })); - const account = usersArray1.find((user) => user.username === login as unknown as string && user.password === password as unknown as string); - if (!account) { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({error: "Invalid login"})); - } + if (account) { + await oidc.interactionFinished( + req, + res, + { + login: { accountId: account.username }, + }, + { mergeWithLastSubmission: false }, + ); + } + break; + } + case "consent": { + let grant; + if (grantId) { + // we'll be modifying existing grant in existing session + grant = await oidc.Grant.find(grantId); + } else { + // we're establishing a new grant + grant = new oidc.Grant({ + accountId: session!.accountId, + clientId: params.client_id as string, + }); + } - if (account) { - await oidc.interactionFinished(req, res, { - login: {accountId: account.username} - }, {mergeWithLastSubmission: false}); - } - break; - } - case 'consent': { - let grant; - if (grantId) { - // we'll be modifying existing grant in existing session - grant = await oidc.Grant.find(grantId); - } else { - // we're establishing a new grant - grant = new oidc.Grant({ - accountId: session!.accountId, - clientId: params.client_id as string, - }); - } + if (prompt.details.missingOIDCScope) { + // @ts-ignore + grant!.addOIDCScope(prompt.details.missingOIDCScope.join(" ")); + } + if (prompt.details.missingOIDCClaims) { + grant!.addOIDCClaims( + prompt.details.missingOIDCClaims as string[], + ); + } + if (prompt.details.missingResourceScopes) { + for (const [indicator, scope] of Object.entries( + prompt.details.missingResourceScopes, + )) { + grant!.addResourceScope(indicator, scope.join(" ")); + } + } + const result = { consent: { grantId: await grant!.save() } }; + await oidc.interactionFinished(req, res, result, { + mergeWithLastSubmission: true, + }); + break; + } + } + await next(); + } catch (err: any) { + return res.writeHead(500).end(err.message); + } + }, + ); - if (prompt.details.missingOIDCScope) { - // @ts-ignore - grant!.addOIDCScope(prompt.details.missingOIDCScope.join(' ')); - } - if (prompt.details.missingOIDCClaims) { - grant!.addOIDCClaims(prompt.details.missingOIDCClaims as string[]); - } - if (prompt.details.missingResourceScopes) { - for (const [indicator, scope] of Object.entries(prompt.details.missingResourceScopes)) { - grant!.addResourceScope(indicator, scope.join(' ')); - } - } - const result = {consent: {grantId: await grant!.save()}}; - await oidc.interactionFinished(req, res, result, { - mergeWithLastSubmission: true, - }); - break; - } - } - await next(); - } catch (err:any) { - return res.writeHead(500).end(err.message); - } - }) + args.app.get( + "/interaction/:uid", + async (req: Request, res: Response, next: Function) => { + try { + const { uid, prompt, params, session } = await oidc.interactionDetails( + req, + res, + ); + params["state"] = uid; - args.app.get('/interaction/:uid', async (req: Request, res: Response, next: Function) => { - try { - const { - uid, prompt, params, session, - } = await oidc.interactionDetails(req, res); + switch (prompt.name) { + case "login": { + res.redirect( + format({ + pathname: "/views/login.html", + query: params as ParsedUrlQuery, + }), + ); + break; + } + case "consent": { + res.redirect( + format({ + pathname: "/views/consent.html", + query: params as ParsedUrlQuery, + }), + ); + break; + } + default: + return res.sendFile( + path.join(settings.root, "src", "static", "oidc", "login.html"), + ); + } + } catch (err) { + return next(err); + } + }, + ); - params["state"] = uid + args.app.use( + "/views/", + express.static(path.join(settings.root, "src", "static", "oidc"), { + maxAge: 1000 * 60 * 60 * 24, + }), + ); - switch (prompt.name) { - case 'login': { - res.redirect(format({ - pathname: '/views/login.html', - query: params as ParsedUrlQuery - })) - break - } - case 'consent': { - res.redirect(format({ - pathname: '/views/consent.html', - query: params as ParsedUrlQuery - })) - break - } - default: - return res.sendFile(path.join(settings.root,'src','static', 'oidc','login.html')); - } - } catch (err) { - return next(err); - } - }); + oidc.on("authorization.error", (ctx, error) => { + console.log("authorization.error", error); + }); - - args.app.use('/views/', express.static(path.join(settings.root,'src','static', 'oidc'), {maxAge: 1000 * 60 * 60 * 24})); - - - oidc.on('authorization.error', (ctx, error) => { - console.log('authorization.error', error); - }) - - oidc.on('server_error', (ctx, error) => { - console.log('server_error', error); - }) - oidc.on('grant.error', (ctx, error) => { - console.log('grant.error', error); - }) - oidc.on('introspection.error', (ctx, error) => { - console.log('introspection.error', error); - }) - oidc.on('revocation.error', (ctx, error) => { - console.log('revocation.error', error); - }) - args.app.use("/oidc", oidc.callback()); - //cb(); -} + oidc.on("server_error", (ctx, error) => { + console.log("server_error", error); + }); + oidc.on("grant.error", (ctx, error) => { + console.log("grant.error", error); + }); + oidc.on("introspection.error", (ctx, error) => { + console.log("introspection.error", error); + }); + oidc.on("revocation.error", (ctx, error) => { + console.log("revocation.error", error); + }); + args.app.use("/oidc", oidc.callback()); + //cb(); +}; diff --git a/src/node/security/OAuth2User.ts b/src/node/security/OAuth2User.ts index b4305c401..4522c78e1 100644 --- a/src/node/security/OAuth2User.ts +++ b/src/node/security/OAuth2User.ts @@ -1,5 +1,5 @@ export type OAuth2User = { - username: string; - password: string; - admin: boolean; -} + username: string; + password: string; + admin: boolean; +}; diff --git a/src/node/security/OIDCAdapter.ts b/src/node/security/OIDCAdapter.ts index 7fb907776..15d5c0769 100644 --- a/src/node/security/OIDCAdapter.ts +++ b/src/node/security/OIDCAdapter.ts @@ -1,115 +1,124 @@ -import {LRUCache} from 'lru-cache'; -import type {Adapter, AdapterPayload} from "oidc-provider"; - +import { LRUCache } from "lru-cache"; +import type { Adapter, AdapterPayload } from "oidc-provider"; const options = { - max: 500, - sizeCalculation: (item:any, key:any) => { - return 1 - }, - // for use with tracking overall storage size - maxSize: 5000, + max: 500, + sizeCalculation: (item: any, key: any) => { + return 1; + }, + // for use with tracking overall storage size + maxSize: 5000, - // how long to live in ms - ttl: 1000 * 60 * 5, + // how long to live in ms + ttl: 1000 * 60 * 5, - // return stale items before removing from cache? - allowStale: false, + // return stale items before removing from cache? + allowStale: false, - updateAgeOnGet: false, - updateAgeOnHas: false, -} + updateAgeOnGet: false, + updateAgeOnHas: false, +}; const epochTime = (date = Date.now()) => Math.floor(date / 1000); -const storage = new LRUCache(options); +const storage = new LRUCache( + options, +); function grantKeyFor(id: string) { - return `grant:${id}`; + return `grant:${id}`; } -function userCodeKeyFor(userCode:string) { - return `userCode:${userCode}`; +function userCodeKeyFor(userCode: string) { + return `userCode:${userCode}`; } -class MemoryAdapter implements Adapter{ - private readonly name: string; - constructor(name:string) { - this.name = name; - } +class MemoryAdapter implements Adapter { + private readonly name: string; + constructor(name: string) { + this.name = name; + } - key(id:string) { - return `${this.name}:${id}`; - } + key(id: string) { + return `${this.name}:${id}`; + } - destroy(id:string) { - const key = this.key(id); + destroy(id: string) { + const key = this.key(id); - const found = storage.get(key) as AdapterPayload; - const grantId = found && found.grantId; + const found = storage.get(key) as AdapterPayload; + const grantId = found && found.grantId; - storage.delete(key); + storage.delete(key); - if (grantId) { - const grantKey = grantKeyFor(grantId); - (storage.get(grantKey) as string[])!.forEach(token => storage.delete(token)); - storage.delete(grantKey); - } + if (grantId) { + const grantKey = grantKeyFor(grantId); + (storage.get(grantKey) as string[])!.forEach((token) => + storage.delete(token), + ); + storage.delete(grantKey); + } - return Promise.resolve(); - } + return Promise.resolve(); + } - consume(id: string) { - (storage.get(this.key(id)) as AdapterPayload)!.consumed = epochTime(); - return Promise.resolve(); - } + consume(id: string) { + (storage.get(this.key(id)) as AdapterPayload)!.consumed = epochTime(); + return Promise.resolve(); + } - find(id: string): Promise { - if (storage.has(this.key(id))){ - return Promise.resolve(storage.get(this.key(id)) as AdapterPayload); - } - return Promise.resolve(undefined) - } + find(id: string): Promise { + if (storage.has(this.key(id))) { + return Promise.resolve( + storage.get(this.key(id)) as AdapterPayload, + ); + } + return Promise.resolve(undefined); + } - findByUserCode(userCode: string) { - const id = storage.get(userCodeKeyFor(userCode)) as string; - return this.find(id); - } + findByUserCode(userCode: string) { + const id = storage.get(userCodeKeyFor(userCode)) as string; + return this.find(id); + } - upsert(id: string, payload: { - iat: number; - exp: number; - uid: string; - kind: string; - jti: string; - accountId: string; - loginTs: number; - }, expiresIn: number) { - const key = this.key(id); + upsert( + id: string, + payload: { + iat: number; + exp: number; + uid: string; + kind: string; + jti: string; + accountId: string; + loginTs: number; + }, + expiresIn: number, + ) { + const key = this.key(id); - storage.set(key, payload, {ttl: expiresIn * 1000}); + storage.set(key, payload, { ttl: expiresIn * 1000 }); - return Promise.resolve(); - } + return Promise.resolve(); + } - findByUid(uid: string): Promise { - for(const [_, value] of storage.entries()){ - if(typeof value ==="object" && "uid" in value && value.uid === uid){ - return Promise.resolve(value); - } - } - return Promise.resolve(undefined); - } + findByUid(uid: string): Promise { + for (const [_, value] of storage.entries()) { + if (typeof value === "object" && "uid" in value && value.uid === uid) { + return Promise.resolve(value); + } + } + return Promise.resolve(undefined); + } - revokeByGrantId(grantId: string): Promise { - const grantKey = grantKeyFor(grantId); - const grant = storage.get(grantKey) as string[]; - if (grant) { - grant.forEach((token) => storage.delete(token)); - storage.delete(grantKey); - } - return Promise.resolve(); - } + revokeByGrantId(grantId: string): Promise { + const grantKey = grantKeyFor(grantId); + const grant = storage.get(grantKey) as string[]; + if (grant) { + grant.forEach((token) => storage.delete(token)); + storage.delete(grantKey); + } + return Promise.resolve(); + } } -export default MemoryAdapter +export default MemoryAdapter; diff --git a/src/node/security/SecretRotator.ts b/src/node/security/SecretRotator.ts index ee5bec772..61d577818 100644 --- a/src/node/security/SecretRotator.ts +++ b/src/node/security/SecretRotator.ts @@ -1,43 +1,60 @@ +import { DeriveModel } from "../types/DeriveModel"; +import { LegacyParams } from "../types/LegacyParams"; - -import {DeriveModel} from "../types/DeriveModel"; -import {LegacyParams} from "../types/LegacyParams"; - -const {Buffer} = require('buffer'); -const crypto = require('./crypto'); -const db = require('../db/DB'); -const log4js = require('log4js'); +const { Buffer } = require("buffer"); +const crypto = require("./crypto"); +const db = require("../db/DB"); +const log4js = require("log4js"); class Kdf { - async generateParams(): Promise<{ salt: string; digest: string; keyLen: number; secret: string }> { throw new Error('not implemented'); } - async derive(params: DeriveModel, info: any) { throw new Error('not implemented'); } + async generateParams(): Promise<{ + salt: string; + digest: string; + keyLen: number; + secret: string; + }> { + throw new Error("not implemented"); + } + async derive(params: DeriveModel, info: any) { + throw new Error("not implemented"); + } } class LegacyStaticSecret extends Kdf { - async derive(params:any, info:any) { return params; } + async derive(params: any, info: any) { + return params; + } } class Hkdf extends Kdf { - private readonly _digest: string - private readonly _keyLen: number - constructor(digest:string, keyLen:number) { - super(); - this._digest = digest; - this._keyLen = keyLen; - } + private readonly _digest: string; + private readonly _keyLen: number; + constructor(digest: string, keyLen: number) { + super(); + this._digest = digest; + this._keyLen = keyLen; + } - async generateParams(): Promise<{ salt: string; digest: string; keyLen: number; secret: string }> { - 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 generateParams(): Promise<{ + salt: string; + digest: string; + keyLen: number; + secret: string; + }> { + 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: DeriveModel, info:any) { - return Buffer.from( - await crypto.hkdf(p.digest, p.secret, p.salt, info, p.keyLen)).toString('hex'); - } + async derive(p: DeriveModel, info: any) { + 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: @@ -46,15 +63,12 @@ class Hkdf extends Kdf { // * 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 algorithms = [new LegacyStaticSecret(), new Hkdf("sha256", 32)]; const defaultAlgId = algorithms.length - 1; // In JavaScript, the % operator is remainder, not modulus. -const mod = (a:number, n:number) => ((a % n) + n) % n; -const intervalStart = (t:number, interval:number) => t - mod(t, interval); +const mod = (a: number, n: number) => ((a % n) + n) % n; +const intervalStart = (t: number, interval: number) => t - mod(t, interval); /** * Maintains an array of secrets across one or more Etherpad instances sharing the same database, @@ -64,201 +78,244 @@ const intervalStart = (t:number, interval:number) => t - mod(t, interval); * from a long-lived secret stored in the database (generated if missing). */ export class SecretRotator { - readonly secrets: string[]; - private readonly _dbPrefix - private readonly _interval - private readonly _legacyStaticSecret - private readonly _lifetime - private readonly _logger - private _updateTimeout:any - private readonly _t - /** - * @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: string, interval: number, lifetime: number, legacyStaticSecret:string|null = 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. + readonly secrets: string[]; + private readonly _dbPrefix; + private readonly _interval; + private readonly _legacyStaticSecret; + private readonly _lifetime; + private readonly _logger; + private _updateTimeout: any; + private readonly _t; + /** + * @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: string, + interval: number, + lifetime: number, + legacyStaticSecret: string | null = 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; + 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}; - } + // Indirections to facilitate testing. + this._t = { + now: Date.now.bind(Date), + setTimeout, + clearTimeout, + algorithms, + }; + } - async _publish(params: LegacyParams, id:string|null = 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 _publish(params: LegacyParams, id: string | null = 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(); - } + 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; - } + stop() { + this._logger.debug("stopping secret rotation"); + this._t.clearTimeout(this._updateTimeout); + this._updateTimeout = null; + } - async _deriveSecrets(p: any, now: number) { - 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:number) => 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 _deriveSecrets(p: any, now: number) { + 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: number) => 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:string[] = await db.findKeys(`${this._dbPrefix}:*`, null) || []; - let currentParams:any = null; - let currentId = null; - const dbWrites:any[] = []; - const allParams = []; - const legacyParams: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: LegacyParams = { - 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()); - } + 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: string[] = + (await db.findKeys(`${this._dbPrefix}:*`, null)) || []; + let currentParams: any = null; + let currentId = null; + const dbWrites: any[] = []; + const allParams = []; + const legacyParams: 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: LegacyParams = { + 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(), + ); + } } -export default SecretRotator +export default SecretRotator; diff --git a/src/node/security/crypto.ts b/src/node/security/crypto.ts index 9cf0a95a0..e5e9bc3e5 100644 --- a/src/node/security/crypto.ts +++ b/src/node/security/crypto.ts @@ -1,8 +1,7 @@ -'use strict'; - -const crypto = require('crypto'); -const util = require('util'); +"use strict"; +const crypto = require("crypto"); +const util = require("util"); /** * Promisified version of Node.js's crypto.hkdf. diff --git a/src/node/server.ts b/src/node/server.ts index f96db3ab1..fb1de011e 100755 --- a/src/node/server.ts +++ b/src/node/server.ts @@ -22,288 +22,304 @@ * limitations under the License. */ -import {PluginType} from "./types/Plugin"; -import {ErrorCaused} from "./types/ErrorCaused"; -import log4js from 'log4js'; -import pkg from '../package.json'; -import {checkForMigration} from "../static/js/pluginfw/installer"; +import { PluginType } from "./types/Plugin"; +import { ErrorCaused } from "./types/ErrorCaused"; +import log4js from "log4js"; +import pkg from "../package.json"; +import { checkForMigration } from "../static/js/pluginfw/installer"; import axios from "axios"; -const settings = require('./utils/Settings'); +const settings = require("./utils/Settings"); let wtfnode: any; if (settings.dumpOnUncleanExit) { - // wtfnode should be loaded after log4js.replaceConsole() so that it uses log4js for logging, and - // it should be above everything else so that it can hook in before resources are used. - wtfnode = require('wtfnode'); + // wtfnode should be loaded after log4js.replaceConsole() so that it uses log4js for logging, and + // it should be above everything else so that it can hook in before resources are used. + wtfnode = require("wtfnode"); } - const addProxyToAxios = (url: URL) => { - axios.defaults.proxy = { - host: url.hostname, - port: Number(url.port), - protocol: url.protocol, - } + axios.defaults.proxy = { + host: url.hostname, + port: Number(url.port), + protocol: url.protocol, + }; +}; + +if (process.env["http_proxy"]) { + console.log("Using proxy: " + process.env["http_proxy"]); + addProxyToAxios(new URL(process.env["http_proxy"])); } -if(process.env['http_proxy']) { - console.log("Using proxy: " + process.env['http_proxy']) - addProxyToAxios(new URL(process.env['http_proxy'])); +if (process.env["https_proxy"]) { + console.log("Using proxy: " + process.env["https_proxy"]); + addProxyToAxios(new URL(process.env["https_proxy"])); } - -if (process.env['https_proxy']) { - console.log("Using proxy: " + process.env['https_proxy']) - addProxyToAxios(new URL(process.env['https_proxy'])); -} - - - /* * early check for version compatibility before calling * any modules that require newer versions of NodeJS */ -const NodeVersion = require('./utils/NodeVersion'); +const NodeVersion = require("./utils/NodeVersion"); NodeVersion.enforceMinNodeVersion(pkg.engines.node.replace(">=", "")); -NodeVersion.checkDeprecationStatus(pkg.engines.node.replace(">=", ""), '2.1.0'); +NodeVersion.checkDeprecationStatus(pkg.engines.node.replace(">=", ""), "2.1.0"); -const UpdateCheck = require('./utils/UpdateCheck'); -const db = require('./db/DB'); -const express = require('./hooks/express'); -const hooks = require('../static/js/pluginfw/hooks'); -const pluginDefs = require('../static/js/pluginfw/plugin_defs'); -const plugins = require('../static/js/pluginfw/plugins'); -const {Gate} = require('./utils/promises'); -const stats = require('./stats') +const UpdateCheck = require("./utils/UpdateCheck"); +const db = require("./db/DB"); +const express = require("./hooks/express"); +const hooks = require("../static/js/pluginfw/hooks"); +const pluginDefs = require("../static/js/pluginfw/plugin_defs"); +const plugins = require("../static/js/pluginfw/plugins"); +const { Gate } = require("./utils/promises"); +const stats = require("./stats"); -const logger = log4js.getLogger('server'); +const logger = log4js.getLogger("server"); const State = { - INITIAL: 1, - STARTING: 2, - RUNNING: 3, - STOPPING: 4, - STOPPED: 5, - EXITING: 6, - WAITING_FOR_EXIT: 7, - STATE_TRANSITION_FAILED: 8, + INITIAL: 1, + STARTING: 2, + RUNNING: 3, + STOPPING: 4, + STOPPED: 5, + EXITING: 6, + WAITING_FOR_EXIT: 7, + STATE_TRANSITION_FAILED: 8, }; let state = State.INITIAL; -const removeSignalListener = (signal: NodeJS.Signals, listener: NodeJS.SignalsListener) => { - logger.debug(`Removing ${signal} listener because it might interfere with shutdown tasks. ` + - `Function code:\n${listener.toString()}\n` + - `Current stack:\n${new Error()!.stack!.split('\n').slice(1).join('\n')}`); - process.off(signal, listener); +const removeSignalListener = ( + signal: NodeJS.Signals, + listener: NodeJS.SignalsListener, +) => { + logger.debug( + `Removing ${signal} listener because it might interfere with shutdown tasks. ` + + `Function code:\n${listener.toString()}\n` + + `Current stack:\n${new Error()!.stack!.split("\n").slice(1).join("\n")}`, + ); + process.off(signal, listener); }; - -let startDoneGate: { resolve: () => void; } +let startDoneGate: { resolve: () => void }; exports.start = async () => { - switch (state) { - case State.INITIAL: - break; - case State.STARTING: - await startDoneGate; - // Retry. Don't fall through because it might have transitioned to STATE_TRANSITION_FAILED. - return await exports.start(); - case State.RUNNING: - return express.server; - case State.STOPPING: - case State.STOPPED: - case State.EXITING: - case State.WAITING_FOR_EXIT: - case State.STATE_TRANSITION_FAILED: - throw new Error('restart not supported'); - default: - throw new Error(`unknown State: ${state.toString()}`); - } - logger.info('Starting Etherpad...'); - startDoneGate = new Gate(); - state = State.STARTING; - try { - // Check if Etherpad version is up-to-date - UpdateCheck.check(); + switch (state) { + case State.INITIAL: + break; + case State.STARTING: + await startDoneGate; + // Retry. Don't fall through because it might have transitioned to STATE_TRANSITION_FAILED. + return await exports.start(); + case State.RUNNING: + return express.server; + case State.STOPPING: + case State.STOPPED: + case State.EXITING: + case State.WAITING_FOR_EXIT: + case State.STATE_TRANSITION_FAILED: + throw new Error("restart not supported"); + default: + throw new Error(`unknown State: ${state.toString()}`); + } + logger.info("Starting Etherpad..."); + startDoneGate = new Gate(); + state = State.STARTING; + try { + // Check if Etherpad version is up-to-date + UpdateCheck.check(); - // @ts-ignore - stats.gauge('memoryUsage', () => process.memoryUsage().rss); - // @ts-ignore - stats.gauge('memoryUsageHeap', () => process.memoryUsage().heapUsed); + // @ts-ignore + stats.gauge("memoryUsage", () => process.memoryUsage().rss); + // @ts-ignore + stats.gauge("memoryUsageHeap", () => process.memoryUsage().heapUsed); - process.on('uncaughtException', (err: ErrorCaused) => { - logger.debug(`uncaught exception: ${err.stack || err}`); + process.on("uncaughtException", (err: ErrorCaused) => { + logger.debug(`uncaught exception: ${err.stack || err}`); - // eslint-disable-next-line promise/no-promise-in-callback - exports.exit(err) - .catch((err: ErrorCaused) => { - logger.error('Error in process exit', err); - // eslint-disable-next-line n/no-process-exit - process.exit(1); - }); - }); - // As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an - // unhandled rejection into an uncaught exception, which does cause Node.js to exit. - process.on('unhandledRejection', (err: ErrorCaused) => { - logger.debug(`unhandled rejection: ${err.stack || err}`); - throw err; - }); + // eslint-disable-next-line promise/no-promise-in-callback + exports.exit(err).catch((err: ErrorCaused) => { + logger.error("Error in process exit", err); + // eslint-disable-next-line n/no-process-exit + process.exit(1); + }); + }); + // As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an + // unhandled rejection into an uncaught exception, which does cause Node.js to exit. + process.on("unhandledRejection", (err: ErrorCaused) => { + logger.debug(`unhandled rejection: ${err.stack || err}`); + throw err; + }); - for (const signal of ['SIGINT', 'SIGTERM'] as NodeJS.Signals[]) { - // Forcibly remove other signal listeners to prevent them from terminating node before we are - // done cleaning up. See https://github.com/andywer/threads.js/pull/329 for an example of a - // problematic listener. This means that exports.exit is solely responsible for performing all - // necessary cleanup tasks. - for (const listener of process.listeners(signal)) { - removeSignalListener(signal, listener); - } - process.on(signal, exports.exit); - // Prevent signal listeners from being added in the future. - process.on('newListener', (event, listener) => { - if (event !== signal) return; - removeSignalListener(signal, listener); - }); - } + for (const signal of ["SIGINT", "SIGTERM"] as NodeJS.Signals[]) { + // Forcibly remove other signal listeners to prevent them from terminating node before we are + // done cleaning up. See https://github.com/andywer/threads.js/pull/329 for an example of a + // problematic listener. This means that exports.exit is solely responsible for performing all + // necessary cleanup tasks. + for (const listener of process.listeners(signal)) { + removeSignalListener(signal, listener); + } + process.on(signal, exports.exit); + // Prevent signal listeners from being added in the future. + process.on("newListener", (event, listener) => { + if (event !== signal) return; + removeSignalListener(signal, listener); + }); + } - await db.init(); - await checkForMigration(); - await plugins.update(); - const installedPlugins = (Object.values(pluginDefs.plugins) as PluginType[]) - .filter((plugin) => plugin.package.name !== 'ep_etherpad-lite') - .map((plugin) => `${plugin.package.name}@${plugin.package.version}`) - .join(', '); - logger.info(`Installed plugins: ${installedPlugins}`); - logger.debug(`Installed parts:\n${plugins.formatParts()}`); - logger.debug(`Installed server-side hooks:\n${plugins.formatHooks('hooks', false)}`); - await hooks.aCallAll('loadSettings', {settings}); - await hooks.aCallAll('createServer'); - } catch (err) { - logger.error('Error occurred while starting Etherpad'); - state = State.STATE_TRANSITION_FAILED; - startDoneGate.resolve(); - return await exports.exit(err); - } + await db.init(); + await checkForMigration(); + await plugins.update(); + const installedPlugins = (Object.values(pluginDefs.plugins) as PluginType[]) + .filter((plugin) => plugin.package.name !== "ep_etherpad-lite") + .map((plugin) => `${plugin.package.name}@${plugin.package.version}`) + .join(", "); + logger.info(`Installed plugins: ${installedPlugins}`); + logger.debug(`Installed parts:\n${plugins.formatParts()}`); + logger.debug( + `Installed server-side hooks:\n${plugins.formatHooks("hooks", false)}`, + ); + await hooks.aCallAll("loadSettings", { settings }); + await hooks.aCallAll("createServer"); + } catch (err) { + logger.error("Error occurred while starting Etherpad"); + state = State.STATE_TRANSITION_FAILED; + startDoneGate.resolve(); + return await exports.exit(err); + } - logger.info('Etherpad is running'); - state = State.RUNNING; - startDoneGate.resolve(); + logger.info("Etherpad is running"); + state = State.RUNNING; + startDoneGate.resolve(); - // Return the HTTP server to make it easier to write tests. - return express.server; + // Return the HTTP server to make it easier to write tests. + return express.server; }; const stopDoneGate = new Gate(); exports.stop = async () => { - switch (state) { - case State.STARTING: - await exports.start(); - // Don't fall through to State.RUNNING in case another caller is also waiting for startup. - return await exports.stop(); - case State.RUNNING: - break; - case State.STOPPING: - await stopDoneGate; - // fall through - case State.INITIAL: - case State.STOPPED: - case State.EXITING: - case State.WAITING_FOR_EXIT: - case State.STATE_TRANSITION_FAILED: - return; - default: - throw new Error(`unknown State: ${state.toString()}`); - } - logger.info('Stopping Etherpad...'); - state = State.STOPPING; - try { - let timeout: NodeJS.Timeout = null as unknown as NodeJS.Timeout; - await Promise.race([ - hooks.aCallAll('shutdown'), - new Promise((resolve, reject) => { - timeout = setTimeout(() => reject(new Error('Timed out waiting for shutdown tasks')), 3000); - }), - ]); - clearTimeout(timeout); - } catch (err) { - logger.error('Error occurred while stopping Etherpad'); - state = State.STATE_TRANSITION_FAILED; - stopDoneGate.resolve(); - return await exports.exit(err); - } - logger.info('Etherpad stopped'); - state = State.STOPPED; - stopDoneGate.resolve(); + switch (state) { + case State.STARTING: + await exports.start(); + // Don't fall through to State.RUNNING in case another caller is also waiting for startup. + return await exports.stop(); + case State.RUNNING: + break; + case State.STOPPING: + await stopDoneGate; + // fall through + case State.INITIAL: + case State.STOPPED: + case State.EXITING: + case State.WAITING_FOR_EXIT: + case State.STATE_TRANSITION_FAILED: + return; + default: + throw new Error(`unknown State: ${state.toString()}`); + } + logger.info("Stopping Etherpad..."); + state = State.STOPPING; + try { + let timeout: NodeJS.Timeout = null as unknown as NodeJS.Timeout; + await Promise.race([ + hooks.aCallAll("shutdown"), + new Promise((resolve, reject) => { + timeout = setTimeout( + () => reject(new Error("Timed out waiting for shutdown tasks")), + 3000, + ); + }), + ]); + clearTimeout(timeout); + } catch (err) { + logger.error("Error occurred while stopping Etherpad"); + state = State.STATE_TRANSITION_FAILED; + stopDoneGate.resolve(); + return await exports.exit(err); + } + logger.info("Etherpad stopped"); + state = State.STOPPED; + stopDoneGate.resolve(); }; let exitGate: any; let exitCalled = false; -exports.exit = async (err: ErrorCaused|string|null = null) => { - /* eslint-disable no-process-exit */ - if (err === 'SIGTERM') { - // Termination from SIGTERM is not treated as an abnormal termination. - logger.info('Received SIGTERM signal'); - err = null; - } else if (typeof err == "object" && err != null) { - logger.error(`Metrics at time of fatal error:\n${JSON.stringify(stats.toJSON(), null, 2)}`); - logger.error(err.stack || err.toString()); - process.exitCode = 1; - if (exitCalled) { - logger.error('Error occurred while waiting to exit. Forcing an immediate unclean exit...'); - process.exit(1); - } - } - if (!exitCalled) logger.info('Exiting...'); - exitCalled = true; - switch (state) { - case State.STARTING: - case State.RUNNING: - case State.STOPPING: - await exports.stop(); - // Don't fall through to State.STOPPED in case another caller is also waiting for stop(). - // Don't pass err to exports.exit() because this err has already been processed. (If err is - // passed again to exit() then exit() will think that a second error occurred while exiting.) - return await exports.exit(); - case State.INITIAL: - case State.STOPPED: - case State.STATE_TRANSITION_FAILED: - break; - case State.EXITING: - await exitGate; - // fall through - case State.WAITING_FOR_EXIT: - return; - default: - throw new Error(`unknown State: ${state.toString()}`); - } - exitGate = new Gate(); - state = State.EXITING; - exitGate.resolve(); +exports.exit = async (err: ErrorCaused | string | null = null) => { + /* eslint-disable no-process-exit */ + if (err === "SIGTERM") { + // Termination from SIGTERM is not treated as an abnormal termination. + logger.info("Received SIGTERM signal"); + err = null; + } else if (typeof err == "object" && err != null) { + logger.error( + `Metrics at time of fatal error:\n${JSON.stringify( + stats.toJSON(), + null, + 2, + )}`, + ); + logger.error(err.stack || err.toString()); + process.exitCode = 1; + if (exitCalled) { + logger.error( + "Error occurred while waiting to exit. Forcing an immediate unclean exit...", + ); + process.exit(1); + } + } + if (!exitCalled) logger.info("Exiting..."); + exitCalled = true; + switch (state) { + case State.STARTING: + case State.RUNNING: + case State.STOPPING: + await exports.stop(); + // Don't fall through to State.STOPPED in case another caller is also waiting for stop(). + // Don't pass err to exports.exit() because this err has already been processed. (If err is + // passed again to exit() then exit() will think that a second error occurred while exiting.) + return await exports.exit(); + case State.INITIAL: + case State.STOPPED: + case State.STATE_TRANSITION_FAILED: + break; + case State.EXITING: + await exitGate; + // fall through + case State.WAITING_FOR_EXIT: + return; + default: + throw new Error(`unknown State: ${state.toString()}`); + } + exitGate = new Gate(); + state = State.EXITING; + exitGate.resolve(); - // Node.js should exit on its own without further action. Add a timeout to force Node.js to exit - // just in case something failed to get cleaned up during the shutdown hook. unref() is called - // on the timeout so that the timeout itself does not prevent Node.js from exiting. - setTimeout(() => { - logger.error('Something that should have been cleaned up during the shutdown hook (such as ' + - 'a timer, worker thread, or open connection) is preventing Node.js from exiting'); + // Node.js should exit on its own without further action. Add a timeout to force Node.js to exit + // just in case something failed to get cleaned up during the shutdown hook. unref() is called + // on the timeout so that the timeout itself does not prevent Node.js from exiting. + setTimeout(() => { + logger.error( + "Something that should have been cleaned up during the shutdown hook (such as " + + "a timer, worker thread, or open connection) is preventing Node.js from exiting", + ); - if (settings.dumpOnUncleanExit) { - wtfnode.dump(); - } else { - logger.error('Enable `dumpOnUncleanExit` setting to get a dump of objects preventing a ' + - 'clean exit'); - } + if (settings.dumpOnUncleanExit) { + wtfnode.dump(); + } else { + logger.error( + "Enable `dumpOnUncleanExit` setting to get a dump of objects preventing a " + + "clean exit", + ); + } - logger.error('Forcing an unclean exit...'); - process.exit(1); - }, 5000).unref(); + logger.error("Forcing an unclean exit..."); + process.exit(1); + }, 5000).unref(); - logger.info('Waiting for Node.js to exit...'); - state = State.WAITING_FOR_EXIT; - /* eslint-enable no-process-exit */ + logger.info("Waiting for Node.js to exit..."); + state = State.WAITING_FOR_EXIT; + /* eslint-enable no-process-exit */ }; if (require.main === module) exports.start(); // @ts-ignore -if (typeof(PhusionPassenger) !== 'undefined') exports.start(); +if (typeof PhusionPassenger !== "undefined") exports.start(); diff --git a/src/node/stats.ts b/src/node/stats.ts index f1fc0cccf..59d7ac460 100644 --- a/src/node/stats.ts +++ b/src/node/stats.ts @@ -1,10 +1,10 @@ -'use strict'; +"use strict"; -const measured = require('measured-core'); +const measured = require("measured-core"); module.exports = measured.createCollection(); // @ts-ignore module.exports.shutdown = async (hookName, context) => { - module.exports.end(); -}; \ No newline at end of file + module.exports.end(); +}; diff --git a/src/node/types/ArgsExpressType.ts b/src/node/types/ArgsExpressType.ts index 5c0675b97..6ccf52a18 100644 --- a/src/node/types/ArgsExpressType.ts +++ b/src/node/types/ArgsExpressType.ts @@ -1,5 +1,5 @@ export type ArgsExpressType = { - app:any, - io: any, - server:any -} \ No newline at end of file + app: any; + io: any; + server: any; +}; diff --git a/src/node/types/AsyncQueueTask.ts b/src/node/types/AsyncQueueTask.ts index 03a915ac7..97958f173 100644 --- a/src/node/types/AsyncQueueTask.ts +++ b/src/node/types/AsyncQueueTask.ts @@ -1,5 +1,5 @@ export type AsyncQueueTask = { - srcFile: string, - destFile: string, - type: string -} \ No newline at end of file + srcFile: string; + destFile: string; + type: string; +}; diff --git a/src/node/types/ChangeSet.ts b/src/node/types/ChangeSet.ts index 17f38f101..0058c5e04 100644 --- a/src/node/types/ChangeSet.ts +++ b/src/node/types/ChangeSet.ts @@ -1,3 +1 @@ -export type ChangeSet = { - -} +export type ChangeSet = {}; diff --git a/src/node/types/DeriveModel.ts b/src/node/types/DeriveModel.ts index b6297f3ce..4818c96fb 100644 --- a/src/node/types/DeriveModel.ts +++ b/src/node/types/DeriveModel.ts @@ -1,6 +1,6 @@ export type DeriveModel = { - digest: string, - secret: string, - salt: string, - keyLen: number -} \ No newline at end of file + digest: string; + secret: string; + salt: string; + keyLen: number; +}; diff --git a/src/node/types/ErrorCaused.ts b/src/node/types/ErrorCaused.ts index 63cc677b5..a5a77d5cd 100644 --- a/src/node/types/ErrorCaused.ts +++ b/src/node/types/ErrorCaused.ts @@ -1,14 +1,11 @@ -export class ErrorCaused extends Error { - cause: Error; - code: any; - constructor(message: string, cause: Error) { - super(); - this.cause = cause - this.name = "ErrorCaused" - } +export class ErrorCaused extends Error { + cause: Error; + code: any; + constructor(message: string, cause: Error) { + super(); + this.cause = cause; + this.name = "ErrorCaused"; + } } - -type ErrorCause = { - -} \ No newline at end of file +type ErrorCause = {}; diff --git a/src/node/types/I18nPluginDefs.ts b/src/node/types/I18nPluginDefs.ts index feb9a593d..092151e4d 100644 --- a/src/node/types/I18nPluginDefs.ts +++ b/src/node/types/I18nPluginDefs.ts @@ -1,5 +1,5 @@ export type I18nPluginDefs = { - package: { - path: string - } -} \ No newline at end of file + package: { + path: string; + }; +}; diff --git a/src/node/types/LegacyParams.ts b/src/node/types/LegacyParams.ts index ea03c5618..bf4aece1e 100644 --- a/src/node/types/LegacyParams.ts +++ b/src/node/types/LegacyParams.ts @@ -1,8 +1,8 @@ export type LegacyParams = { - start: number, - end: number, - lifetime: number, - algId: number, - algParams: any, - interval:number|null -} \ No newline at end of file + start: number; + end: number; + lifetime: number; + algId: number; + algParams: any; + interval: number | null; +}; diff --git a/src/node/types/MapType.ts b/src/node/types/MapType.ts index 709ca0348..870aa8f90 100644 --- a/src/node/types/MapType.ts +++ b/src/node/types/MapType.ts @@ -1,7 +1,7 @@ export type MapType = { - [key: string|number]: string|number -} + [key: string | number]: string | number; +}; export type MapArrayType = { - [key:string]: T -} \ No newline at end of file + [key: string]: T; +}; diff --git a/src/node/types/PackageInfo.ts b/src/node/types/PackageInfo.ts index 3c4a884d8..d54acc782 100644 --- a/src/node/types/PackageInfo.ts +++ b/src/node/types/PackageInfo.ts @@ -1,20 +1,19 @@ -export type PackageInfo = { - from: string, - name: string, - version: string, - resolved: string, - description: string, - license: string, - author: { - name: string - }, - homepage: string, - repository: string, - path: string -} - +export type PackageInfo = { + from: string; + name: string; + version: string; + resolved: string; + description: string; + license: string; + author: { + name: string; + }; + homepage: string; + repository: string; + path: string; +}; export type PackageData = { - version: string, - name: string -} \ No newline at end of file + version: string; + name: string; +}; diff --git a/src/node/types/PadSearchQuery.ts b/src/node/types/PadSearchQuery.ts index aaef233e6..0cbda7b7c 100644 --- a/src/node/types/PadSearchQuery.ts +++ b/src/node/types/PadSearchQuery.ts @@ -1,15 +1,14 @@ export type PadSearchQuery = { - pattern: string; - offset: number; - limit: number; - ascending: boolean; - sortBy: "padName" | "lastEdited" | "userCount" | "revisionNumber"; -} - + pattern: string; + offset: number; + limit: number; + ascending: boolean; + sortBy: "padName" | "lastEdited" | "userCount" | "revisionNumber"; +}; export type PadQueryResult = { - padName: string, - lastEdited: string, - userCount: number, - revisionNumber: number -} + padName: string; + lastEdited: string; + userCount: number; + revisionNumber: number; +}; diff --git a/src/node/types/PadType.ts b/src/node/types/PadType.ts index b344ed8c5..590ab88e2 100644 --- a/src/node/types/PadType.ts +++ b/src/node/types/PadType.ts @@ -1,53 +1,45 @@ -import {MapArrayType} from "./MapType"; +import { MapArrayType } from "./MapType"; export type PadType = { - id: string, - apool: ()=>APool, - atext: AText, - pool: APool, - getInternalRevisionAText: (text:number|string)=>Promise, - getValidRevisionRange: (fromRev: string, toRev: string)=>PadRange, - getRevisionAuthor: (rev: number)=>Promise, - getRevision: (rev?: string)=>Promise, - head: number, - getAllAuthorColors: ()=>Promise>, - remove: ()=>Promise, - text: ()=>string, - setText: (text: string, authorId?: string)=>Promise, - appendText: (text: string)=>Promise, - getHeadRevisionNumber: ()=>number, - getRevisionDate: (rev: number)=>Promise, - getRevisionChangeset: (rev: number)=>Promise, - appendRevision: (changeset: AChangeSet, author: string)=>Promise, -} - + id: string; + apool: () => APool; + atext: AText; + pool: APool; + getInternalRevisionAText: (text: number | string) => Promise; + getValidRevisionRange: (fromRev: string, toRev: string) => PadRange; + getRevisionAuthor: (rev: number) => Promise; + getRevision: (rev?: string) => Promise; + head: number; + getAllAuthorColors: () => Promise>; + remove: () => Promise; + text: () => string; + setText: (text: string, authorId?: string) => Promise; + appendText: (text: string) => Promise; + getHeadRevisionNumber: () => number; + getRevisionDate: (rev: number) => Promise; + getRevisionChangeset: (rev: number) => Promise; + appendRevision: (changeset: AChangeSet, author: string) => Promise; +}; type PadRange = { - startRev: string, - endRev: string, -} - + startRev: string; + endRev: string; +}; export type APool = { - putAttrib: ([],flag?: boolean)=>number, - numToAttrib: MapArrayType, - toJsonable: ()=>any, - clone: ()=>APool, - check: ()=>Promise, - eachAttrib: (callback: (key: string, value: any)=>void)=>void, -} - + putAttrib: ([], flag?: boolean) => number; + numToAttrib: MapArrayType; + toJsonable: () => any; + clone: () => APool; + check: () => Promise; + eachAttrib: (callback: (key: string, value: any) => void) => void; +}; export type AText = { - text: string, - attribs: any -} + text: string; + attribs: any; +}; +export type PadAuthor = {}; -export type PadAuthor = { - -} - -export type AChangeSet = { - -} +export type AChangeSet = {}; diff --git a/src/node/types/PartType.ts b/src/node/types/PartType.ts index 3785b73e8..db7e34678 100644 --- a/src/node/types/PartType.ts +++ b/src/node/types/PartType.ts @@ -1,10 +1,10 @@ export type PartType = { - plugin: string, - client_hooks:any -} + plugin: string; + client_hooks: any; +}; export type PluginDef = { - package:{ - path:string - } -} + package: { + path: string; + }; +}; diff --git a/src/node/types/Plugin.ts b/src/node/types/Plugin.ts index 44b97922c..593850e5b 100644 --- a/src/node/types/Plugin.ts +++ b/src/node/types/Plugin.ts @@ -1,9 +1,8 @@ -'use strict'; - +"use strict"; export type PluginType = { - package: { - name: string, - version: string - } -} \ No newline at end of file + package: { + name: string; + version: string; + }; +}; diff --git a/src/node/types/PromiseWithStd.ts b/src/node/types/PromiseWithStd.ts index 426fcbe54..a60e43689 100644 --- a/src/node/types/PromiseWithStd.ts +++ b/src/node/types/PromiseWithStd.ts @@ -1,8 +1,8 @@ -import type {Readable} from "node:stream"; -import type {ChildProcess} from "node:child_process"; +import type { Readable } from "node:stream"; +import type { ChildProcess } from "node:child_process"; export type PromiseWithStd = { - stdout?: Readable|null, - stderr?: Readable|null, - child?: ChildProcess -} & Promise \ No newline at end of file + stdout?: Readable | null; + stderr?: Readable | null; + child?: ChildProcess; +} & Promise; diff --git a/src/node/types/QueryType.ts b/src/node/types/QueryType.ts index f851c6534..5a96a025e 100644 --- a/src/node/types/QueryType.ts +++ b/src/node/types/QueryType.ts @@ -1,3 +1,7 @@ export type QueryType = { -searchTerm: string; sortBy: string; sortDir: string; offset: number; limit: number; -} \ No newline at end of file + searchTerm: string; + sortBy: string; + sortDir: string; + offset: number; + limit: number; +}; diff --git a/src/node/types/RunCMDOptions.ts b/src/node/types/RunCMDOptions.ts index 74298f221..4b02f5a60 100644 --- a/src/node/types/RunCMDOptions.ts +++ b/src/node/types/RunCMDOptions.ts @@ -1,15 +1,15 @@ export type RunCMDOptions = { - cwd?: string, - stdio?: string[], - env?: NodeJS.ProcessEnv -} + cwd?: string; + stdio?: string[]; + env?: NodeJS.ProcessEnv; +}; export type RunCMDPromise = { - stdout?:Function, - stderr?:Function -} + stdout?: Function; + stderr?: Function; +}; export type ErrorExtended = { - code?: number|null, - signal?: NodeJS.Signals|null -} \ No newline at end of file + code?: number | null; + signal?: NodeJS.Signals | null; +}; diff --git a/src/node/types/SecretRotatorType.ts b/src/node/types/SecretRotatorType.ts index 2c0f05f15..001429be0 100644 --- a/src/node/types/SecretRotatorType.ts +++ b/src/node/types/SecretRotatorType.ts @@ -1,3 +1,3 @@ export type SecretRotatorType = { - stop: ()=>void -} \ No newline at end of file + stop: () => void; +}; diff --git a/src/node/types/SettingsUser.ts b/src/node/types/SettingsUser.ts index cb06332e3..52241a8c7 100644 --- a/src/node/types/SettingsUser.ts +++ b/src/node/types/SettingsUser.ts @@ -1,6 +1,6 @@ export type SettingsUser = { - [username: string]:{ - password: string, - is_admin?: boolean, - } -} + [username: string]: { + password: string; + is_admin?: boolean; + }; +}; diff --git a/src/node/types/SocketAcknowledge.ts b/src/node/types/SocketAcknowledge.ts index a55f77219..452cac56d 100644 --- a/src/node/types/SocketAcknowledge.ts +++ b/src/node/types/SocketAcknowledge.ts @@ -1,3 +1 @@ -export type SocketAcknowledge = { - -} +export type SocketAcknowledge = {}; diff --git a/src/node/types/SocketClientRequest.ts b/src/node/types/SocketClientRequest.ts index 07c015fc5..14159c786 100644 --- a/src/node/types/SocketClientRequest.ts +++ b/src/node/types/SocketClientRequest.ts @@ -1,30 +1,28 @@ export type SocketClientRequest = { - session: { - user: { - username: string; - readOnly: boolean; - padAuthorizations: { - [key: string]: string; - } - } - } -} - + session: { + user: { + username: string; + readOnly: boolean; + padAuthorizations: { + [key: string]: string; + }; + }; + }; +}; export type PadUserInfo = { - data: { - userInfo: { - name: string|null; - colorId: string; - } - } -} - + data: { + userInfo: { + name: string | null; + colorId: string; + }; + }; +}; export type ChangesetRequest = { - data: { - granularity: number; - start: number; - requestID: string; - } -} + data: { + granularity: number; + start: number; + requestID: string; + }; +}; diff --git a/src/node/types/SocketModule.ts b/src/node/types/SocketModule.ts index fab6b572e..6b66f6cec 100644 --- a/src/node/types/SocketModule.ts +++ b/src/node/types/SocketModule.ts @@ -1,3 +1,3 @@ export type SocketModule = { - setSocketIO: (io: any) => void; -} + setSocketIO: (io: any) => void; +}; diff --git a/src/node/types/SwaggerUIResource.ts b/src/node/types/SwaggerUIResource.ts index 3f61f9ba8..e21f73464 100644 --- a/src/node/types/SwaggerUIResource.ts +++ b/src/node/types/SwaggerUIResource.ts @@ -1,34 +1,32 @@ export type SwaggerUIResource = { - [key: string]: { - [secondKey: string]: { - operationId: string, - summary?: string, - description?:string - responseSchema?: object - } - } -} - + [key: string]: { + [secondKey: string]: { + operationId: string; + summary?: string; + description?: string; + responseSchema?: object; + }; + }; +}; export type OpenAPISuccessResponse = { - [key: number] :{ - $ref: string, - content?: { - [key: string]: { - schema: { - properties: { - data: { - type: string, - properties: object - } - } - } - } - } - } -} - + [key: number]: { + $ref: string; + content?: { + [key: string]: { + schema: { + properties: { + data: { + type: string; + properties: object; + }; + }; + }; + }; + }; + }; +}; export type OpenAPIOperations = { - [key:string]: any -} \ No newline at end of file + [key: string]: any; +}; diff --git a/src/node/types/UserSettingsObject.ts b/src/node/types/UserSettingsObject.ts index 7cd196866..ace69d287 100644 --- a/src/node/types/UserSettingsObject.ts +++ b/src/node/types/UserSettingsObject.ts @@ -1,5 +1,5 @@ export type UserSettingsObject = { - canCreate: boolean, - readOnly: boolean, - padAuthorizations: any -} + canCreate: boolean; + readOnly: boolean; + padAuthorizations: any; +}; diff --git a/src/node/types/WebAccessTypes.ts b/src/node/types/WebAccessTypes.ts index b351f059f..9c1c50349 100644 --- a/src/node/types/WebAccessTypes.ts +++ b/src/node/types/WebAccessTypes.ts @@ -1,10 +1,10 @@ -import {SettingsUser} from "./SettingsUser"; +import { SettingsUser } from "./SettingsUser"; export type WebAccessTypes = { - username?: string|null; - password?: string; - req:any; - res:any; - next:any; - users: SettingsUser; -} + username?: string | null; + password?: string; + req: any; + res: any; + next: any; + users: SettingsUser; +}; diff --git a/src/node/utils/Abiword.ts b/src/node/utils/Abiword.ts index c0937fcd9..1fbbe40f8 100644 --- a/src/node/utils/Abiword.ts +++ b/src/node/utils/Abiword.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * Controls the communication with the Abiword application */ @@ -19,75 +19,93 @@ * limitations under the License. */ -import {ChildProcess} from "node:child_process"; -import {AsyncQueueTask} from "../types/AsyncQueueTask"; +import { ChildProcess } from "node:child_process"; +import { AsyncQueueTask } from "../types/AsyncQueueTask"; -const spawn = require('child_process').spawn; -const async = require('async'); -const settings = require('./Settings'); -const os = require('os'); +const spawn = require("child_process").spawn; +const async = require("async"); +const settings = require("./Settings"); +const os = require("os"); // on windows we have to spawn a process for each convertion, // cause the plugin abicommand doesn't exist on this platform -if (os.type().indexOf('Windows') > -1) { - exports.convertFile = async (srcFile: string, destFile: string, type: string) => { - const abiword = spawn(settings.abiword, [`--to=${destFile}`, srcFile]); - let stdoutBuffer = ''; - abiword.stdout.on('data', (data: string) => { stdoutBuffer += data.toString(); }); - abiword.stderr.on('data', (data: string) => { stdoutBuffer += data.toString(); }); - await new Promise((resolve, reject) => { - abiword.on('exit', (code: number) => { - if (code !== 0) return reject(new Error(`Abiword died with exit code ${code}`)); - if (stdoutBuffer !== '') { - console.log(stdoutBuffer); - } - resolve(); - }); - }); - }; - // on unix operating systems, we can start abiword with abicommand and - // communicate with it via stdin/stdout - // thats much faster, about factor 10 +if (os.type().indexOf("Windows") > -1) { + exports.convertFile = async ( + srcFile: string, + destFile: string, + type: string, + ) => { + const abiword = spawn(settings.abiword, [`--to=${destFile}`, srcFile]); + let stdoutBuffer = ""; + abiword.stdout.on("data", (data: string) => { + stdoutBuffer += data.toString(); + }); + abiword.stderr.on("data", (data: string) => { + stdoutBuffer += data.toString(); + }); + await new Promise((resolve, reject) => { + abiword.on("exit", (code: number) => { + if (code !== 0) + return reject(new Error(`Abiword died with exit code ${code}`)); + if (stdoutBuffer !== "") { + console.log(stdoutBuffer); + } + resolve(); + }); + }); + }; + // on unix operating systems, we can start abiword with abicommand and + // communicate with it via stdin/stdout + // thats much faster, about factor 10 } else { - let abiword: ChildProcess; - let stdoutCallback: Function|null = null; - const spawnAbiword = () => { - abiword = spawn(settings.abiword, ['--plugin', 'AbiCommand']); - let stdoutBuffer = ''; - let firstPrompt = true; - abiword.stderr!.on('data', (data) => { stdoutBuffer += data.toString(); }); - abiword.on('exit', (code) => { - spawnAbiword(); - if (stdoutCallback != null) { - stdoutCallback(new Error(`Abiword died with exit code ${code}`)); - stdoutCallback = null; - } - }); - abiword.stdout!.on('data', (data) => { - stdoutBuffer += data.toString(); - // we're searching for the prompt, cause this means everything we need is in the buffer - if (stdoutBuffer.search('AbiWord:>') !== -1) { - const err = stdoutBuffer.search('OK') !== -1 ? null : new Error(stdoutBuffer); - stdoutBuffer = ''; - if (stdoutCallback != null && !firstPrompt) { - stdoutCallback(err); - stdoutCallback = null; - } - firstPrompt = false; - } - }); - }; - spawnAbiword(); + let abiword: ChildProcess; + let stdoutCallback: Function | null = null; + const spawnAbiword = () => { + abiword = spawn(settings.abiword, ["--plugin", "AbiCommand"]); + let stdoutBuffer = ""; + let firstPrompt = true; + abiword.stderr!.on("data", (data) => { + stdoutBuffer += data.toString(); + }); + abiword.on("exit", (code) => { + spawnAbiword(); + if (stdoutCallback != null) { + stdoutCallback(new Error(`Abiword died with exit code ${code}`)); + stdoutCallback = null; + } + }); + abiword.stdout!.on("data", (data) => { + stdoutBuffer += data.toString(); + // we're searching for the prompt, cause this means everything we need is in the buffer + if (stdoutBuffer.search("AbiWord:>") !== -1) { + const err = + stdoutBuffer.search("OK") !== -1 ? null : new Error(stdoutBuffer); + stdoutBuffer = ""; + if (stdoutCallback != null && !firstPrompt) { + stdoutCallback(err); + stdoutCallback = null; + } + firstPrompt = false; + } + }); + }; + spawnAbiword(); - const queue = async.queue((task: AsyncQueueTask, callback:Function) => { - abiword.stdin!.write(`convert ${task.srcFile} ${task.destFile} ${task.type}\n`); - stdoutCallback = (err: string) => { - if (err != null) console.error('Abiword File failed to convert', err); - callback(err); - }; - }, 1); + const queue = async.queue((task: AsyncQueueTask, callback: Function) => { + abiword.stdin!.write( + `convert ${task.srcFile} ${task.destFile} ${task.type}\n`, + ); + stdoutCallback = (err: string) => { + if (err != null) console.error("Abiword File failed to convert", err); + callback(err); + }; + }, 1); - exports.convertFile = async (srcFile: string, destFile: string, type: string) => { - await queue.pushAsync({srcFile, destFile, type}); - }; + exports.convertFile = async ( + srcFile: string, + destFile: string, + type: string, + ) => { + await queue.pushAsync({ srcFile, destFile, type }); + }; } diff --git a/src/node/utils/AbsolutePaths.ts b/src/node/utils/AbsolutePaths.ts index c257440a1..88fa3a4c8 100644 --- a/src/node/utils/AbsolutePaths.ts +++ b/src/node/utils/AbsolutePaths.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * Library for deterministic relative filename expansion for Etherpad. */ @@ -18,17 +18,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -const log4js = require('log4js'); -const path = require('path'); -const _ = require('underscore'); +const log4js = require("log4js"); +const path = require("path"); +const _ = require("underscore"); -const absPathLogger = log4js.getLogger('AbsolutePaths'); +const absPathLogger = log4js.getLogger("AbsolutePaths"); /* * findEtherpadRoot() computes its value only on first invocation. * Subsequent invocations are served from this variable. */ -let etherpadRoot: string|null = null; +let etherpadRoot: string | null = null; /** * If stringArray's last elements are exactly equal to lastDesiredElements, @@ -40,23 +40,31 @@ let etherpadRoot: string|null = null; * @return {string[]|boolean} The shortened array, or false if there was no * overlap. */ -const popIfEndsWith = (stringArray: string[], lastDesiredElements: string[]): string[] | false => { - if (stringArray.length <= lastDesiredElements.length) { - absPathLogger.debug(`In order to pop "${lastDesiredElements.join(path.sep)}" ` + - `from "${stringArray.join(path.sep)}", it should contain at least ` + - `${lastDesiredElements.length + 1} elements`); - return false; - } +const popIfEndsWith = ( + stringArray: string[], + lastDesiredElements: string[], +): string[] | false => { + if (stringArray.length <= lastDesiredElements.length) { + absPathLogger.debug( + `In order to pop "${lastDesiredElements.join(path.sep)}" ` + + `from "${stringArray.join(path.sep)}", it should contain at least ` + + `${lastDesiredElements.length + 1} elements`, + ); + return false; + } - const lastElementsFound = _.last(stringArray, lastDesiredElements.length); + const lastElementsFound = _.last(stringArray, lastDesiredElements.length); - if (_.isEqual(lastElementsFound, lastDesiredElements)) { - return _.initial(stringArray, lastDesiredElements.length); - } + if (_.isEqual(lastElementsFound, lastDesiredElements)) { + return _.initial(stringArray, lastDesiredElements.length); + } - absPathLogger.debug( - `${stringArray.join(path.sep)} does not end with "${lastDesiredElements.join(path.sep)}"`); - return false; + absPathLogger.debug( + `${stringArray.join( + path.sep, + )} does not end with "${lastDesiredElements.join(path.sep)}"`, + ); + return false; }; /** @@ -75,49 +83,55 @@ const popIfEndsWith = (stringArray: string[], lastDesiredElements: string[]): st * identified, prints a log and exits the application. */ exports.findEtherpadRoot = () => { - if (etherpadRoot != null) { - return etherpadRoot; - } + if (etherpadRoot != null) { + return etherpadRoot; + } - const findRoot = require('find-root'); - const foundRoot = findRoot(__dirname); - const splitFoundRoot = foundRoot.split(path.sep); + const findRoot = require("find-root"); + const foundRoot = findRoot(__dirname); + const splitFoundRoot = foundRoot.split(path.sep); - /* - * On Unix platforms and on Windows manual installs, foundRoot's value will - * be: - * - * \src - */ - let maybeEtherpadRoot = popIfEndsWith(splitFoundRoot, ['src']); + /* + * On Unix platforms and on Windows manual installs, foundRoot's value will + * be: + * + * \src + */ + let maybeEtherpadRoot = popIfEndsWith(splitFoundRoot, ["src"]); - if ((maybeEtherpadRoot === false) && (process.platform === 'win32')) { - /* - * If we did not find the path we are expecting, and we are running under - * Windows, we may still be running from a prebuilt package, whose directory - * structure is different: - * - * \node_modules\ep_etherpad-lite - */ - maybeEtherpadRoot = popIfEndsWith(splitFoundRoot, ['node_modules', 'ep_etherpad-lite']); - } + if (maybeEtherpadRoot === false && process.platform === "win32") { + /* + * If we did not find the path we are expecting, and we are running under + * Windows, we may still be running from a prebuilt package, whose directory + * structure is different: + * + * \node_modules\ep_etherpad-lite + */ + maybeEtherpadRoot = popIfEndsWith(splitFoundRoot, [ + "node_modules", + "ep_etherpad-lite", + ]); + } - if (maybeEtherpadRoot === false) { - absPathLogger.error('Could not identity Etherpad base path in this ' + - `${process.platform} installation in "${foundRoot}"`); - process.exit(1); - } + if (maybeEtherpadRoot === false) { + absPathLogger.error( + "Could not identity Etherpad base path in this " + + `${process.platform} installation in "${foundRoot}"`, + ); + process.exit(1); + } - // SIDE EFFECT on this module-level variable - etherpadRoot = maybeEtherpadRoot.join(path.sep); + // SIDE EFFECT on this module-level variable + etherpadRoot = maybeEtherpadRoot.join(path.sep); - if (path.isAbsolute(etherpadRoot)) { - return etherpadRoot; - } + if (path.isAbsolute(etherpadRoot)) { + return etherpadRoot; + } - absPathLogger.error( - `To run, Etherpad has to identify an absolute base path. This is not: "${etherpadRoot}"`); - process.exit(1); + absPathLogger.error( + `To run, Etherpad has to identify an absolute base path. This is not: "${etherpadRoot}"`, + ); + process.exit(1); }; /** @@ -131,14 +145,16 @@ exports.findEtherpadRoot = () => { * relative to exports.root. */ exports.makeAbsolute = (somePath: string) => { - if (path.isAbsolute(somePath)) { - return somePath; - } + if (path.isAbsolute(somePath)) { + return somePath; + } - const rewrittenPath = path.join(exports.findEtherpadRoot(), somePath); + const rewrittenPath = path.join(exports.findEtherpadRoot(), somePath); - absPathLogger.debug(`Relative path "${somePath}" can be rewritten to "${rewrittenPath}"`); - return rewrittenPath; + absPathLogger.debug( + `Relative path "${somePath}" can be rewritten to "${rewrittenPath}"`, + ); + return rewrittenPath; }; /** @@ -150,7 +166,7 @@ exports.makeAbsolute = (somePath: string) => { * @return {boolean} */ exports.isSubdir = (parent: string, arbitraryDir: string): boolean => { - // modified from: https://stackoverflow.com/questions/37521893/determine-if-a-path-is-subdirectory-of-another-in-node-js#45242825 - const relative = path.relative(parent, arbitraryDir); - return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative); + // modified from: https://stackoverflow.com/questions/37521893/determine-if-a-path-is-subdirectory-of-another-in-node-js#45242825 + const relative = path.relative(parent, arbitraryDir); + return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative); }; diff --git a/src/node/utils/Cli.ts b/src/node/utils/Cli.ts index 1b2938196..151b232d6 100644 --- a/src/node/utils/Cli.ts +++ b/src/node/utils/Cli.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * The CLI module handles command line parameters */ @@ -28,22 +28,22 @@ let arg, prevArg; // Loop through args for (let i = 0; i < argv.length; i++) { - arg = argv[i]; + arg = argv[i]; - // Override location of settings.json file - if (prevArg === '--settings' || prevArg === '-s') { - exports.argv.settings = arg; - } + // Override location of settings.json file + if (prevArg === "--settings" || prevArg === "-s") { + exports.argv.settings = arg; + } - // Override location of credentials.json file - if (prevArg === '--credentials') { - exports.argv.credentials = arg; - } + // Override location of credentials.json file + if (prevArg === "--credentials") { + exports.argv.credentials = arg; + } - // Override location of settings.json file - if (prevArg === '--sessionkey') { - exports.argv.sessionkey = arg; - } + // Override location of settings.json file + if (prevArg === "--sessionkey") { + exports.argv.sessionkey = arg; + } - prevArg = arg; + prevArg = arg; } diff --git a/src/node/utils/ExportEtherpad.ts b/src/node/utils/ExportEtherpad.ts index 292fbcec4..ac3baebaf 100644 --- a/src/node/utils/ExportEtherpad.ts +++ b/src/node/utils/ExportEtherpad.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * 2014 John McLear (Etherpad Foundation / McLear Ltd) * @@ -15,50 +15,58 @@ * limitations under the License. */ -const Stream = require('./Stream'); -const assert = require('assert').strict; -const authorManager = require('../db/AuthorManager'); -const hooks = require('../../static/js/pluginfw/hooks'); -const padManager = require('../db/PadManager'); +const Stream = require("./Stream"); +const assert = require("assert").strict; +const authorManager = require("../db/AuthorManager"); +const hooks = require("../../static/js/pluginfw/hooks"); +const padManager = require("../db/PadManager"); -exports.getPadRaw = async (padId:string, readOnlyId:string) => { - const dstPfx = `pad:${readOnlyId || padId}`; - const [pad, customPrefixes] = await Promise.all([ - padManager.getPad(padId), - hooks.aCallAll('exportEtherpadAdditionalContent'), - ]); - const pluginRecords = await Promise.all(customPrefixes.map(async (customPrefix:string) => { - const srcPfx = `${customPrefix}:${padId}`; - const dstPfx = `${customPrefix}:${readOnlyId || padId}`; - assert(!srcPfx.includes('*')); - const srcKeys = await pad.db.findKeys(`${srcPfx}:*`, null); - return (function* () { - yield [dstPfx, pad.db.get(srcPfx)]; - for (const k of srcKeys) { - assert(k.startsWith(`${srcPfx}:`)); - yield [`${dstPfx}${k.slice(srcPfx.length)}`, pad.db.get(k)]; - } - })(); - })); - const records = (function* () { - for (const authorId of pad.getAllAuthors()) { - yield [`globalAuthor:${authorId}`, (async () => { - const authorEntry = await authorManager.getAuthor(authorId); - if (!authorEntry) return undefined; // Becomes unset when converted to JSON. - if (authorEntry.padIDs) authorEntry.padIDs = readOnlyId || padId; - return authorEntry; - })()]; - } - for (let i = 0; i <= pad.head; ++i) yield [`${dstPfx}:revs:${i}`, pad.getRevision(i)]; - for (let i = 0; i <= pad.chatHead; ++i) yield [`${dstPfx}:chat:${i}`, pad.getChatMessage(i)]; - for (const gen of pluginRecords) yield* gen; - })(); - const data = {[dstPfx]: pad}; - for (const [dstKey, p] of new Stream(records).batch(100).buffer(99)) data[dstKey] = await p; - await hooks.aCallAll('exportEtherpad', { - pad, - data, - dstPadId: readOnlyId || padId, - }); - return data; +exports.getPadRaw = async (padId: string, readOnlyId: string) => { + const dstPfx = `pad:${readOnlyId || padId}`; + const [pad, customPrefixes] = await Promise.all([ + padManager.getPad(padId), + hooks.aCallAll("exportEtherpadAdditionalContent"), + ]); + const pluginRecords = await Promise.all( + customPrefixes.map(async (customPrefix: string) => { + const srcPfx = `${customPrefix}:${padId}`; + const dstPfx = `${customPrefix}:${readOnlyId || padId}`; + assert(!srcPfx.includes("*")); + const srcKeys = await pad.db.findKeys(`${srcPfx}:*`, null); + return (function* () { + yield [dstPfx, pad.db.get(srcPfx)]; + for (const k of srcKeys) { + assert(k.startsWith(`${srcPfx}:`)); + yield [`${dstPfx}${k.slice(srcPfx.length)}`, pad.db.get(k)]; + } + })(); + }), + ); + const records = (function* () { + for (const authorId of pad.getAllAuthors()) { + yield [ + `globalAuthor:${authorId}`, + (async () => { + const authorEntry = await authorManager.getAuthor(authorId); + if (!authorEntry) return undefined; // Becomes unset when converted to JSON. + if (authorEntry.padIDs) authorEntry.padIDs = readOnlyId || padId; + return authorEntry; + })(), + ]; + } + for (let i = 0; i <= pad.head; ++i) + yield [`${dstPfx}:revs:${i}`, pad.getRevision(i)]; + for (let i = 0; i <= pad.chatHead; ++i) + yield [`${dstPfx}:chat:${i}`, pad.getChatMessage(i)]; + for (const gen of pluginRecords) yield* gen; + })(); + const data = { [dstPfx]: pad }; + for (const [dstKey, p] of new Stream(records).batch(100).buffer(99)) + data[dstKey] = await p; + await hooks.aCallAll("exportEtherpad", { + pad, + data, + dstPadId: readOnlyId || padId, + }); + return data; }; diff --git a/src/node/utils/ExportHelper.ts b/src/node/utils/ExportHelper.ts index f3a438e86..e09103f6a 100644 --- a/src/node/utils/ExportHelper.ts +++ b/src/node/utils/ExportHelper.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * Helpers for export requests */ @@ -19,73 +19,87 @@ * limitations under the License. */ -const AttributeMap = require('../../static/js/AttributeMap'); -const Changeset = require('../../static/js/Changeset'); -const { checkValidRev } = require('./checkValidRev'); +const AttributeMap = require("../../static/js/AttributeMap"); +const Changeset = require("../../static/js/Changeset"); +const { checkValidRev } = require("./checkValidRev"); /* * This method seems unused in core and no plugins depend on it */ -exports.getPadPlainText = (pad: { getInternalRevisionAText: (arg0: any) => any; atext: any; pool: any; }, revNum: undefined) => { - const _analyzeLine = exports._analyzeLine; - const atext = ((revNum !== undefined) ? pad.getInternalRevisionAText(checkValidRev(revNum)) : pad.atext); - const textLines = atext.text.slice(0, -1).split('\n'); - const attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text); - const apool = pad.pool; +exports.getPadPlainText = ( + pad: { getInternalRevisionAText: (arg0: any) => any; atext: any; pool: any }, + revNum: undefined, +) => { + const _analyzeLine = exports._analyzeLine; + const atext = + revNum !== undefined + ? pad.getInternalRevisionAText(checkValidRev(revNum)) + : pad.atext; + const textLines = atext.text.slice(0, -1).split("\n"); + const attribLines = Changeset.splitAttributionLines( + atext.attribs, + atext.text, + ); + const apool = pad.pool; - const pieces = []; - for (let i = 0; i < textLines.length; i++) { - const line = _analyzeLine(textLines[i], attribLines[i], apool); - if (line.listLevel) { - const numSpaces = line.listLevel * 2 - 1; - const bullet = '*'; - pieces.push(new Array(numSpaces + 1).join(' '), bullet, ' ', line.text, '\n'); - } else { - pieces.push(line.text, '\n'); - } - } + const pieces = []; + for (let i = 0; i < textLines.length; i++) { + const line = _analyzeLine(textLines[i], attribLines[i], apool); + if (line.listLevel) { + const numSpaces = line.listLevel * 2 - 1; + const bullet = "*"; + pieces.push( + new Array(numSpaces + 1).join(" "), + bullet, + " ", + line.text, + "\n", + ); + } else { + pieces.push(line.text, "\n"); + } + } - return pieces.join(''); + return pieces.join(""); }; type LineModel = { - [id:string]:string|number|LineModel -} - -exports._analyzeLine = (text:string, aline: LineModel, apool: Function) => { - const line: LineModel = {}; - - // identify list - let lineMarker = 0; - line.listLevel = 0; - if (aline) { - const [op] = Changeset.deserializeOps(aline); - if (op != null) { - const attribs = AttributeMap.fromString(op.attribs, apool); - let listType = attribs.get('list'); - if (listType) { - lineMarker = 1; - listType = /([a-z]+)([0-9]+)/.exec(listType); - if (listType) { - line.listTypeName = listType[1]; - line.listLevel = Number(listType[2]); - } - } - const start = attribs.get('start'); - if (start) { - line.start = start; - } - } - } - if (lineMarker) { - line.text = text.substring(1); - line.aline = Changeset.subattribution(aline, 1); - } else { - line.text = text; - line.aline = aline; - } - return line; + [id: string]: string | number | LineModel; }; +exports._analyzeLine = (text: string, aline: LineModel, apool: Function) => { + const line: LineModel = {}; -exports._encodeWhitespace = - (s:string) => s.replace(/[^\x21-\x7E\s\t\n\r]/gu, (c) => `&#${c.codePointAt(0)};`); + // identify list + let lineMarker = 0; + line.listLevel = 0; + if (aline) { + const [op] = Changeset.deserializeOps(aline); + if (op != null) { + const attribs = AttributeMap.fromString(op.attribs, apool); + let listType = attribs.get("list"); + if (listType) { + lineMarker = 1; + listType = /([a-z]+)([0-9]+)/.exec(listType); + if (listType) { + line.listTypeName = listType[1]; + line.listLevel = Number(listType[2]); + } + } + const start = attribs.get("start"); + if (start) { + line.start = start; + } + } + } + if (lineMarker) { + line.text = text.substring(1); + line.aline = Changeset.subattribution(aline, 1); + } else { + line.text = text; + line.aline = aline; + } + return line; +}; + +exports._encodeWhitespace = (s: string) => + s.replace(/[^\x21-\x7E\s\t\n\r]/gu, (c) => `&#${c.codePointAt(0)};`); diff --git a/src/node/utils/ExportHtml.ts b/src/node/utils/ExportHtml.ts index 3b84c4380..72c7903b1 100644 --- a/src/node/utils/ExportHtml.ts +++ b/src/node/utils/ExportHtml.ts @@ -1,6 +1,6 @@ -'use strict'; -import {AText, PadType} from "../types/PadType"; -import {MapArrayType} from "../types/MapType"; +"use strict"; +import { AText, PadType } from "../types/PadType"; +import { MapArrayType } from "../types/MapType"; /** * Copyright 2009 Google Inc. @@ -18,337 +18,377 @@ import {MapArrayType} from "../types/MapType"; * limitations under the License. */ -const Changeset = require('../../static/js/Changeset'); -const attributes = require('../../static/js/attributes'); -const padManager = require('../db/PadManager'); -const _ = require('underscore'); -const Security = require('../../static/js/security'); -const hooks = require('../../static/js/pluginfw/hooks'); -const eejs = require('../eejs'); -const _analyzeLine = require('./ExportHelper')._analyzeLine; -const _encodeWhitespace = require('./ExportHelper')._encodeWhitespace; -const padutils = require('../../static/js/pad_utils').padutils; +const Changeset = require("../../static/js/Changeset"); +const attributes = require("../../static/js/attributes"); +const padManager = require("../db/PadManager"); +const _ = require("underscore"); +const Security = require("../../static/js/security"); +const hooks = require("../../static/js/pluginfw/hooks"); +const eejs = require("../eejs"); +const _analyzeLine = require("./ExportHelper")._analyzeLine; +const _encodeWhitespace = require("./ExportHelper")._encodeWhitespace; +const padutils = require("../../static/js/pad_utils").padutils; const getPadHTML = async (pad: PadType, revNum: string) => { - let atext = pad.atext; + let atext = pad.atext; - // fetch revision atext - if (revNum !== undefined) { - atext = await pad.getInternalRevisionAText(revNum); - } + // fetch revision atext + if (revNum !== undefined) { + atext = await pad.getInternalRevisionAText(revNum); + } - // convert atext to html - return await getHTMLFromAtext(pad, atext); + // convert atext to html + return await getHTMLFromAtext(pad, atext); }; -const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string[]) => { - const apool = pad.apool(); - const textLines = atext.text.slice(0, -1).split('\n'); - const attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text); +const getHTMLFromAtext = async ( + pad: PadType, + atext: AText, + authorColors?: string[], +) => { + const apool = pad.apool(); + const textLines = atext.text.slice(0, -1).split("\n"); + const attribLines = Changeset.splitAttributionLines( + atext.attribs, + atext.text, + ); - const tags = ['h1', 'h2', 'strong', 'em', 'u', 's']; - const props = ['heading1', 'heading2', 'bold', 'italic', 'underline', 'strikethrough']; + const tags = ["h1", "h2", "strong", "em", "u", "s"]; + const props = [ + "heading1", + "heading2", + "bold", + "italic", + "underline", + "strikethrough", + ]; - await Promise.all([ - // prepare tags stored as ['tag', true] to be exported - hooks.aCallAll('exportHtmlAdditionalTags', pad).then((newProps: string[]) => { - newProps.forEach((prop) => { - tags.push(prop); - props.push(prop); - }); - }), - // prepare tags stored as ['tag', 'value'] to be exported. This will generate HTML with tags - // like - hooks.aCallAll('exportHtmlAdditionalTagsWithData', pad).then((newProps: string[]) => { - newProps.forEach((prop) => { - tags.push(`span data-${prop[0]}="${prop[1]}"`); - props.push(prop); - }); - }), - ]); + await Promise.all([ + // prepare tags stored as ['tag', true] to be exported + hooks + .aCallAll("exportHtmlAdditionalTags", pad) + .then((newProps: string[]) => { + newProps.forEach((prop) => { + tags.push(prop); + props.push(prop); + }); + }), + // prepare tags stored as ['tag', 'value'] to be exported. This will generate HTML with tags + // like + hooks + .aCallAll("exportHtmlAdditionalTagsWithData", pad) + .then((newProps: string[]) => { + newProps.forEach((prop) => { + tags.push(`span data-${prop[0]}="${prop[1]}"`); + props.push(prop); + }); + }), + ]); - // holds a map of used styling attributes (*1, *2, etc) in the apool - // and maps them to an index in props - // *3:2 -> the attribute *3 means strong - // *2:5 -> the attribute *2 means s(trikethrough) - const anumMap:MapArrayType = {}; - let css = ''; + // holds a map of used styling attributes (*1, *2, etc) in the apool + // and maps them to an index in props + // *3:2 -> the attribute *3 means strong + // *2:5 -> the attribute *2 means s(trikethrough) + const anumMap: MapArrayType = {}; + let css = ""; - const stripDotFromAuthorID = (id: string) => id.replace(/\./g, '_'); + const stripDotFromAuthorID = (id: string) => id.replace(/\./g, "_"); - if (authorColors) { - css += ''; - } + css += ""; + } - // iterates over all props(h1,h2,strong,...), checks if it is used in - // this pad, and if yes puts its attrib id->props value into anumMap - props.forEach((propName, i) => { - let attrib = [propName, true]; - if (Array.isArray(propName)) { - // propName can be in the form of ['color', 'red'], - // see hook exportHtmlAdditionalTagsWithData - attrib = propName; - } - const propTrueNum = apool.putAttrib(attrib, true); - if (propTrueNum >= 0) { - anumMap[propTrueNum] = i; - } - }); + // iterates over all props(h1,h2,strong,...), checks if it is used in + // this pad, and if yes puts its attrib id->props value into anumMap + props.forEach((propName, i) => { + let attrib = [propName, true]; + if (Array.isArray(propName)) { + // propName can be in the form of ['color', 'red'], + // see hook exportHtmlAdditionalTagsWithData + attrib = propName; + } + const propTrueNum = apool.putAttrib(attrib, true); + if (propTrueNum >= 0) { + anumMap[propTrueNum] = i; + } + }); - const getLineHTML = (text: string, attribs: string[]) => { - // Use order of tags (b/i/u) as order of nesting, for simplicity - // and decent nesting. For example, - // Just bold Bold and italics Just italics - // becomes - // Just bold Bold and italics Just italics - const taker = Changeset.stringIterator(text); - const assem = Changeset.stringAssembler(); - const openTags:string[] = []; + const getLineHTML = (text: string, attribs: string[]) => { + // Use order of tags (b/i/u) as order of nesting, for simplicity + // and decent nesting. For example, + // Just bold Bold and italics Just italics + // becomes + // Just bold Bold and italics Just italics + const taker = Changeset.stringIterator(text); + const assem = Changeset.stringAssembler(); + const openTags: string[] = []; - const getSpanClassFor = (i: string) => { - // return if author colors are disabled - if (!authorColors) return false; + const getSpanClassFor = (i: string) => { + // return if author colors are disabled + if (!authorColors) return false; - // @ts-ignore - const property = props[i]; + // @ts-ignore + const property = props[i]; - // we are not insterested on properties in the form of ['color', 'red'], - // see hook exportHtmlAdditionalTagsWithData - if (Array.isArray(property)) { - return false; - } + // we are not insterested on properties in the form of ['color', 'red'], + // see hook exportHtmlAdditionalTagsWithData + if (Array.isArray(property)) { + return false; + } - if (property.substr(0, 6) === 'author') { - return stripDotFromAuthorID(property); - } + if (property.substr(0, 6) === "author") { + return stripDotFromAuthorID(property); + } - if (property === 'removed') { - return 'removed'; - } + if (property === "removed") { + return "removed"; + } - return false; - }; + return false; + }; - // tags added by exportHtmlAdditionalTagsWithData will be exported as with - // data attributes - const isSpanWithData = (i: string) => { - // @ts-ignore - const property = props[i]; - return Array.isArray(property); - }; + // tags added by exportHtmlAdditionalTagsWithData will be exported as with + // data attributes + const isSpanWithData = (i: string) => { + // @ts-ignore + const property = props[i]; + return Array.isArray(property); + }; - const emitOpenTag = (i: string) => { - openTags.unshift(i); - const spanClass = getSpanClassFor(i); + const emitOpenTag = (i: string) => { + openTags.unshift(i); + const spanClass = getSpanClassFor(i); - if (spanClass) { - assem.append(''); - } else { - assem.append('<'); - // @ts-ignore - assem.append(tags[i]); - assem.append('>'); - } - }; + if (spanClass) { + assem.append(''); + } else { + assem.append("<"); + // @ts-ignore + assem.append(tags[i]); + assem.append(">"); + } + }; - // this closes an open tag and removes its reference from openTags - const emitCloseTag = (i: string) => { - openTags.shift(); - const spanClass = getSpanClassFor(i); - const spanWithData = isSpanWithData(i); + // this closes an open tag and removes its reference from openTags + const emitCloseTag = (i: string) => { + openTags.shift(); + const spanClass = getSpanClassFor(i); + const spanWithData = isSpanWithData(i); - if (spanClass || spanWithData) { - assem.append(''); - } else { - assem.append(''); - } - }; + if (spanClass || spanWithData) { + assem.append(""); + } else { + assem.append(""); + } + }; - const urls = padutils.findURLs(text); + const urls = padutils.findURLs(text); - let idx = 0; + let idx = 0; - const processNextChars = (numChars: number) => { - if (numChars <= 0) { - return; - } + const processNextChars = (numChars: number) => { + if (numChars <= 0) { + return; + } - const ops = Changeset.deserializeOps(Changeset.subattribution(attribs, idx, idx + numChars)); - idx += numChars; + const ops = Changeset.deserializeOps( + Changeset.subattribution(attribs, idx, idx + numChars), + ); + idx += numChars; - // this iterates over every op string and decides which tags to open or to close - // based on the attribs used - for (const o of ops) { - const usedAttribs:string[] = []; + // this iterates over every op string and decides which tags to open or to close + // based on the attribs used + for (const o of ops) { + const usedAttribs: string[] = []; - // mark all attribs as used - for (const a of attributes.decodeAttribString(o.attribs)) { - if (a in anumMap) { - usedAttribs.push(String(anumMap[a])); // i = 0 => bold, etc. - } - } - let outermostTag = -1; - // find the outer most open tag that is no longer used - for (let i = openTags.length - 1; i >= 0; i--) { - if (usedAttribs.indexOf(openTags[i]) === -1) { - outermostTag = i; - break; - } - } + // mark all attribs as used + for (const a of attributes.decodeAttribString(o.attribs)) { + if (a in anumMap) { + usedAttribs.push(String(anumMap[a])); // i = 0 => bold, etc. + } + } + let outermostTag = -1; + // find the outer most open tag that is no longer used + for (let i = openTags.length - 1; i >= 0; i--) { + if (usedAttribs.indexOf(openTags[i]) === -1) { + outermostTag = i; + break; + } + } - // close all tags upto the outer most - if (outermostTag !== -1) { - while (outermostTag >= 0) { - emitCloseTag(openTags[0]); - outermostTag--; - } - } + // close all tags upto the outer most + if (outermostTag !== -1) { + while (outermostTag >= 0) { + emitCloseTag(openTags[0]); + outermostTag--; + } + } - // open all tags that are used but not open - for (let i = 0; i < usedAttribs.length; i++) { - if (openTags.indexOf(usedAttribs[i]) === -1) { - emitOpenTag(usedAttribs[i]); - } - } + // open all tags that are used but not open + for (let i = 0; i < usedAttribs.length; i++) { + if (openTags.indexOf(usedAttribs[i]) === -1) { + emitOpenTag(usedAttribs[i]); + } + } - let chars = o.chars; - if (o.lines) { - chars--; // exclude newline at end of line, if present - } + let chars = o.chars; + if (o.lines) { + chars--; // exclude newline at end of line, if present + } - let s = taker.take(chars); + let s = taker.take(chars); - // removes the characters with the code 12. Don't know where they come - // from but they break the abiword parser and are completly useless - s = s.replace(String.fromCharCode(12), ''); + // removes the characters with the code 12. Don't know where they come + // from but they break the abiword parser and are completly useless + s = s.replace(String.fromCharCode(12), ""); - assem.append(_encodeWhitespace(Security.escapeHTML(s))); - } // end iteration over spans in line + assem.append(_encodeWhitespace(Security.escapeHTML(s))); + } // end iteration over spans in line - // close all the tags that are open after the last op - while (openTags.length > 0) { - emitCloseTag(openTags[0]); - } - }; - // end processNextChars - if (urls) { - urls.forEach((urlData: [number, { - length: number, - }]) => { - const startIndex = urlData[0]; - const url = urlData[1]; - const urlLength = url.length; - processNextChars(startIndex - idx); - // Using rel="noreferrer" stops leaking the URL/location of the exported HTML - // when clicking links in the document. - // Not all browsers understand this attribute, but it's part of the HTML5 standard. - // https://html.spec.whatwg.org/multipage/links.html#link-type-noreferrer - // Additionally, we do rel="noopener" to ensure a higher level of referrer security. - // https://html.spec.whatwg.org/multipage/links.html#link-type-noopener - // https://mathiasbynens.github.io/rel-noopener/ - // https://github.com/ether/etherpad-lite/pull/3636 - assem.append(``); - processNextChars(urlLength); - assem.append(''); - }); - } - processNextChars(text.length - idx); + // close all the tags that are open after the last op + while (openTags.length > 0) { + emitCloseTag(openTags[0]); + } + }; + // end processNextChars + if (urls) { + urls.forEach( + ( + urlData: [ + number, + { + length: number; + }, + ], + ) => { + const startIndex = urlData[0]; + const url = urlData[1]; + const urlLength = url.length; + processNextChars(startIndex - idx); + // Using rel="noreferrer" stops leaking the URL/location of the exported HTML + // when clicking links in the document. + // Not all browsers understand this attribute, but it's part of the HTML5 standard. + // https://html.spec.whatwg.org/multipage/links.html#link-type-noreferrer + // Additionally, we do rel="noopener" to ensure a higher level of referrer security. + // https://html.spec.whatwg.org/multipage/links.html#link-type-noopener + // https://mathiasbynens.github.io/rel-noopener/ + // https://github.com/ether/etherpad-lite/pull/3636 + assem.append( + ``, + ); + processNextChars(urlLength); + assem.append(""); + }, + ); + } + processNextChars(text.length - idx); - return _processSpaces(assem.toString()); - }; - // end getLineHTML - const pieces = [css]; + return _processSpaces(assem.toString()); + }; + // end getLineHTML + const pieces = [css]; - // Need to deal with constraints imposed on HTML lists; can - // only gain one level of nesting at once, can't change type - // mid-list, etc. - // People might use weird indenting, e.g. skip a level, - // so we want to do something reasonable there. We also - // want to deal gracefully with blank lines. - // => keeps track of the parents level of indentation + // Need to deal with constraints imposed on HTML lists; can + // only gain one level of nesting at once, can't change type + // mid-list, etc. + // People might use weird indenting, e.g. skip a level, + // so we want to do something reasonable there. We also + // want to deal gracefully with blank lines. + // => keeps track of the parents level of indentation - type openList = { - level: number, - type: string, - } + type openList = { + level: number; + type: string; + }; - let openLists: openList[] = []; - for (let i = 0; i < textLines.length; i++) { - let context; - const line = _analyzeLine(textLines[i], attribLines[i], apool); - const lineContent = getLineHTML(line.text, line.aline); - // If we are inside a list - if (line.listLevel) { - context = { - line, - lineContent, - apool, - attribLine: attribLines[i], - text: textLines[i], - padId: pad.id, - }; - let prevLine = null; - let nextLine = null; - if (i > 0) { - prevLine = _analyzeLine(textLines[i - 1], attribLines[i - 1], apool); - } - if (i < textLines.length) { - nextLine = _analyzeLine(textLines[i + 1], attribLines[i + 1], apool); - } - await hooks.aCallAll('getLineHTMLForExport', context); - // To create list parent elements - if ((!prevLine || prevLine.listLevel !== line.listLevel) || - (line.listTypeName !== prevLine.listTypeName)) { - const exists = _.find(openLists, (item:openList) => ( - item.level === line.listLevel && item.type === line.listTypeName)); - if (!exists) { - let prevLevel = 0; - if (prevLine && prevLine.listLevel) { - prevLevel = prevLine.listLevel; - } - if (prevLine && line.listTypeName !== prevLine.listTypeName) { - prevLevel = 0; - } + let openLists: openList[] = []; + for (let i = 0; i < textLines.length; i++) { + let context; + const line = _analyzeLine(textLines[i], attribLines[i], apool); + const lineContent = getLineHTML(line.text, line.aline); + // If we are inside a list + if (line.listLevel) { + context = { + line, + lineContent, + apool, + attribLine: attribLines[i], + text: textLines[i], + padId: pad.id, + }; + let prevLine = null; + let nextLine = null; + if (i > 0) { + prevLine = _analyzeLine(textLines[i - 1], attribLines[i - 1], apool); + } + if (i < textLines.length) { + nextLine = _analyzeLine(textLines[i + 1], attribLines[i + 1], apool); + } + await hooks.aCallAll("getLineHTMLForExport", context); + // To create list parent elements + if ( + !prevLine || + prevLine.listLevel !== line.listLevel || + line.listTypeName !== prevLine.listTypeName + ) { + const exists = _.find( + openLists, + (item: openList) => + item.level === line.listLevel && item.type === line.listTypeName, + ); + if (!exists) { + let prevLevel = 0; + if (prevLine && prevLine.listLevel) { + prevLevel = prevLine.listLevel; + } + if (prevLine && line.listTypeName !== prevLine.listTypeName) { + prevLevel = 0; + } - for (let diff = prevLevel; diff < line.listLevel; diff++) { - openLists.push({level: diff, type: line.listTypeName}); - const prevPiece = pieces[pieces.length - 1]; + for (let diff = prevLevel; diff < line.listLevel; diff++) { + openLists.push({ level: diff, type: line.listTypeName }); + const prevPiece = pieces[pieces.length - 1]; - if (prevPiece.indexOf('') === 0) { - /* + if ( + prevPiece.indexOf("") === 0 + ) { + /* uncommenting this breaks nested ols.. if the previous item is NOT a ul, NOT an ol OR closing li then close the list so we consider this HTML, @@ -372,175 +412,196 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string // pieces.push(""); */ - if ((nextLine.listTypeName === 'number') && (nextLine.text === '')) { - // is the listTypeName check needed here? null text might be completely fine! - // TODO Check against Uls - // don't do anything because the next item is a nested ol openener so - // we need to keep the li open - } else { - pieces.push('
  • '); - } - } + if (nextLine.listTypeName === "number" && nextLine.text === "") { + // is the listTypeName check needed here? null text might be completely fine! + // TODO Check against Uls + // don't do anything because the next item is a nested ol openener so + // we need to keep the li open + } else { + pieces.push("
  • "); + } + } - if (line.listTypeName === 'number') { - // We introduce line.start here, this is useful for continuing - // Ordered list line numbers - // in case you have a bullet in a list IE you Want - // 1. hello - // * foo - // 2. world - // Without this line.start logic it would be - // 1. hello * foo 1. world because the bullet would kill the OL + if (line.listTypeName === "number") { + // We introduce line.start here, this is useful for continuing + // Ordered list line numbers + // in case you have a bullet in a list IE you Want + // 1. hello + // * foo + // 2. world + // Without this line.start logic it would be + // 1. hello * foo 1. world because the bullet would kill the OL - // TODO: This logic could also be used to continue OL with indented content - // but that's a job for another day.... - if (line.start) { - pieces.push(`
      `); - } else { - pieces.push(`
        `); - } - } else { - pieces.push(`
          `); - } - } - } - } - // if we're going up a level we shouldn't be adding.. - if (context.lineContent) { - pieces.push('
        • ', context.lineContent); - } + // TODO: This logic could also be used to continue OL with indented content + // but that's a job for another day.... + if (line.start) { + pieces.push( + `
            `, + ); + } else { + pieces.push(`
              `); + } + } else { + pieces.push(`
                `); + } + } + } + } + // if we're going up a level we shouldn't be adding.. + if (context.lineContent) { + pieces.push("
              • ", context.lineContent); + } - // To close list elements - if (nextLine && - nextLine.listLevel === line.listLevel && - line.listTypeName === nextLine.listTypeName) { - if (context.lineContent) { - if ((nextLine.listTypeName === 'number') && (nextLine.text === '')) { - // is the listTypeName check needed here? null text might be completely fine! - // TODO Check against Uls - // don't do anything because the next item is a nested ol openener so we need to - // keep the li open - } else { - pieces.push('
              • '); - } - } - } - if ((!nextLine || - !nextLine.listLevel || - nextLine.listLevel < line.listLevel) || - (line.listTypeName !== nextLine.listTypeName)) { - let nextLevel = 0; - if (nextLine && nextLine.listLevel) { - nextLevel = nextLine.listLevel; - } - if (nextLine && line.listTypeName !== nextLine.listTypeName) { - nextLevel = 0; - } + // To close list elements + if ( + nextLine && + nextLine.listLevel === line.listLevel && + line.listTypeName === nextLine.listTypeName + ) { + if (context.lineContent) { + if (nextLine.listTypeName === "number" && nextLine.text === "") { + // is the listTypeName check needed here? null text might be completely fine! + // TODO Check against Uls + // don't do anything because the next item is a nested ol openener so we need to + // keep the li open + } else { + pieces.push(""); + } + } + } + if ( + !nextLine || + !nextLine.listLevel || + nextLine.listLevel < line.listLevel || + line.listTypeName !== nextLine.listTypeName + ) { + let nextLevel = 0; + if (nextLine && nextLine.listLevel) { + nextLevel = nextLine.listLevel; + } + if (nextLine && line.listTypeName !== nextLine.listTypeName) { + nextLevel = 0; + } - for (let diff = nextLevel; diff < line.listLevel; diff++) { - openLists = openLists.filter((el) => el.level !== diff && el.type !== line.listTypeName); + for (let diff = nextLevel; diff < line.listLevel; diff++) { + openLists = openLists.filter( + (el) => el.level !== diff && el.type !== line.listTypeName, + ); - if (pieces[pieces.length - 1].indexOf(''); - } + if ( + pieces[pieces.length - 1].indexOf(""); + } - if (line.listTypeName === 'number') { - pieces.push('
            '); - } else { - pieces.push('
        '); - } - } - } - } else { - // outside any list, need to close line.listLevel of lists - context = { - line, - lineContent, - apool, - attribLine: attribLines[i], - text: textLines[i], - padId: pad.id, - }; + if (line.listTypeName === "number") { + pieces.push("
      "); + } else { + pieces.push(""); + } + } + } + } else { + // outside any list, need to close line.listLevel of lists + context = { + line, + lineContent, + apool, + attribLine: attribLines[i], + text: textLines[i], + padId: pad.id, + }; - await hooks.aCallAll('getLineHTMLForExport', context); - pieces.push(context.lineContent, '
      '); - } - } + await hooks.aCallAll("getLineHTMLForExport", context); + pieces.push(context.lineContent, "
      "); + } + } - return pieces.join(''); + return pieces.join(""); }; -exports.getPadHTMLDocument = async (padId: string, revNum: string, readOnlyId: number) => { - const pad = await padManager.getPad(padId); +exports.getPadHTMLDocument = async ( + padId: string, + revNum: string, + readOnlyId: number, +) => { + const pad = await padManager.getPad(padId); - // Include some Styles into the Head for Export - let stylesForExportCSS = ''; - const stylesForExport: string[] = await hooks.aCallAll('stylesForExport', padId); - stylesForExport.forEach((css) => { - stylesForExportCSS += css; - }); + // Include some Styles into the Head for Export + let stylesForExportCSS = ""; + const stylesForExport: string[] = await hooks.aCallAll( + "stylesForExport", + padId, + ); + stylesForExport.forEach((css) => { + stylesForExportCSS += css; + }); - let html = await getPadHTML(pad, revNum); + let html = await getPadHTML(pad, revNum); - for (const hookHtml of await hooks.aCallAll('exportHTMLAdditionalContent', {padId})) { - html += hookHtml; - } + for (const hookHtml of await hooks.aCallAll("exportHTMLAdditionalContent", { + padId, + })) { + html += hookHtml; + } - return eejs.require('ep_etherpad-lite/templates/export_html.html', { - body: html, - padId: Security.escapeHTML(readOnlyId || padId), - extraCSS: stylesForExportCSS, - }); + return eejs.require("ep_etherpad-lite/templates/export_html.html", { + body: html, + padId: Security.escapeHTML(readOnlyId || padId), + extraCSS: stylesForExportCSS, + }); }; // copied from ACE const _processSpaces = (s: string) => { - const doesWrap = true; - if (s.indexOf('<') < 0 && !doesWrap) { - // short-cut - return s.replace(/ /g, ' '); - } - const parts = []; - s.replace(/<[^>]*>?| |[^ <]+/g, (m) => { - parts.push(m); - return m - }); - if (doesWrap) { - let endOfLine = true; - let beforeSpace = false; - // last space in a run is normal, others are nbsp, - // end of line is nbsp - for (let i = parts.length - 1; i >= 0; i--) { - const p = parts[i]; - if (p === ' ') { - if (endOfLine || beforeSpace) parts[i] = ' '; - endOfLine = false; - beforeSpace = true; - } else if (p.charAt(0) !== '<') { - endOfLine = false; - beforeSpace = false; - } - } - // beginning of line is nbsp - for (let i = 0; i < parts.length; i++) { - const p = parts[i]; - if (p === ' ') { - parts[i] = ' '; - break; - } else if (p.charAt(0) !== '<') { - break; - } - } - } else { - for (let i = 0; i < parts.length; i++) { - const p = parts[i]; - if (p === ' ') { - parts[i] = ' '; - } - } - } - return parts.join(''); + const doesWrap = true; + if (s.indexOf("<") < 0 && !doesWrap) { + // short-cut + return s.replace(/ /g, " "); + } + const parts = []; + s.replace(/<[^>]*>?| |[^ <]+/g, (m) => { + parts.push(m); + return m; + }); + if (doesWrap) { + let endOfLine = true; + let beforeSpace = false; + // last space in a run is normal, others are nbsp, + // end of line is nbsp + for (let i = parts.length - 1; i >= 0; i--) { + const p = parts[i]; + if (p === " ") { + if (endOfLine || beforeSpace) parts[i] = " "; + endOfLine = false; + beforeSpace = true; + } else if (p.charAt(0) !== "<") { + endOfLine = false; + beforeSpace = false; + } + } + // beginning of line is nbsp + for (let i = 0; i < parts.length; i++) { + const p = parts[i]; + if (p === " ") { + parts[i] = " "; + break; + } else if (p.charAt(0) !== "<") { + break; + } + } + } else { + for (let i = 0; i < parts.length; i++) { + const p = parts[i]; + if (p === " ") { + parts[i] = " "; + } + } + } + return parts.join(""); }; exports.getPadHTML = getPadHTML; diff --git a/src/node/utils/ExportTxt.ts b/src/node/utils/ExportTxt.ts index 95e8b0456..89331b1b5 100644 --- a/src/node/utils/ExportTxt.ts +++ b/src/node/utils/ExportTxt.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * TXT export */ @@ -19,250 +19,262 @@ * limitations under the License. */ -import {AText, PadType} from "../types/PadType"; -import {MapType} from "../types/MapType"; +import { AText, PadType } from "../types/PadType"; +import { MapType } from "../types/MapType"; -const Changeset = require('../../static/js/Changeset'); -const attributes = require('../../static/js/attributes'); -const padManager = require('../db/PadManager'); -const _analyzeLine = require('./ExportHelper')._analyzeLine; +const Changeset = require("../../static/js/Changeset"); +const attributes = require("../../static/js/attributes"); +const padManager = require("../db/PadManager"); +const _analyzeLine = require("./ExportHelper")._analyzeLine; // This is slightly different than the HTML method as it passes the output to getTXTFromAText const getPadTXT = async (pad: PadType, revNum: string) => { - let atext = pad.atext; + let atext = pad.atext; - if (revNum !== undefined) { - // fetch revision atext - atext = await pad.getInternalRevisionAText(revNum); - } + if (revNum !== undefined) { + // fetch revision atext + atext = await pad.getInternalRevisionAText(revNum); + } - // convert atext to html - return getTXTFromAtext(pad, atext); + // convert atext to html + return getTXTFromAtext(pad, atext); }; // This is different than the functionality provided in ExportHtml as it provides formatting // functionality that is designed specifically for TXT exports -const getTXTFromAtext = (pad: PadType, atext: AText, authorColors?:string) => { - const apool = pad.apool(); - const textLines = atext.text.slice(0, -1).split('\n'); - const attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text); +const getTXTFromAtext = (pad: PadType, atext: AText, authorColors?: string) => { + const apool = pad.apool(); + const textLines = atext.text.slice(0, -1).split("\n"); + const attribLines = Changeset.splitAttributionLines( + atext.attribs, + atext.text, + ); - const props = ['heading1', 'heading2', 'bold', 'italic', 'underline', 'strikethrough']; - const anumMap: MapType = {}; - const css = ''; + const props = [ + "heading1", + "heading2", + "bold", + "italic", + "underline", + "strikethrough", + ]; + const anumMap: MapType = {}; + const css = ""; - props.forEach((propName, i) => { - const propTrueNum = apool.putAttrib([propName, true], true); - if (propTrueNum >= 0) { - anumMap[propTrueNum] = i; - } - }); + props.forEach((propName, i) => { + const propTrueNum = apool.putAttrib([propName, true], true); + if (propTrueNum >= 0) { + anumMap[propTrueNum] = i; + } + }); - const getLineTXT = (text:string, attribs:any) => { - const propVals:(number|boolean)[] = [false, false, false]; - const ENTER = 1; - const STAY = 2; - const LEAVE = 0; + const getLineTXT = (text: string, attribs: any) => { + const propVals: (number | boolean)[] = [false, false, false]; + const ENTER = 1; + const STAY = 2; + const LEAVE = 0; - // Use order of tags (b/i/u) as order of nesting, for simplicity - // and decent nesting. For example, - // Just bold Bold and italics Just italics - // becomes - // Just bold Bold and italics Just italics - const taker = Changeset.stringIterator(text); - const assem = Changeset.stringAssembler(); + // Use order of tags (b/i/u) as order of nesting, for simplicity + // and decent nesting. For example, + // Just bold Bold and italics Just italics + // becomes + // Just bold Bold and italics Just italics + const taker = Changeset.stringIterator(text); + const assem = Changeset.stringAssembler(); - let idx = 0; + let idx = 0; - const processNextChars = (numChars: number) => { - if (numChars <= 0) { - return; - } + const processNextChars = (numChars: number) => { + if (numChars <= 0) { + return; + } - const ops = Changeset.deserializeOps(Changeset.subattribution(attribs, idx, idx + numChars)); - idx += numChars; + const ops = Changeset.deserializeOps( + Changeset.subattribution(attribs, idx, idx + numChars), + ); + idx += numChars; - for (const o of ops) { - let propChanged = false; + for (const o of ops) { + let propChanged = false; - for (const a of attributes.decodeAttribString(o.attribs)) { - if (a in anumMap) { - const i = anumMap[a] as number; // i = 0 => bold, etc. + for (const a of attributes.decodeAttribString(o.attribs)) { + if (a in anumMap) { + const i = anumMap[a] as number; // i = 0 => bold, etc. - if (!propVals[i]) { - propVals[i] = ENTER; - propChanged = true; - } else { - propVals[i] = STAY; - } - } - } + if (!propVals[i]) { + propVals[i] = ENTER; + propChanged = true; + } else { + propVals[i] = STAY; + } + } + } - for (let i = 0; i < propVals.length; i++) { - if (propVals[i] === true) { - propVals[i] = LEAVE; - propChanged = true; - } else if (propVals[i] === STAY) { - // set it back - propVals[i] = true; - } - } + for (let i = 0; i < propVals.length; i++) { + if (propVals[i] === true) { + propVals[i] = LEAVE; + propChanged = true; + } else if (propVals[i] === STAY) { + // set it back + propVals[i] = true; + } + } - // now each member of propVal is in {false,LEAVE,ENTER,true} - // according to what happens at start of span - if (propChanged) { - // leaving bold (e.g.) also leaves italics, etc. - let left = false; + // now each member of propVal is in {false,LEAVE,ENTER,true} + // according to what happens at start of span + if (propChanged) { + // leaving bold (e.g.) also leaves italics, etc. + let left = false; - for (let i = 0; i < propVals.length; i++) { - const v = propVals[i]; + for (let i = 0; i < propVals.length; i++) { + const v = propVals[i]; - if (!left) { - if (v === LEAVE) { - left = true; - } - } else if (v === true) { - // tag will be closed and re-opened - propVals[i] = STAY; - } - } + if (!left) { + if (v === LEAVE) { + left = true; + } + } else if (v === true) { + // tag will be closed and re-opened + propVals[i] = STAY; + } + } - const tags2close = []; + const tags2close = []; - for (let i = propVals.length - 1; i >= 0; i--) { - if (propVals[i] === LEAVE) { - // emitCloseTag(i); - tags2close.push(i); - propVals[i] = false; - } else if (propVals[i] === STAY) { - // emitCloseTag(i); - tags2close.push(i); - } - } + for (let i = propVals.length - 1; i >= 0; i--) { + if (propVals[i] === LEAVE) { + // emitCloseTag(i); + tags2close.push(i); + propVals[i] = false; + } else if (propVals[i] === STAY) { + // emitCloseTag(i); + tags2close.push(i); + } + } - for (let i = 0; i < propVals.length; i++) { - if (propVals[i] === ENTER || propVals[i] === STAY) { - propVals[i] = true; - } - } - // propVals is now all {true,false} again - } // end if (propChanged) + for (let i = 0; i < propVals.length; i++) { + if (propVals[i] === ENTER || propVals[i] === STAY) { + propVals[i] = true; + } + } + // propVals is now all {true,false} again + } // end if (propChanged) - let chars = o.chars; - if (o.lines) { - // exclude newline at end of line, if present - chars--; - } + let chars = o.chars; + if (o.lines) { + // exclude newline at end of line, if present + chars--; + } - const s = taker.take(chars); + const s = taker.take(chars); - // removes the characters with the code 12. Don't know where they come - // from but they break the abiword parser and are completly useless - // s = s.replace(String.fromCharCode(12), ""); + // removes the characters with the code 12. Don't know where they come + // from but they break the abiword parser and are completly useless + // s = s.replace(String.fromCharCode(12), ""); - // remove * from s, it's just not needed on a blank line.. This stops - // plugins from being able to display * at the beginning of a line - // s = s.replace("*", ""); // Then remove it + // remove * from s, it's just not needed on a blank line.. This stops + // plugins from being able to display * at the beginning of a line + // s = s.replace("*", ""); // Then remove it - assem.append(s); - } // end iteration over spans in line + assem.append(s); + } // end iteration over spans in line - const tags2close = []; - for (let i = propVals.length - 1; i >= 0; i--) { - if (propVals[i]) { - tags2close.push(i); - propVals[i] = false; - } - } - }; - // end processNextChars + const tags2close = []; + for (let i = propVals.length - 1; i >= 0; i--) { + if (propVals[i]) { + tags2close.push(i); + propVals[i] = false; + } + } + }; + // end processNextChars - processNextChars(text.length - idx); - return (assem.toString()); - }; - // end getLineHTML + processNextChars(text.length - idx); + return assem.toString(); + }; + // end getLineHTML - const pieces = [css]; + const pieces = [css]; - // Need to deal with constraints imposed on HTML lists; can - // only gain one level of nesting at once, can't change type - // mid-list, etc. - // People might use weird indenting, e.g. skip a level, - // so we want to do something reasonable there. We also - // want to deal gracefully with blank lines. - // => keeps track of the parents level of indentation + // Need to deal with constraints imposed on HTML lists; can + // only gain one level of nesting at once, can't change type + // mid-list, etc. + // People might use weird indenting, e.g. skip a level, + // so we want to do something reasonable there. We also + // want to deal gracefully with blank lines. + // => keeps track of the parents level of indentation - const listNumbers:MapType = {}; - let prevListLevel; + const listNumbers: MapType = {}; + let prevListLevel; - for (let i = 0; i < textLines.length; i++) { - const line = _analyzeLine(textLines[i], attribLines[i], apool); - let lineContent = getLineTXT(line.text, line.aline); + for (let i = 0; i < textLines.length; i++) { + const line = _analyzeLine(textLines[i], attribLines[i], apool); + let lineContent = getLineTXT(line.text, line.aline); - if (line.listTypeName === 'bullet') { - lineContent = `* ${lineContent}`; // add a bullet - } + if (line.listTypeName === "bullet") { + lineContent = `* ${lineContent}`; // add a bullet + } - if (line.listTypeName !== 'number') { - // We're no longer in an OL so we can reset counting - for (const key of Object.keys(listNumbers)) { - delete listNumbers[key]; - } - } + if (line.listTypeName !== "number") { + // We're no longer in an OL so we can reset counting + for (const key of Object.keys(listNumbers)) { + delete listNumbers[key]; + } + } - if (line.listLevel > 0) { - for (let j = line.listLevel - 1; j >= 0; j--) { - pieces.push('\t'); // tab indent list numbers.. - if (!listNumbers[line.listLevel]) { - listNumbers[line.listLevel] = 0; - } - } + if (line.listLevel > 0) { + for (let j = line.listLevel - 1; j >= 0; j--) { + pieces.push("\t"); // tab indent list numbers.. + if (!listNumbers[line.listLevel]) { + listNumbers[line.listLevel] = 0; + } + } - if (line.listTypeName === 'number') { - /* - * listLevel == amount of indentation - * listNumber(s) == item number - * - * Example: - * 1. foo - * 1.1 bah - * 2. latte - * 2.1 latte - * - * To handle going back to 2.1 when prevListLevel is lower number - * than current line.listLevel then reset the object value - */ - if (line.listLevel < prevListLevel) { - delete listNumbers[prevListLevel]; - } + if (line.listTypeName === "number") { + /* + * listLevel == amount of indentation + * listNumber(s) == item number + * + * Example: + * 1. foo + * 1.1 bah + * 2. latte + * 2.1 latte + * + * To handle going back to 2.1 when prevListLevel is lower number + * than current line.listLevel then reset the object value + */ + if (line.listLevel < prevListLevel) { + delete listNumbers[prevListLevel]; + } - // @ts-ignore - listNumbers[line.listLevel]++; - if (line.listLevel > 1) { - let x = 1; - while (x <= line.listLevel - 1) { - // if it's undefined to avoid undefined.undefined.1 for 0.0.1 - if (!listNumbers[x]) listNumbers[x] = 0; - pieces.push(`${listNumbers[x]}.`); - x++; - } - } - pieces.push(`${listNumbers[line.listLevel]}. `); - prevListLevel = line.listLevel; - } + // @ts-ignore + listNumbers[line.listLevel]++; + if (line.listLevel > 1) { + let x = 1; + while (x <= line.listLevel - 1) { + // if it's undefined to avoid undefined.undefined.1 for 0.0.1 + if (!listNumbers[x]) listNumbers[x] = 0; + pieces.push(`${listNumbers[x]}.`); + x++; + } + } + pieces.push(`${listNumbers[line.listLevel]}. `); + prevListLevel = line.listLevel; + } - pieces.push(lineContent, '\n'); - } else { - pieces.push(lineContent, '\n'); - } - } + pieces.push(lineContent, "\n"); + } else { + pieces.push(lineContent, "\n"); + } + } - return pieces.join(''); + return pieces.join(""); }; exports.getTXTFromAtext = getTXTFromAtext; -exports.getPadTXTDocument = async (padId:string, revNum:string) => { - const pad = await padManager.getPad(padId); - return getPadTXT(pad, revNum); +exports.getPadTXTDocument = async (padId: string, revNum: string) => { + const pad = await padManager.getPad(padId); + return getPadTXT(pad, revNum); }; diff --git a/src/node/utils/ImportEtherpad.ts b/src/node/utils/ImportEtherpad.ts index 50b9a43d5..a556cdafc 100644 --- a/src/node/utils/ImportEtherpad.ts +++ b/src/node/utils/ImportEtherpad.ts @@ -1,6 +1,6 @@ -'use strict'; +"use strict"; -import {APool} from "../types/PadType"; +import { APool } from "../types/PadType"; /** * 2014 John McLear (Etherpad Foundation / McLear Ltd) @@ -18,111 +18,121 @@ import {APool} from "../types/PadType"; * limitations under the License. */ -const AttributePool = require('../../static/js/AttributePool'); -const {Pad} = require('../db/Pad'); -const Stream = require('./Stream'); -const authorManager = require('../db/AuthorManager'); -const db = require('../db/DB'); -const hooks = require('../../static/js/pluginfw/hooks'); -import log4js from 'log4js'; -const supportedElems = require('../../static/js/contentcollector').supportedElems; -import ueberdb from 'ueberdb2'; +const AttributePool = require("../../static/js/AttributePool"); +const { Pad } = require("../db/Pad"); +const Stream = require("./Stream"); +const authorManager = require("../db/AuthorManager"); +const db = require("../db/DB"); +const hooks = require("../../static/js/pluginfw/hooks"); +import log4js from "log4js"; +const supportedElems = + require("../../static/js/contentcollector").supportedElems; +import ueberdb from "ueberdb2"; -const logger = log4js.getLogger('ImportEtherpad'); +const logger = log4js.getLogger("ImportEtherpad"); -exports.setPadRaw = async (padId: string, r: string, authorId = '') => { - const records = JSON.parse(r); +exports.setPadRaw = async (padId: string, r: string, authorId = "") => { + const records = JSON.parse(r); - // get supported block Elements from plugins, we will use this later. - hooks.callAll('ccRegisterBlockElements').forEach((element:any) => { - supportedElems.add(element); - }); + // get supported block Elements from plugins, we will use this later. + hooks.callAll("ccRegisterBlockElements").forEach((element: any) => { + supportedElems.add(element); + }); - // DB key prefixes for pad records. Each key is expected to have the form `${prefix}:${padId}` or - // `${prefix}:${padId}:${otherstuff}`. - const padKeyPrefixes = [ - ...await hooks.aCallAll('exportEtherpadAdditionalContent'), - 'pad', - ]; + // DB key prefixes for pad records. Each key is expected to have the form `${prefix}:${padId}` or + // `${prefix}:${padId}:${otherstuff}`. + const padKeyPrefixes = [ + ...(await hooks.aCallAll("exportEtherpadAdditionalContent")), + "pad", + ]; - let originalPadId:string|null = null; - const checkOriginalPadId = (padId: string) => { - if (originalPadId == null) originalPadId = padId; - if (originalPadId !== padId) throw new Error('unexpected pad ID in record'); - }; + let originalPadId: string | null = null; + const checkOriginalPadId = (padId: string) => { + if (originalPadId == null) originalPadId = padId; + if (originalPadId !== padId) throw new Error("unexpected pad ID in record"); + }; - // First validate and transform values. Do not commit any records to the database yet in case - // there is a problem with the data. + // First validate and transform values. Do not commit any records to the database yet in case + // there is a problem with the data. - const data = new Map(); - const existingAuthors = new Set(); - const padDb = new ueberdb.Database('memory', {data}); - await padDb.init(); - try { - const processRecord = async (key:string, value: null|{ - padIDs: string|Record, - pool: APool - }) => { - if (!value) return; - const keyParts = key.split(':'); - const [prefix, id] = keyParts; - if (prefix === 'globalAuthor' && keyParts.length === 2) { - // In the database, the padIDs subkey is an object (which is used as a set) that records - // every pad the author has worked on. When exported, that object becomes a single string - // containing the exported pad's ID. - if (typeof value.padIDs !== 'string') { - throw new TypeError('globalAuthor padIDs subkey is not a string'); - } - checkOriginalPadId(value.padIDs); - if (await authorManager.doesAuthorExist(id)) { - existingAuthors.add(id); - return; - } - value.padIDs = {[padId]: 1}; - } else if (padKeyPrefixes.includes(prefix)) { - checkOriginalPadId(id); - if (prefix === 'pad' && keyParts.length === 2) { - const pool = new AttributePool().fromJsonable(value.pool); - const unsupportedElements = new Set(); - pool.eachAttrib((k: string, v:any) => { - if (!supportedElems.has(k)) unsupportedElements.add(k); - }); - if (unsupportedElements.size) { - logger.warn(`(pad ${padId}) unsupported attributes (try installing a plugin): ` + - `${[...unsupportedElements].join(', ')}`); - } - } - keyParts[1] = padId; - key = keyParts.join(':'); - } else { - logger.debug(`(pad ${padId}) The record with the following key will be ignored unless an ` + - `importEtherpad hook function processes it: ${key}`); - return; - } - // @ts-ignore - await padDb.set(key, value); - }; - // @ts-ignore - const readOps = new Stream(Object.entries(records)).map(([k, v]) => processRecord(k, v)); - for (const op of readOps.batch(100).buffer(99)) await op; + const data = new Map(); + const existingAuthors = new Set(); + const padDb = new ueberdb.Database("memory", { data }); + await padDb.init(); + try { + const processRecord = async ( + key: string, + value: null | { + padIDs: string | Record; + pool: APool; + }, + ) => { + if (!value) return; + const keyParts = key.split(":"); + const [prefix, id] = keyParts; + if (prefix === "globalAuthor" && keyParts.length === 2) { + // In the database, the padIDs subkey is an object (which is used as a set) that records + // every pad the author has worked on. When exported, that object becomes a single string + // containing the exported pad's ID. + if (typeof value.padIDs !== "string") { + throw new TypeError("globalAuthor padIDs subkey is not a string"); + } + checkOriginalPadId(value.padIDs); + if (await authorManager.doesAuthorExist(id)) { + existingAuthors.add(id); + return; + } + value.padIDs = { [padId]: 1 }; + } else if (padKeyPrefixes.includes(prefix)) { + checkOriginalPadId(id); + if (prefix === "pad" && keyParts.length === 2) { + const pool = new AttributePool().fromJsonable(value.pool); + const unsupportedElements = new Set(); + pool.eachAttrib((k: string, v: any) => { + if (!supportedElems.has(k)) unsupportedElements.add(k); + }); + if (unsupportedElements.size) { + logger.warn( + `(pad ${padId}) unsupported attributes (try installing a plugin): ` + + `${[...unsupportedElements].join(", ")}`, + ); + } + } + keyParts[1] = padId; + key = keyParts.join(":"); + } else { + logger.debug( + `(pad ${padId}) The record with the following key will be ignored unless an ` + + `importEtherpad hook function processes it: ${key}`, + ); + return; + } + // @ts-ignore + await padDb.set(key, value); + }; + // @ts-ignore + const readOps = new Stream(Object.entries(records)).map(([k, v]) => + processRecord(k, v), + ); + for (const op of readOps.batch(100).buffer(99)) await op; - const pad = new Pad(padId, padDb); - await pad.init(null, authorId); - await hooks.aCallAll('importEtherpad', { - pad, - // Shallow freeze meant to prevent accidental bugs. It would be better to deep freeze, but - // it's not worth the added complexity. - data: Object.freeze(records), - srcPadId: originalPadId, - }); - await pad.check(); - } finally { - await padDb.close(); - } + const pad = new Pad(padId, padDb); + await pad.init(null, authorId); + await hooks.aCallAll("importEtherpad", { + pad, + // Shallow freeze meant to prevent accidental bugs. It would be better to deep freeze, but + // it's not worth the added complexity. + data: Object.freeze(records), + srcPadId: originalPadId, + }); + await pad.check(); + } finally { + await padDb.close(); + } - const writeOps = (function* () { - for (const [k, v] of data) yield db.set(k, v); - for (const a of existingAuthors) yield authorManager.addPad(a, padId); - })(); - for (const op of new Stream(writeOps).batch(100).buffer(99)) await op; + const writeOps = (function* () { + for (const [k, v] of data) yield db.set(k, v); + for (const a of existingAuthors) yield authorManager.addPad(a, padId); + })(); + for (const op of new Stream(writeOps).batch(100).buffer(99)) await op; }; diff --git a/src/node/utils/ImportHtml.ts b/src/node/utils/ImportHtml.ts index 57d9cbe4f..bec561b8d 100644 --- a/src/node/utils/ImportHtml.ts +++ b/src/node/utils/ImportHtml.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * Copyright Yaco Sistemas S.L. 2011. * @@ -15,80 +15,84 @@ * limitations under the License. */ -import log4js from 'log4js'; -const Changeset = require('../../static/js/Changeset'); -const contentcollector = require('../../static/js/contentcollector'); -import jsdom from 'jsdom'; -import {PadType} from "../types/PadType"; +import log4js from "log4js"; +const Changeset = require("../../static/js/Changeset"); +const contentcollector = require("../../static/js/contentcollector"); +import jsdom from "jsdom"; +import { PadType } from "../types/PadType"; -const apiLogger = log4js.getLogger('ImportHtml'); -let processor:any; +const apiLogger = log4js.getLogger("ImportHtml"); +let processor: any; -exports.setPadHTML = async (pad: PadType, html:string, authorId = '') => { - if (processor == null) { - const [{rehype}, {default: minifyWhitespace}] = - await Promise.all([import('rehype'), import('rehype-minify-whitespace')]); - processor = rehype().use(minifyWhitespace, {newlines: false}); - } +exports.setPadHTML = async (pad: PadType, html: string, authorId = "") => { + if (processor == null) { + const [{ rehype }, { default: minifyWhitespace }] = await Promise.all([ + import("rehype"), + import("rehype-minify-whitespace"), + ]); + processor = rehype().use(minifyWhitespace, { newlines: false }); + } - html = String(await processor.process(html)); - const {window: {document}} = new jsdom.JSDOM(html); + html = String(await processor.process(html)); + const { + window: { document }, + } = new jsdom.JSDOM(html); - // Appends a line break, used by Etherpad to ensure a caret is available - // below the last line of an import - document.body.appendChild(document.createElement('p')); + // Appends a line break, used by Etherpad to ensure a caret is available + // below the last line of an import + document.body.appendChild(document.createElement("p")); - apiLogger.debug('html:'); - apiLogger.debug(html); + apiLogger.debug("html:"); + apiLogger.debug(html); - // Convert a dom tree into a list of lines and attribute liens - // using the content collector object - const cc = contentcollector.makeContentCollector(true, null, pad.pool); - try { - // we use a try here because if the HTML is bad it will blow up - cc.collectContent(document.body); - } catch (err: any) { - apiLogger.warn(`Error processing HTML: ${err.stack || err}`); - throw err; - } + // Convert a dom tree into a list of lines and attribute liens + // using the content collector object + const cc = contentcollector.makeContentCollector(true, null, pad.pool); + try { + // we use a try here because if the HTML is bad it will blow up + cc.collectContent(document.body); + } catch (err: any) { + apiLogger.warn(`Error processing HTML: ${err.stack || err}`); + throw err; + } - const result = cc.finish(); + const result = cc.finish(); - apiLogger.debug('Lines:'); + apiLogger.debug("Lines:"); - let i; - for (i = 0; i < result.lines.length; i++) { - apiLogger.debug(`Line ${i + 1} text: ${result.lines[i]}`); - apiLogger.debug(`Line ${i + 1} attributes: ${result.lineAttribs[i]}`); - } + let i; + for (i = 0; i < result.lines.length; i++) { + apiLogger.debug(`Line ${i + 1} text: ${result.lines[i]}`); + apiLogger.debug(`Line ${i + 1} attributes: ${result.lineAttribs[i]}`); + } - // Get the new plain text and its attributes - const newText = result.lines.join('\n'); - apiLogger.debug('newText:'); - apiLogger.debug(newText); - const newAttribs = `${result.lineAttribs.join('|1+1')}|1+1`; + // Get the new plain text and its attributes + const newText = result.lines.join("\n"); + apiLogger.debug("newText:"); + apiLogger.debug(newText); + const newAttribs = `${result.lineAttribs.join("|1+1")}|1+1`; - // create a new changeset with a helper builder object - const builder = Changeset.builder(1); + // create a new changeset with a helper builder object + const builder = Changeset.builder(1); - // assemble each line into the builder - let textIndex = 0; - const newTextStart = 0; - const newTextEnd = newText.length; - for (const op of Changeset.deserializeOps(newAttribs)) { - const nextIndex = textIndex + op.chars; - if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) { - const start = Math.max(newTextStart, textIndex); - const end = Math.min(newTextEnd, nextIndex); - builder.insert(newText.substring(start, end), op.attribs); - } - textIndex = nextIndex; - } + // assemble each line into the builder + let textIndex = 0; + const newTextStart = 0; + const newTextEnd = newText.length; + for (const op of Changeset.deserializeOps(newAttribs)) { + const nextIndex = textIndex + op.chars; + if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) { + const start = Math.max(newTextStart, textIndex); + const end = Math.min(newTextEnd, nextIndex); + builder.insert(newText.substring(start, end), op.attribs); + } + textIndex = nextIndex; + } - // the changeset is ready! - const theChangeset = builder.toString(); + // the changeset is ready! + const theChangeset = builder.toString(); - apiLogger.debug(`The changeset: ${theChangeset}`); - await pad.setText('\n', authorId); - await pad.appendRevision(theChangeset, authorId); + apiLogger.debug(`The changeset: ${theChangeset}`); + await pad.setText("\n", authorId); + await pad.appendRevision(theChangeset, authorId); }; diff --git a/src/node/utils/LibreOffice.ts b/src/node/utils/LibreOffice.ts index e89ebe460..91227051f 100644 --- a/src/node/utils/LibreOffice.ts +++ b/src/node/utils/LibreOffice.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * Controls the communication with LibreOffice */ @@ -17,65 +17,76 @@ * limitations under the License. */ -const async = require('async'); -const fs = require('fs').promises; -const log4js = require('log4js'); -const os = require('os'); -const path = require('path'); -const runCmd = require('./run_cmd'); -const settings = require('./Settings'); +const async = require("async"); +const fs = require("fs").promises; +const log4js = require("log4js"); +const os = require("os"); +const path = require("path"); +const runCmd = require("./run_cmd"); +const settings = require("./Settings"); -const logger = log4js.getLogger('LibreOffice'); +const logger = log4js.getLogger("LibreOffice"); -const doConvertTask = async (task:{ - type: string, - srcFile: string, - fileExtension: string, - destFile: string, +const doConvertTask = async (task: { + type: string; + srcFile: string; + fileExtension: string; + destFile: string; }) => { - const tmpDir = os.tmpdir(); - // @ts-ignore - const p = runCmd([ - settings.soffice, - '--headless', - '--invisible', - '--nologo', - '--nolockcheck', - '--writer', - '--convert-to', - task.type, - task.srcFile, - '--outdir', - tmpDir, - ], {stdio: [ - null, - // @ts-ignore - (line) => logger.info(`[${p.child.pid}] stdout: ${line}`), - // @ts-ignore - (line) => logger.error(`[${p.child.pid}] stderr: ${line}`), - ]}); - logger.info(`[${p.child.pid}] Converting ${task.srcFile} to ${task.type} in ${tmpDir}`); - // Soffice/libreoffice is buggy and often hangs. - // To remedy this we kill the spawned process after a while. - // TODO: Use the timeout option once support for Node.js < v15.13.0 is dropped. - const hangTimeout = setTimeout(() => { - logger.error(`[${p.child.pid}] Conversion timed out; killing LibreOffice...`); - p.child.kill(); - }, 120000); - try { - await p; - } catch (err:any) { - logger.error(`[${p.child.pid}] Conversion failed: ${err.stack || err}`); - throw err; - } finally { - clearTimeout(hangTimeout); - } - logger.info(`[${p.child.pid}] Conversion done.`); - const filename = path.basename(task.srcFile); - const sourceFile = `${filename.substr(0, filename.lastIndexOf('.'))}.${task.fileExtension}`; - const sourcePath = path.join(tmpDir, sourceFile); - logger.debug(`Renaming ${sourcePath} to ${task.destFile}`); - await fs.rename(sourcePath, task.destFile); + const tmpDir = os.tmpdir(); + // @ts-ignore + const p = runCmd( + [ + settings.soffice, + "--headless", + "--invisible", + "--nologo", + "--nolockcheck", + "--writer", + "--convert-to", + task.type, + task.srcFile, + "--outdir", + tmpDir, + ], + { + stdio: [ + null, + // @ts-ignore + (line) => logger.info(`[${p.child.pid}] stdout: ${line}`), + // @ts-ignore + (line) => logger.error(`[${p.child.pid}] stderr: ${line}`), + ], + }, + ); + logger.info( + `[${p.child.pid}] Converting ${task.srcFile} to ${task.type} in ${tmpDir}`, + ); + // Soffice/libreoffice is buggy and often hangs. + // To remedy this we kill the spawned process after a while. + // TODO: Use the timeout option once support for Node.js < v15.13.0 is dropped. + const hangTimeout = setTimeout(() => { + logger.error( + `[${p.child.pid}] Conversion timed out; killing LibreOffice...`, + ); + p.child.kill(); + }, 120000); + try { + await p; + } catch (err: any) { + logger.error(`[${p.child.pid}] Conversion failed: ${err.stack || err}`); + throw err; + } finally { + clearTimeout(hangTimeout); + } + logger.info(`[${p.child.pid}] Conversion done.`); + const filename = path.basename(task.srcFile); + const sourceFile = `${filename.substr(0, filename.lastIndexOf("."))}.${ + task.fileExtension + }`; + const sourcePath = path.join(tmpDir, sourceFile); + logger.debug(`Renaming ${sourcePath} to ${task.destFile}`); + await fs.rename(sourcePath, task.destFile); }; // Conversion tasks will be queued up, so we don't overload the system @@ -89,29 +100,43 @@ const queue = async.queue(doConvertTask, 1); * @param {String} type The type to convert into * @param {Function} callback Standard callback function */ -exports.convertFile = async (srcFile: string, destFile: string, type:string) => { - // Used for the moving of the file, not the conversion - const fileExtension = type; +exports.convertFile = async ( + srcFile: string, + destFile: string, + type: string, +) => { + // Used for the moving of the file, not the conversion + const fileExtension = type; - if (type === 'html') { - // "html:XHTML Writer File:UTF8" does a better job than normal html exports - if (path.extname(srcFile).toLowerCase() === '.doc') { - type = 'html'; - } - // PDF files need to be converted with LO Draw ref https://github.com/ether/etherpad-lite/issues/4151 - if (path.extname(srcFile).toLowerCase() === '.pdf') { - type = 'html:XHTML Draw File'; - } - } + if (type === "html") { + // "html:XHTML Writer File:UTF8" does a better job than normal html exports + if (path.extname(srcFile).toLowerCase() === ".doc") { + type = "html"; + } + // PDF files need to be converted with LO Draw ref https://github.com/ether/etherpad-lite/issues/4151 + if (path.extname(srcFile).toLowerCase() === ".pdf") { + type = "html:XHTML Draw File"; + } + } - // soffice can't convert from html to doc directly (verified with LO 5 and 6) - // we need to convert to odt first, then to doc - // to avoid `Error: no export filter for /tmp/xxxx.doc` error - if (type === 'doc') { - const intermediateFile = destFile.replace(/\.doc$/, '.odt'); - await queue.pushAsync({srcFile, destFile: intermediateFile, type: 'odt', fileExtension: 'odt'}); - await queue.pushAsync({srcFile: intermediateFile, destFile, type, fileExtension}); - } else { - await queue.pushAsync({srcFile, destFile, type, fileExtension}); - } + // soffice can't convert from html to doc directly (verified with LO 5 and 6) + // we need to convert to odt first, then to doc + // to avoid `Error: no export filter for /tmp/xxxx.doc` error + if (type === "doc") { + const intermediateFile = destFile.replace(/\.doc$/, ".odt"); + await queue.pushAsync({ + srcFile, + destFile: intermediateFile, + type: "odt", + fileExtension: "odt", + }); + await queue.pushAsync({ + srcFile: intermediateFile, + destFile, + type, + fileExtension, + }); + } else { + await queue.pushAsync({ srcFile, destFile, type, fileExtension }); + } }; diff --git a/src/node/utils/Minify.js b/src/node/utils/Minify.js index 2e8a2d960..ef398a108 100644 --- a/src/node/utils/Minify.js +++ b/src/node/utils/Minify.js @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * This Module manages all /minified/* requests. It controls the @@ -21,98 +21,110 @@ * limitations under the License. */ -const settings = require('./Settings'); -const fs = require('fs').promises; -const path = require('path'); -const plugins = require('../../static/js/pluginfw/plugin_defs'); -const RequireKernel = require('etherpad-require-kernel'); -const mime = require('mime-types'); -const Threads = require('threads'); -const log4js = require('log4js'); -const sanitizePathname = require('./sanitizePathname'); +const settings = require("./Settings"); +const fs = require("fs").promises; +const path = require("path"); +const plugins = require("../../static/js/pluginfw/plugin_defs"); +const RequireKernel = require("etherpad-require-kernel"); +const mime = require("mime-types"); +const Threads = require("threads"); +const log4js = require("log4js"); +const sanitizePathname = require("./sanitizePathname"); -const logger = log4js.getLogger('Minify'); +const logger = log4js.getLogger("Minify"); -const ROOT_DIR = path.join(settings.root, 'src/static/'); +const ROOT_DIR = path.join(settings.root, "src/static/"); -const threadsPool = new Threads.Pool(() => Threads.spawn(new Threads.Worker('./MinifyWorker')), 2); +const threadsPool = new Threads.Pool( + () => Threads.spawn(new Threads.Worker("./MinifyWorker")), + 2, +); const LIBRARY_WHITELIST = [ - 'async', - 'js-cookie', - 'security', - 'split-grid', - 'tinycon', - 'underscore', - 'unorm', + "async", + "js-cookie", + "security", + "split-grid", + "tinycon", + "underscore", + "unorm", ]; // What follows is a terrible hack to avoid loop-back within the server. // TODO: Serve files from another service, or directly from the file system. const requestURI = async (url, method, headers) => { - const parsedUrl = new URL(url); - let status = 500; - const content = []; - const mockRequest = { - url, - method, - params: {filename: (parsedUrl.pathname + parsedUrl.search).replace(/^\/static\//, '')}, - headers, - }; - let mockResponse; - const p = new Promise((resolve) => { - mockResponse = { - writeHead: (_status, _headers) => { - status = _status; - for (const header in _headers) { - if (Object.prototype.hasOwnProperty.call(_headers, header)) { - headers[header] = _headers[header]; - } - } - }, - setHeader: (header, value) => { - headers[header.toLowerCase()] = value.toString(); - }, - header: (header, value) => { - headers[header.toLowerCase()] = value.toString(); - }, - write: (_content) => { - _content && content.push(_content); - }, - end: (_content) => { - _content && content.push(_content); - resolve([status, headers, content.join('')]); - }, - }; - }); - await minify(mockRequest, mockResponse); - return await p; + const parsedUrl = new URL(url); + let status = 500; + const content = []; + const mockRequest = { + url, + method, + params: { + filename: (parsedUrl.pathname + parsedUrl.search).replace( + /^\/static\//, + "", + ), + }, + headers, + }; + let mockResponse; + const p = new Promise((resolve) => { + mockResponse = { + writeHead: (_status, _headers) => { + status = _status; + for (const header in _headers) { + if (Object.prototype.hasOwnProperty.call(_headers, header)) { + headers[header] = _headers[header]; + } + } + }, + setHeader: (header, value) => { + headers[header.toLowerCase()] = value.toString(); + }, + header: (header, value) => { + headers[header.toLowerCase()] = value.toString(); + }, + write: (_content) => { + _content && content.push(_content); + }, + end: (_content) => { + _content && content.push(_content); + resolve([status, headers, content.join("")]); + }, + }; + }); + await minify(mockRequest, mockResponse); + return await p; }; const requestURIs = (locations, method, headers, callback) => { - Promise.all(locations.map(async (loc) => { - try { - return await requestURI(loc, method, headers); - } catch (err) { - logger.debug(`requestURI(${JSON.stringify(loc)}, ${JSON.stringify(method)}, ` + - `${JSON.stringify(headers)}) failed: ${err.stack || err}`); - return [500, headers, '']; - } - })).then((responses) => { - const statuss = responses.map((x) => x[0]); - const headerss = responses.map((x) => x[1]); - const contentss = responses.map((x) => x[2]); - callback(statuss, headerss, contentss); - }); + Promise.all( + locations.map(async (loc) => { + try { + return await requestURI(loc, method, headers); + } catch (err) { + logger.debug( + `requestURI(${JSON.stringify(loc)}, ${JSON.stringify(method)}, ` + + `${JSON.stringify(headers)}) failed: ${err.stack || err}`, + ); + return [500, headers, ""]; + } + }), + ).then((responses) => { + const statuss = responses.map((x) => x[0]); + const headerss = responses.map((x) => x[1]); + const contentss = responses.map((x) => x[2]); + callback(statuss, headerss, contentss); + }); }; const compatPaths = { - 'js/browser.js': 'js/vendors/browser.js', - 'js/farbtastic.js': 'js/vendors/farbtastic.js', - 'js/gritter.js': 'js/vendors/gritter.js', - 'js/html10n.js': 'js/vendors/html10n.js', - 'js/jquery.js': 'js/vendors/jquery.js', - 'js/nice-select.js': 'js/vendors/nice-select.js', + "js/browser.js": "js/vendors/browser.js", + "js/farbtastic.js": "js/vendors/farbtastic.js", + "js/gritter.js": "js/vendors/gritter.js", + "js/html10n.js": "js/vendors/html10n.js", + "js/jquery.js": "js/vendors/jquery.js", + "js/nice-select.js": "js/vendors/nice-select.js", }; /** @@ -121,206 +133,228 @@ const compatPaths = { * @param res the Express response */ const minify = async (req, res) => { - let filename = req.params.filename; - try { - filename = sanitizePathname(filename); - } catch (err) { - logger.error(`sanitization of pathname "${filename}" failed: ${err.stack || err}`); - res.writeHead(404, {}); - res.end(); - return; - } + let filename = req.params.filename; + try { + filename = sanitizePathname(filename); + } catch (err) { + logger.error( + `sanitization of pathname "${filename}" failed: ${err.stack || err}`, + ); + res.writeHead(404, {}); + res.end(); + return; + } - // Backward compatibility for plugins that require() files from old paths. - const newLocation = compatPaths[filename.replace(/^plugins\/ep_etherpad-lite\/static\//, '')]; - if (newLocation != null) { - logger.warn(`request for deprecated path "${filename}", replacing with "${newLocation}"`); - filename = newLocation; - } + // Backward compatibility for plugins that require() files from old paths. + const newLocation = + compatPaths[filename.replace(/^plugins\/ep_etherpad-lite\/static\//, "")]; + if (newLocation != null) { + logger.warn( + `request for deprecated path "${filename}", replacing with "${newLocation}"`, + ); + filename = newLocation; + } - /* Handle static files for plugins/libraries: + /* Handle static files for plugins/libraries: paths like "plugins/ep_myplugin/static/js/test.js" are rewritten into ROOT_PATH_OF_MYPLUGIN/static/js/test.js, commonly ETHERPAD_ROOT/node_modules/ep_myplugin/static/js/test.js */ - const match = filename.match(/^plugins\/([^/]+)(\/(?:(static\/.*)|.*))?$/); - if (match) { - const library = match[1]; - const libraryPath = match[2] || ''; + const match = filename.match(/^plugins\/([^/]+)(\/(?:(static\/.*)|.*))?$/); + if (match) { + const library = match[1]; + const libraryPath = match[2] || ""; - if (plugins.plugins[library] && match[3]) { - const plugin = plugins.plugins[library]; - const pluginPath = plugin.package.realPath; - filename = path.join(pluginPath, libraryPath); - // On Windows, path.relative converts forward slashes to backslashes. Convert them back - // because some of the code below assumes forward slashes. Node.js treats both the backlash - // and the forward slash characters as pathname component separators on Windows so this does - // not change the meaning of the pathname. This conversion does not introduce a directory - // traversal vulnerability because all '..\\' substrings have already been removed by - // sanitizePathname. - filename = filename.replace(/\\/g, '/'); - } else if (LIBRARY_WHITELIST.indexOf(library) !== -1) { - // Go straight into node_modules - // Avoid `require.resolve()`, since 'mustache' and 'mustache/index.js' - // would end up resolving to logically distinct resources. - filename = path.join('../node_modules/', library, libraryPath); - } - } - const [, testf] = /^plugins\/ep_etherpad-lite\/(tests\/frontend\/.*)/.exec(filename) || []; - if (testf != null) filename = `../${testf}`; + if (plugins.plugins[library] && match[3]) { + const plugin = plugins.plugins[library]; + const pluginPath = plugin.package.realPath; + filename = path.join(pluginPath, libraryPath); + // On Windows, path.relative converts forward slashes to backslashes. Convert them back + // because some of the code below assumes forward slashes. Node.js treats both the backlash + // and the forward slash characters as pathname component separators on Windows so this does + // not change the meaning of the pathname. This conversion does not introduce a directory + // traversal vulnerability because all '..\\' substrings have already been removed by + // sanitizePathname. + filename = filename.replace(/\\/g, "/"); + } else if (LIBRARY_WHITELIST.indexOf(library) !== -1) { + // Go straight into node_modules + // Avoid `require.resolve()`, since 'mustache' and 'mustache/index.js' + // would end up resolving to logically distinct resources. + filename = path.join("../node_modules/", library, libraryPath); + } + } + const [, testf] = + /^plugins\/ep_etherpad-lite\/(tests\/frontend\/.*)/.exec(filename) || []; + if (testf != null) filename = `../${testf}`; - const contentType = mime.lookup(filename); + const contentType = mime.lookup(filename); - const [date, exists] = await statFile(filename, 3); - if (date) { - date.setMilliseconds(0); - res.setHeader('last-modified', date.toUTCString()); - res.setHeader('date', (new Date()).toUTCString()); - if (settings.maxAge !== undefined) { - const expiresDate = new Date(Date.now() + settings.maxAge * 1000); - res.setHeader('expires', expiresDate.toUTCString()); - res.setHeader('cache-control', `max-age=${settings.maxAge}`); - } - } + const [date, exists] = await statFile(filename, 3); + if (date) { + date.setMilliseconds(0); + res.setHeader("last-modified", date.toUTCString()); + res.setHeader("date", new Date().toUTCString()); + if (settings.maxAge !== undefined) { + const expiresDate = new Date(Date.now() + settings.maxAge * 1000); + res.setHeader("expires", expiresDate.toUTCString()); + res.setHeader("cache-control", `max-age=${settings.maxAge}`); + } + } - if (!exists) { - res.writeHead(404, {}); - res.end(); - } else if (new Date(req.headers['if-modified-since']) >= date) { - res.writeHead(304, {}); - res.end(); - } else if (req.method === 'HEAD') { - res.header('Content-Type', contentType); - res.writeHead(200, {}); - res.end(); - } else if (req.method === 'GET') { - const content = await getFileCompressed(filename, contentType); - res.header('Content-Type', contentType); - res.writeHead(200, {}); - res.write(content); - res.end(); - } else { - res.writeHead(405, {allow: 'HEAD, GET'}); - res.end(); - } + if (!exists) { + res.writeHead(404, {}); + res.end(); + } else if (new Date(req.headers["if-modified-since"]) >= date) { + res.writeHead(304, {}); + res.end(); + } else if (req.method === "HEAD") { + res.header("Content-Type", contentType); + res.writeHead(200, {}); + res.end(); + } else if (req.method === "GET") { + const content = await getFileCompressed(filename, contentType); + res.header("Content-Type", contentType); + res.writeHead(200, {}); + res.write(content); + res.end(); + } else { + res.writeHead(405, { allow: "HEAD, GET" }); + res.end(); + } }; // Check for the existance of the file and get the last modification date. const statFile = async (filename, dirStatLimit) => { - /* - * The only external call to this function provides an explicit value for - * dirStatLimit: this check could be removed. - */ - if (typeof dirStatLimit === 'undefined') { - dirStatLimit = 3; - } + /* + * The only external call to this function provides an explicit value for + * dirStatLimit: this check could be removed. + */ + if (typeof dirStatLimit === "undefined") { + dirStatLimit = 3; + } - if (dirStatLimit < 1 || filename === '' || filename === '/') { - return [null, false]; - } else if (filename === 'js/ace.js') { - // Sometimes static assets are inlined into this file, so we have to stat - // everything. - return [await lastModifiedDateOfEverything(), true]; - } else if (filename === 'js/require-kernel.js') { - return [_requireLastModified, true]; - } else { - let stats; - try { - stats = await fs.stat(path.resolve(ROOT_DIR, filename)); - } catch (err) { - if (['ENOENT', 'ENOTDIR'].includes(err.code)) { - // Stat the directory instead. - const [date] = await statFile(path.dirname(filename), dirStatLimit - 1); - return [date, false]; - } - throw err; - } - return [stats.mtime, stats.isFile()]; - } + if (dirStatLimit < 1 || filename === "" || filename === "/") { + return [null, false]; + } else if (filename === "js/ace.js") { + // Sometimes static assets are inlined into this file, so we have to stat + // everything. + return [await lastModifiedDateOfEverything(), true]; + } else if (filename === "js/require-kernel.js") { + return [_requireLastModified, true]; + } else { + let stats; + try { + stats = await fs.stat(path.resolve(ROOT_DIR, filename)); + } catch (err) { + if (["ENOENT", "ENOTDIR"].includes(err.code)) { + // Stat the directory instead. + const [date] = await statFile(path.dirname(filename), dirStatLimit - 1); + return [date, false]; + } + throw err; + } + return [stats.mtime, stats.isFile()]; + } }; const lastModifiedDateOfEverything = async () => { - const folders2check = [path.join(ROOT_DIR, 'js/'), path.join(ROOT_DIR, 'css/')]; - let latestModification = null; - // go through this two folders - await Promise.all(folders2check.map(async (dir) => { - // read the files in the folder - const files = await fs.readdir(dir); + const folders2check = [ + path.join(ROOT_DIR, "js/"), + path.join(ROOT_DIR, "css/"), + ]; + let latestModification = null; + // go through this two folders + await Promise.all( + folders2check.map(async (dir) => { + // read the files in the folder + const files = await fs.readdir(dir); - // we wanna check the directory itself for changes too - files.push('.'); + // we wanna check the directory itself for changes too + files.push("."); - // go through all files in this folder - await Promise.all(files.map(async (filename) => { - // get the stat data of this file - const stats = await fs.stat(path.join(dir, filename)); + // go through all files in this folder + await Promise.all( + files.map(async (filename) => { + // get the stat data of this file + const stats = await fs.stat(path.join(dir, filename)); - // compare the modification time to the highest found - if (latestModification == null || stats.mtime > latestModification) { - latestModification = stats.mtime; - } - })); - })); - return latestModification; + // compare the modification time to the highest found + if (latestModification == null || stats.mtime > latestModification) { + latestModification = stats.mtime; + } + }), + ); + }), + ); + return latestModification; }; // This should be provided by the module, but until then, just use startup // time. const _requireLastModified = new Date(); -const requireDefinition = () => `var require = ${RequireKernel.kernelSource};\n`; +const requireDefinition = () => + `var require = ${RequireKernel.kernelSource};\n`; const getFileCompressed = async (filename, contentType) => { - let content = await getFile(filename); - if (!content || !settings.minify) { - return content; - } else if (contentType === 'application/javascript') { - return await new Promise((resolve) => { - threadsPool.queue(async ({compressJS}) => { - try { - logger.info('Compress JS file %s.', filename); + let content = await getFile(filename); + if (!content || !settings.minify) { + return content; + } else if (contentType === "application/javascript") { + return await new Promise((resolve) => { + threadsPool.queue(async ({ compressJS }) => { + try { + logger.info("Compress JS file %s.", filename); - content = content.toString(); - const compressResult = await compressJS(content); + content = content.toString(); + const compressResult = await compressJS(content); - if (compressResult.error) { - console.error(`Error compressing JS (${filename}) using terser`, compressResult.error); - } else { - content = compressResult.code.toString(); // Convert content obj code to string - } - } catch (error) { - console.error('getFile() returned an error in ' + - `getFileCompressed(${filename}, ${contentType}): ${error}`); - } - resolve(content); - }); - }); - } else if (contentType === 'text/css') { - return await new Promise((resolve) => { - threadsPool.queue(async ({compressCSS}) => { - try { - logger.info('Compress CSS file %s.', filename); + if (compressResult.error) { + console.error( + `Error compressing JS (${filename}) using terser`, + compressResult.error, + ); + } else { + content = compressResult.code.toString(); // Convert content obj code to string + } + } catch (error) { + console.error( + "getFile() returned an error in " + + `getFileCompressed(${filename}, ${contentType}): ${error}`, + ); + } + resolve(content); + }); + }); + } else if (contentType === "text/css") { + return await new Promise((resolve) => { + threadsPool.queue(async ({ compressCSS }) => { + try { + logger.info("Compress CSS file %s.", filename); - content = await compressCSS(filename, ROOT_DIR); - } catch (error) { - console.error(`CleanCSS.minify() returned an error on ${filename}: ${error}`); - } - resolve(content); - }); - }); - } else { - return content; - } + content = await compressCSS(filename, ROOT_DIR); + } catch (error) { + console.error( + `CleanCSS.minify() returned an error on ${filename}: ${error}`, + ); + } + resolve(content); + }); + }); + } else { + return content; + } }; const getFile = async (filename) => { - if (filename === 'js/require-kernel.js') return requireDefinition(); - return await fs.readFile(path.resolve(ROOT_DIR, filename)); + if (filename === "js/require-kernel.js") return requireDefinition(); + return await fs.readFile(path.resolve(ROOT_DIR, filename)); }; -exports.minify = (req, res, next) => minify(req, res).catch((err) => next(err || new Error(err))); +exports.minify = (req, res, next) => + minify(req, res).catch((err) => next(err || new Error(err))); exports.requestURIs = requestURIs; exports.shutdown = async (hookName, context) => { - await threadsPool.terminate(); + await threadsPool.terminate(); }; diff --git a/src/node/utils/MinifyWorker.js b/src/node/utils/MinifyWorker.js index 364ecc96c..a0b792c76 100644 --- a/src/node/utils/MinifyWorker.js +++ b/src/node/utils/MinifyWorker.js @@ -1,33 +1,35 @@ -'use strict'; +"use strict"; /** * Worker thread to minify JS & CSS files out of the main NodeJS thread */ -const CleanCSS = require('clean-css'); -const Terser = require('terser'); -const fsp = require('fs').promises; -const path = require('path'); -const Threads = require('threads'); +const CleanCSS = require("clean-css"); +const Terser = require("terser"); +const fsp = require("fs").promises; +const path = require("path"); +const Threads = require("threads"); const compressJS = (content) => Terser.minify(content); const compressCSS = async (filename, ROOT_DIR) => { - const absPath = path.resolve(ROOT_DIR, filename); - try { - const basePath = path.dirname(absPath); - const output = await new CleanCSS({ - rebase: true, - rebaseTo: basePath, - }).minify([absPath]); - return output.styles; - } catch (error) { - // on error, just yield the un-minified original, but write a log message - console.error(`Unexpected error minifying ${filename} (${absPath}): ${error}`); - return await fsp.readFile(absPath, 'utf8'); - } + const absPath = path.resolve(ROOT_DIR, filename); + try { + const basePath = path.dirname(absPath); + const output = await new CleanCSS({ + rebase: true, + rebaseTo: basePath, + }).minify([absPath]); + return output.styles; + } catch (error) { + // on error, just yield the un-minified original, but write a log message + console.error( + `Unexpected error minifying ${filename} (${absPath}): ${error}`, + ); + return await fsp.readFile(absPath, "utf8"); + } }; Threads.expose({ - compressJS, - compressCSS, + compressJS, + compressCSS, }); diff --git a/src/node/utils/NodeVersion.ts b/src/node/utils/NodeVersion.ts index 8507412d1..49c2c6572 100644 --- a/src/node/utils/NodeVersion.ts +++ b/src/node/utils/NodeVersion.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * Checks related to Node runtime version */ @@ -19,7 +19,7 @@ * limitations under the License. */ -const semver = require('semver'); +const semver = require("semver"); /** * Quits if Etherpad is not running on a given minimum Node version @@ -27,18 +27,22 @@ const semver = require('semver'); * @param {String} minNodeVersion Minimum required Node version */ exports.enforceMinNodeVersion = (minNodeVersion: string) => { - const currentNodeVersion = process.version; + const currentNodeVersion = process.version; - // we cannot use template literals, since we still do not know if we are - // running under Node >= 4.0 - if (semver.lt(currentNodeVersion, minNodeVersion)) { - console.error(`Running Etherpad on Node ${currentNodeVersion} is not supported. ` + - `Please upgrade at least to Node ${minNodeVersion}`); - process.exit(1); - } + // we cannot use template literals, since we still do not know if we are + // running under Node >= 4.0 + if (semver.lt(currentNodeVersion, minNodeVersion)) { + console.error( + `Running Etherpad on Node ${currentNodeVersion} is not supported. ` + + `Please upgrade at least to Node ${minNodeVersion}`, + ); + process.exit(1); + } - console.debug(`Running on Node ${currentNodeVersion} ` + - `(minimum required Node version: ${minNodeVersion})`); + console.debug( + `Running on Node ${currentNodeVersion} ` + + `(minimum required Node version: ${minNodeVersion})`, + ); }; /** @@ -49,12 +53,16 @@ exports.enforceMinNodeVersion = (minNodeVersion: string) => { * @param {Function} epRemovalVersion Etherpad version that will remove support for deprecated * Node releases */ -exports.checkDeprecationStatus = (lowestNonDeprecatedNodeVersion: string, epRemovalVersion:Function) => { - const currentNodeVersion = process.version; +exports.checkDeprecationStatus = ( + lowestNonDeprecatedNodeVersion: string, + epRemovalVersion: Function, +) => { + const currentNodeVersion = process.version; - if (semver.lt(currentNodeVersion, lowestNonDeprecatedNodeVersion)) { - console.warn( - `Support for Node ${currentNodeVersion} will be removed in Etherpad ${epRemovalVersion}. ` + - `Please consider updating at least to Node ${lowestNonDeprecatedNodeVersion}`); - } + if (semver.lt(currentNodeVersion, lowestNonDeprecatedNodeVersion)) { + console.warn( + `Support for Node ${currentNodeVersion} will be removed in Etherpad ${epRemovalVersion}. ` + + `Please consider updating at least to Node ${lowestNonDeprecatedNodeVersion}`, + ); + } }; diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index e773f656e..e430cb4e0 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * The Settings module reads the settings out of settings.json and provides * this information to the other modules @@ -27,51 +27,49 @@ * limitations under the License. */ -import {MapArrayType} from "../types/MapType"; -import {SettingsNode, SettingsTree} from "./SettingsTree"; -import {coerce} from "semver"; +import { MapArrayType } from "../types/MapType"; +import { SettingsNode, SettingsTree } from "./SettingsTree"; +import { coerce } from "semver"; -const absolutePaths = require('./AbsolutePaths'); -const deepEqual = require('fast-deep-equal/es6'); -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const argv = require('./Cli').argv; -const jsonminify = require('jsonminify'); -const log4js = require('log4js'); -const randomString = require('./randomstring'); -const suppressDisableMsg = ' -- To suppress these warning messages change ' + - 'suppressErrorsInPadText to true in your settings.json\n'; -const _ = require('underscore'); +const absolutePaths = require("./AbsolutePaths"); +const deepEqual = require("fast-deep-equal/es6"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const argv = require("./Cli").argv; +const jsonminify = require("jsonminify"); +const log4js = require("log4js"); +const randomString = require("./randomstring"); +const suppressDisableMsg = + " -- To suppress these warning messages change " + + "suppressErrorsInPadText to true in your settings.json\n"; +const _ = require("underscore"); -const logger = log4js.getLogger('settings'); +const logger = log4js.getLogger("settings"); // Exported values that settings.json and credentials.json cannot override. -const nonSettings = [ - 'credentialsFilename', - 'settingsFilename', -]; +const nonSettings = ["credentialsFilename", "settingsFilename"]; // This is a function to make it easy to create a new instance. It is important to not reuse a // config object after passing it to log4js.configure() because that method mutates the object. :( const defaultLogConfig = (level: string) => ({ - appenders: {console: {type: 'console'}}, - categories: { - default: {appenders: ['console'], level}, - } + appenders: { console: { type: "console" } }, + categories: { + default: { appenders: ["console"], level }, + }, }); -const defaultLogLevel = 'INFO'; +const defaultLogLevel = "INFO"; const initLogging = (config: any) => { - // log4js.configure() modifies exports.logconfig so check for equality first. - log4js.configure(config); - log4js.getLogger('console'); + // log4js.configure() modifies exports.logconfig so check for equality first. + log4js.configure(config); + log4js.getLogger("console"); - // Overwrites for console output methods - console.debug = logger.debug.bind(logger); - console.log = logger.info.bind(logger); - console.warn = logger.warn.bind(logger); - console.error = logger.error.bind(logger); + // Overwrites for console output methods + console.debug = logger.debug.bind(logger); + console.log = logger.info.bind(logger); + console.warn = logger.warn.bind(logger); + console.error = logger.error.bind(logger); }; // Initialize logging as early as possible with reasonable defaults. Logging will be re-initialized @@ -80,15 +78,21 @@ initLogging(defaultLogConfig(defaultLogLevel)); /* Root path of the installation */ exports.root = absolutePaths.findEtherpadRoot(); -logger.info('All relative paths will be interpreted relative to the identified ' + - `Etherpad base dir: ${exports.root}`); -exports.settingsFilename = absolutePaths.makeAbsolute(argv.settings || 'settings.json'); -exports.credentialsFilename = absolutePaths.makeAbsolute(argv.credentials || 'credentials.json'); +logger.info( + "All relative paths will be interpreted relative to the identified " + + `Etherpad base dir: ${exports.root}`, +); +exports.settingsFilename = absolutePaths.makeAbsolute( + argv.settings || "settings.json", +); +exports.credentialsFilename = absolutePaths.makeAbsolute( + argv.credentials || "credentials.json", +); /** * The app title, visible e.g. in the browser window */ -exports.title = 'Etherpad'; +exports.title = "Etherpad"; /** * Pathname of the favicon you want to use. If null, the skin's favicon is @@ -106,12 +110,13 @@ exports.favicon = null; */ exports.skinName = null; -exports.skinVariants = 'super-light-toolbar super-light-editor light-background'; +exports.skinVariants = + "super-light-toolbar super-light-editor light-background"; /** * The IP ep-lite should listen to */ -exports.ip = '0.0.0.0'; +exports.ip = "0.0.0.0"; /** * The Port ep-lite should listen to @@ -132,104 +137,104 @@ exports.ssl = false; /** * socket.io transport methods **/ -exports.socketTransportProtocols = ['websocket', 'polling']; +exports.socketTransportProtocols = ["websocket", "polling"]; exports.socketIo = { - /** - * Maximum permitted client message size (in bytes). - * - * All messages from clients that are larger than this will be rejected. Large values make it - * possible to paste large amounts of text, and plugins may require a larger value to work - * properly, but increasing the value increases susceptibility to denial of service attacks - * (malicious clients can exhaust memory). - */ - maxHttpBufferSize: 10000, + /** + * Maximum permitted client message size (in bytes). + * + * All messages from clients that are larger than this will be rejected. Large values make it + * possible to paste large amounts of text, and plugins may require a larger value to work + * properly, but increasing the value increases susceptibility to denial of service attacks + * (malicious clients can exhaust memory). + */ + maxHttpBufferSize: 10000, }; /* * The Type of the database */ -exports.dbType = 'dirty'; +exports.dbType = "dirty"; /** * This setting is passed with dbType to ueberDB to set up the database */ -exports.dbSettings = {filename: path.join(exports.root, 'var/dirty.db')}; +exports.dbSettings = { filename: path.join(exports.root, "var/dirty.db") }; /** * The default Text of a new pad */ exports.defaultPadText = [ - 'Welcome to Etherpad!', - '', - 'This pad text is synchronized as you type, so that everyone viewing this page sees the same ' + - 'text. This allows you to collaborate seamlessly on documents!', - '', - 'Etherpad on Github: https://github.com/ether/etherpad-lite', -].join('\n'); + "Welcome to Etherpad!", + "", + "This pad text is synchronized as you type, so that everyone viewing this page sees the same " + + "text. This allows you to collaborate seamlessly on documents!", + "", + "Etherpad on Github: https://github.com/ether/etherpad-lite", +].join("\n"); /** * The default Pad Settings for a user (Can be overridden by changing the setting */ exports.padOptions = { - noColors: false, - showControls: true, - showChat: true, - showLineNumbers: true, - useMonospaceFont: false, - userName: null, - userColor: null, - rtl: false, - alwaysShowChat: false, - chatAndUsers: false, - lang: null, + noColors: false, + showControls: true, + showChat: true, + showLineNumbers: true, + useMonospaceFont: false, + userName: null, + userColor: null, + rtl: false, + alwaysShowChat: false, + chatAndUsers: false, + lang: null, }; /** * Whether certain shortcut keys are enabled for a user in the pad */ exports.padShortcutEnabled = { - altF9: true, - altC: true, - delete: true, - cmdShift2: true, - return: true, - esc: true, - cmdS: true, - tab: true, - cmdZ: true, - cmdY: true, - cmdB: true, - cmdI: true, - cmdU: true, - cmd5: true, - cmdShiftL: true, - cmdShiftN: true, - cmdShift1: true, - cmdShiftC: true, - cmdH: true, - ctrlHome: true, - pageUp: true, - pageDown: true, + altF9: true, + altC: true, + delete: true, + cmdShift2: true, + return: true, + esc: true, + cmdS: true, + tab: true, + cmdZ: true, + cmdY: true, + cmdB: true, + cmdI: true, + cmdU: true, + cmd5: true, + cmdShiftL: true, + cmdShiftN: true, + cmdShift1: true, + cmdShiftC: true, + cmdH: true, + ctrlHome: true, + pageUp: true, + pageDown: true, }; /** * The toolbar buttons and order. */ exports.toolbar = { - left: [ - ['bold', 'italic', 'underline', 'strikethrough'], - ['orderedlist', 'unorderedlist', 'indent', 'outdent'], - ['undo', 'redo'], - ['clearauthorship'], - ], - right: [ - ['importexport', 'timeslider', 'savedrevision'], - ['settings', 'embed'], - ['showusers'], - ], - timeslider: [ - ['timeslider_export', 'timeslider_settings', 'timeslider_returnToPad'], - ], + left: [ + ["bold", "italic", "underline", "strikethrough"], + ["orderedlist", "unorderedlist", "indent", "outdent"], + ["undo", "redo"], + ["clearauthorship"], + ], + right: [ + ["importexport", "timeslider", "savedrevision"], + ["settings", "embed"], + ["showusers"], + ], + timeslider: [ + ["timeslider_export", "timeslider_settings", "timeslider_returnToPad"], + ], }; /** @@ -316,21 +321,21 @@ 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 - * this must be set to "None". Note: "None" will not work (the browser will - * not send the cookie to Etherpad) unless https is used to access Etherpad - * (either directly or via a reverse proxy with "trustProxy" set to true). - * - * "Strict" is not recommended because it has few security benefits but - * significant usability drawbacks vs. "Lax". See - * https://stackoverflow.com/q/41841880 for discussion. - */ - sameSite: 'Lax', - sessionLifetime: 10 * 24 * 60 * 60 * 1000, - sessionRefreshInterval: 1 * 24 * 60 * 60 * 1000, + 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 + * this must be set to "None". Note: "None" will not work (the browser will + * not send the cookie to Etherpad) unless https is used to access Etherpad + * (either directly or via a reverse proxy with "trustProxy" set to true). + * + * "Strict" is not recommended because it has few security benefits but + * significant usability drawbacks vs. "Lax". See + * https://stackoverflow.com/q/41841880 for discussion. + */ + sameSite: "Lax", + sessionLifetime: 10 * 24 * 60 * 60 * 1000, + sessionRefreshInterval: 1 * 24 * 60 * 60 * 1000, }; /* @@ -346,8 +351,8 @@ exports.users = {}; * This setting is used for configuring sso */ exports.sso = { - issuer: "http://localhost:9001" -} + issuer: "http://localhost:9001", +}; /* * Show settings in admin page, by default it is true @@ -359,31 +364,31 @@ exports.showSettingsInAdminPage = true; * height needed to make this line visible. */ exports.scrollWhenFocusLineIsOutOfViewport = { - /* - * Percentage of viewport height to be additionally scrolled. - */ - percentage: { - editionAboveViewport: 0, - editionBelowViewport: 0, - }, + /* + * Percentage of viewport height to be additionally scrolled. + */ + percentage: { + editionAboveViewport: 0, + editionBelowViewport: 0, + }, - /* - * Time (in milliseconds) used to animate the scroll transition. Set to 0 to - * disable animation - */ - duration: 0, + /* + * Time (in milliseconds) used to animate the scroll transition. Set to 0 to + * disable animation + */ + duration: 0, - /* - * Percentage of viewport height to be additionally scrolled when user presses arrow up - * in the line of the top of the viewport. - */ - percentageToScrollWhenUserPressesArrowUp: 0, + /* + * Percentage of viewport height to be additionally scrolled when user presses arrow up + * in the line of the top of the viewport. + */ + percentageToScrollWhenUserPressesArrowUp: 0, - /* - * Flag to control if it should scroll when user places the caret in the last - * line of the viewport - */ - scrollWhenCaretIsInTheLastLineOfViewport: false, + /* + * Flag to control if it should scroll when user places the caret in the last + * line of the viewport + */ + scrollWhenCaretIsInTheLastLineOfViewport: false, }; /* @@ -408,11 +413,11 @@ exports.customLocaleStrings = {}; * See https://github.com/nfriedly/express-rate-limit for more options */ exports.importExportRateLimiting = { - // duration of the rate limit window (milliseconds) - windowMs: 90000, + // duration of the rate limit window (milliseconds) + windowMs: 90000, - // maximum number of requests per IP to allow during the rate limit window - max: 10, + // maximum number of requests per IP to allow during the rate limit window + max: 10, }; /* @@ -424,11 +429,11 @@ exports.importExportRateLimiting = { * See https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#websocket-single-connection-prevent-flooding for more options */ exports.commitRateLimiting = { - // duration of the rate limit window (seconds) - duration: 1, + // duration of the rate limit window (seconds) + duration: 1, - // maximum number of chanes per IP to allow during the rate limit window - points: 10, + // maximum number of chanes per IP to allow during the rate limit window + points: 10, }; /* @@ -452,62 +457,64 @@ exports.lowerCasePadIds = false; // checks if abiword is avaiable exports.abiwordAvailable = () => { - if (exports.abiword != null) { - return os.type().indexOf('Windows') !== -1 ? 'withoutPDF' : 'yes'; - } else { - return 'no'; - } + if (exports.abiword != null) { + return os.type().indexOf("Windows") !== -1 ? "withoutPDF" : "yes"; + } else { + return "no"; + } }; exports.sofficeAvailable = () => { - if (exports.soffice != null) { - return os.type().indexOf('Windows') !== -1 ? 'withoutPDF' : 'yes'; - } else { - return 'no'; - } + if (exports.soffice != null) { + return os.type().indexOf("Windows") !== -1 ? "withoutPDF" : "yes"; + } else { + return "no"; + } }; exports.exportAvailable = () => { - const abiword = exports.abiwordAvailable(); - const soffice = exports.sofficeAvailable(); + const abiword = exports.abiwordAvailable(); + const soffice = exports.sofficeAvailable(); - if (abiword === 'no' && soffice === 'no') { - return 'no'; - } else if ((abiword === 'withoutPDF' && soffice === 'no') || - (abiword === 'no' && soffice === 'withoutPDF')) { - return 'withoutPDF'; - } else { - return 'yes'; - } + if (abiword === "no" && soffice === "no") { + return "no"; + } else if ( + (abiword === "withoutPDF" && soffice === "no") || + (abiword === "no" && soffice === "withoutPDF") + ) { + return "withoutPDF"; + } else { + return "yes"; + } }; // Provide git version if available exports.getGitCommit = () => { - let version = ''; - try { - let rootPath = exports.root; - if (fs.lstatSync(`${rootPath}/.git`).isFile()) { - rootPath = fs.readFileSync(`${rootPath}/.git`, 'utf8'); - rootPath = rootPath.split(' ').pop().trim(); - } else { - rootPath += '/.git'; - } - const ref = fs.readFileSync(`${rootPath}/HEAD`, 'utf-8'); - if (ref.startsWith('ref: ')) { - const refPath = `${rootPath}/${ref.substring(5, ref.indexOf('\n'))}`; - version = fs.readFileSync(refPath, 'utf-8'); - } else { - version = ref; - } - version = version.substring(0, 7); - } catch (e: any) { - logger.warn(`Can't get git version for server header\n${e.message}`); - } - return version; + let version = ""; + try { + let rootPath = exports.root; + if (fs.lstatSync(`${rootPath}/.git`).isFile()) { + rootPath = fs.readFileSync(`${rootPath}/.git`, "utf8"); + rootPath = rootPath.split(" ").pop().trim(); + } else { + rootPath += "/.git"; + } + const ref = fs.readFileSync(`${rootPath}/HEAD`, "utf-8"); + if (ref.startsWith("ref: ")) { + const refPath = `${rootPath}/${ref.substring(5, ref.indexOf("\n"))}`; + version = fs.readFileSync(refPath, "utf-8"); + } else { + version = ref; + } + version = version.substring(0, 7); + } catch (e: any) { + logger.warn(`Can't get git version for server header\n${e.message}`); + } + return version; }; // Return etherpad version from package.json -exports.getEpVersion = () => require('../../package.json').version; +exports.getEpVersion = () => require("../../package.json").version; /** * Receives a settingsObj and, if the property name is a valid configuration @@ -517,30 +524,32 @@ exports.getEpVersion = () => require('../../package.json').version; * both "settings.json" and "credentials.json". */ const storeSettings = (settingsObj: any) => { - for (const i of Object.keys(settingsObj || {})) { - if (nonSettings.includes(i)) { - logger.warn(`Ignoring setting: '${i}'`); - continue; - } + for (const i of Object.keys(settingsObj || {})) { + if (nonSettings.includes(i)) { + logger.warn(`Ignoring setting: '${i}'`); + continue; + } - // test if the setting starts with a lowercase character - if (i.charAt(0).search('[a-z]') !== 0) { - logger.warn(`Settings should start with a lowercase character: '${i}'`); - } + // test if the setting starts with a lowercase character + if (i.charAt(0).search("[a-z]") !== 0) { + logger.warn(`Settings should start with a lowercase character: '${i}'`); + } - // we know this setting, so we overwrite it - // or it's a settings hash, specific to a plugin - if (exports[i] !== undefined || i.indexOf('ep_') === 0) { - if (_.isObject(settingsObj[i]) && !Array.isArray(settingsObj[i])) { - exports[i] = _.defaults(settingsObj[i], exports[i]); - } else { - exports[i] = settingsObj[i]; - } - } else { - // this setting is unknown, output a warning and throw it away - logger.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`); - } - } + // we know this setting, so we overwrite it + // or it's a settings hash, specific to a plugin + if (exports[i] !== undefined || i.indexOf("ep_") === 0) { + if (_.isObject(settingsObj[i]) && !Array.isArray(settingsObj[i])) { + exports[i] = _.defaults(settingsObj[i], exports[i]); + } else { + exports[i] = settingsObj[i]; + } + } else { + // this setting is unknown, output a warning and throw it away + logger.warn( + `Unknown Setting: '${i}'. This setting doesn't exist or it was removed`, + ); + } + } }; /* @@ -556,28 +565,30 @@ const storeSettings = (settingsObj: any) => { * in the literal string "null", instead. */ const coerceValue = (stringValue: string) => { - // cooked from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number - // @ts-ignore - const isNumeric = !isNaN(stringValue) && !isNaN(parseFloat(stringValue) && isFinite(stringValue)); + // cooked from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number + // @ts-ignore + const isNumeric = + !isNaN(stringValue) && + !isNaN(parseFloat(stringValue) && isFinite(stringValue)); - if (isNumeric) { - // detected numeric string. Coerce to a number + if (isNumeric) { + // detected numeric string. Coerce to a number - return +stringValue; - } + return +stringValue; + } - switch (stringValue) { - case 'true': - return true; - case 'false': - return false; - case 'undefined': - return undefined; - case 'null': - return null; - default: - return stringValue; - } + switch (stringValue) { + case "true": + return true; + case "false": + return false; + case "undefined": + return undefined; + case "null": + return null; + default: + return stringValue; + } }; /** @@ -617,130 +628,136 @@ const coerceValue = (stringValue: string) => { * see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter */ const lookupEnvironmentVariables = (obj: MapArrayType) => { - const replaceEnvs = (obj: MapArrayType) => { - for (let [key, value] of Object.entries(obj)) { - /* - * the first invocation of replacer() is with an empty key. Just go on, or - * we would zap the entire object. - */ - if (key === '') { - obj[key] = value; - continue - } + const replaceEnvs = (obj: MapArrayType) => { + for (let [key, value] of Object.entries(obj)) { + /* + * the first invocation of replacer() is with an empty key. Just go on, or + * we would zap the entire object. + */ + if (key === "") { + obj[key] = value; + continue; + } - /* - * If we received from the configuration file a number, a boolean or - * something that is not a string, we can be sure that it was a literal - * value. No need to perform any variable substitution. - * - * The environment variable expansion syntax "${ENV_VAR}" is just a string - * of specific form, after all. - */ + /* + * If we received from the configuration file a number, a boolean or + * something that is not a string, we can be sure that it was a literal + * value. No need to perform any variable substitution. + * + * The environment variable expansion syntax "${ENV_VAR}" is just a string + * of specific form, after all. + */ - if(key === 'undefined' || value === undefined) { - delete obj[key] - continue - } + if (key === "undefined" || value === undefined) { + delete obj[key]; + continue; + } - if ((typeof value !== 'string' && typeof value !== 'object') || value === null) { - obj[key] = value; - continue - } + if ( + (typeof value !== "string" && typeof value !== "object") || + value === null + ) { + obj[key] = value; + continue; + } - if (typeof obj[key] === "object") { - replaceEnvs(obj[key]); - continue - } + if (typeof obj[key] === "object") { + replaceEnvs(obj[key]); + continue; + } + /* + * Let's check if the string value looks like a variable expansion (e.g.: + * "${ENV_VAR}" or "${ENV_VAR:default_value}") + */ + // MUXATOR 2019-03-21: we could use named capture groups here once we migrate to nodejs v10 + const match = value.match(/^\$\{([^:]*)(:((.|\n)*))?\}$/); - /* - * Let's check if the string value looks like a variable expansion (e.g.: - * "${ENV_VAR}" or "${ENV_VAR:default_value}") - */ - // MUXATOR 2019-03-21: we could use named capture groups here once we migrate to nodejs v10 - const match = value.match(/^\$\{([^:]*)(:((.|\n)*))?\}$/); + if (match == null) { + // no match: use the value literally, without any substitution + obj[key] = value; + continue; + } - if (match == null) { - // no match: use the value literally, without any substitution - obj[key] = value; - continue - } + /* + * We found the name of an environment variable. Let's read its actual value + * and its default value, if given + */ + const envVarName = match[1]; + const envVarValue = process.env[envVarName]; + const defaultValue = match[3]; - /* - * We found the name of an environment variable. Let's read its actual value - * and its default value, if given - */ - const envVarName = match[1]; - const envVarValue = process.env[envVarName]; - const defaultValue = match[3]; + if (envVarValue === undefined && defaultValue === undefined) { + logger.warn( + `Environment variable "${envVarName}" does not contain any value for ` + + `configuration key "${key}", and no default was given. Using null. ` + + "THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF ETHERPAD; you should " + + 'explicitly use "null" as the default if you want to continue to use null.', + ); - if ((envVarValue === undefined) && (defaultValue === undefined)) { - logger.warn(`Environment variable "${envVarName}" does not contain any value for ` + - `configuration key "${key}", and no default was given. Using null. ` + - 'THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF ETHERPAD; you should ' + - 'explicitly use "null" as the default if you want to continue to use null.'); + /* + * We have to return null, because if we just returned undefined, the + * configuration item "key" would be stripped from the returned object. + */ + obj[key] = null; + continue; + } - /* - * We have to return null, because if we just returned undefined, the - * configuration item "key" would be stripped from the returned object. - */ - obj[key] = null; - continue - } + if (envVarValue === undefined && defaultValue !== undefined) { + logger.debug( + `Environment variable "${envVarName}" not found for ` + + `configuration key "${key}". Falling back to default value.`, + ); - if ((envVarValue === undefined) && (defaultValue !== undefined)) { - logger.debug(`Environment variable "${envVarName}" not found for ` + - `configuration key "${key}". Falling back to default value.`); + obj[key] = coerceValue(defaultValue); + continue; + } - obj[key] = coerceValue(defaultValue); - continue - } + // envVarName contained some value. - // envVarName contained some value. + /* + * For numeric and boolean strings let's convert it to proper types before + * returning it, in order to maintain backward compatibility. + */ + logger.debug( + `Configuration key "${key}" will be read from environment variable "${envVarName}"`, + ); - /* - * For numeric and boolean strings let's convert it to proper types before - * returning it, in order to maintain backward compatibility. - */ - logger.debug( - `Configuration key "${key}" will be read from environment variable "${envVarName}"`); + obj[key] = coerceValue(envVarValue!); + } + return obj; + }; - obj[key] = coerceValue(envVarValue!); - } - return obj - } + replaceEnvs(obj); - replaceEnvs(obj); + // Add plugin ENV variables - // Add plugin ENV variables + /** + * If the key contains a double underscore, it's a plugin variable + * E.g. + */ + let treeEntries = new Map(); + const root = new SettingsNode("EP"); - /** - * If the key contains a double underscore, it's a plugin variable - * E.g. - */ - let treeEntries = new Map - const root = new SettingsNode("EP") + for (let [env, envVal] of Object.entries(process.env)) { + if (!env.startsWith("EP")) continue; + treeEntries.set(env, envVal); + } + treeEntries.forEach((value, key) => { + let pathToKey = key.split("__"); + let currentNode = root; + let depth = 0; + depth++; + currentNode.addChild(pathToKey, value!); + }); - for (let [env, envVal] of Object.entries(process.env)) { - if (!env.startsWith("EP")) continue - treeEntries.set(env, envVal) - } - treeEntries.forEach((value, key) => { - let pathToKey = key.split("__") - let currentNode = root - let depth = 0 - depth++ - currentNode.addChild(pathToKey, value!) - }) - - //console.log(root.collectFromLeafsUpwards()) - const rooting = root.collectFromLeafsUpwards() - console.log("Rooting is", rooting.ADMIN) - obj = Object.assign(obj, rooting) - return obj; + //console.log(root.collectFromLeafsUpwards()) + const rooting = root.collectFromLeafsUpwards(); + console.log("Rooting is", rooting.ADMIN); + obj = Object.assign(obj, rooting); + return obj; }; - /** * - reads the JSON configuration file settingsFilename from disk * - strips the comments @@ -750,184 +767,217 @@ const lookupEnvironmentVariables = (obj: MapArrayType) => { * The isSettings variable only controls the error logging. */ const parseSettings = (settingsFilename: string, isSettings: boolean) => { - let settingsStr = ''; + let settingsStr = ""; - let settingsType, notFoundMessage, notFoundFunction; + let settingsType, notFoundMessage, notFoundFunction; - if (isSettings) { - settingsType = 'settings'; - notFoundMessage = 'Continuing using defaults!'; - notFoundFunction = logger.warn.bind(logger); - } else { - settingsType = 'credentials'; - notFoundMessage = 'Ignoring.'; - notFoundFunction = logger.info.bind(logger); - } + if (isSettings) { + settingsType = "settings"; + notFoundMessage = "Continuing using defaults!"; + notFoundFunction = logger.warn.bind(logger); + } else { + settingsType = "credentials"; + notFoundMessage = "Ignoring."; + notFoundFunction = logger.info.bind(logger); + } - try { - // read the settings file - settingsStr = fs.readFileSync(settingsFilename).toString(); - } catch (e) { - notFoundFunction(`No ${settingsType} file found in ${settingsFilename}. ${notFoundMessage}`); + try { + // read the settings file + settingsStr = fs.readFileSync(settingsFilename).toString(); + } catch (e) { + notFoundFunction( + `No ${settingsType} file found in ${settingsFilename}. ${notFoundMessage}`, + ); - // or maybe undefined! - return null; - } + // or maybe undefined! + return null; + } - try { - settingsStr = jsonminify(settingsStr).replace(',]', ']').replace(',}', '}'); + try { + settingsStr = jsonminify(settingsStr).replace(",]", "]").replace(",}", "}"); - const settings = JSON.parse(settingsStr); + const settings = JSON.parse(settingsStr); - logger.info(`${settingsType} loaded from: ${settingsFilename}`); + logger.info(`${settingsType} loaded from: ${settingsFilename}`); - return lookupEnvironmentVariables(settings); - } catch (e: any) { - logger.error(`There was an error processing your ${settingsType} ` + - `file from ${settingsFilename}: ${e.message}`); + return lookupEnvironmentVariables(settings); + } catch (e: any) { + logger.error( + `There was an error processing your ${settingsType} ` + + `file from ${settingsFilename}: ${e.message}`, + ); - process.exit(1); - } + process.exit(1); + } }; exports.reloadSettings = () => { - const settings = parseSettings(exports.settingsFilename, true); - const credentials = parseSettings(exports.credentialsFilename, false); - storeSettings(settings); - storeSettings(credentials); + const settings = parseSettings(exports.settingsFilename, true); + const credentials = parseSettings(exports.credentialsFilename, false); + storeSettings(settings); + storeSettings(credentials); - // Init logging config - exports.logconfig = defaultLogConfig(exports.loglevel ? exports.loglevel : defaultLogLevel); - initLogging(exports.logconfig); + // Init logging config + exports.logconfig = defaultLogConfig( + exports.loglevel ? exports.loglevel : defaultLogLevel, + ); + initLogging(exports.logconfig); - if (!exports.skinName) { - logger.warn('No "skinName" parameter found. Please check out settings.json.template and ' + - 'update your settings.json. Falling back to the default "colibris".'); - exports.skinName = 'colibris'; - } + if (!exports.skinName) { + logger.warn( + 'No "skinName" parameter found. Please check out settings.json.template and ' + + 'update your settings.json. Falling back to the default "colibris".', + ); + exports.skinName = "colibris"; + } - // checks if skinName has an acceptable value, otherwise falls back to "colibris" - if (exports.skinName) { - const skinBasePath = path.join(exports.root, 'src', 'static', 'skins'); - const countPieces = exports.skinName.split(path.sep).length; + // checks if skinName has an acceptable value, otherwise falls back to "colibris" + if (exports.skinName) { + const skinBasePath = path.join(exports.root, "src", "static", "skins"); + const countPieces = exports.skinName.split(path.sep).length; - if (countPieces !== 1) { - logger.error(`skinName must be the name of a directory under "${skinBasePath}". This is ` + - `not valid: "${exports.skinName}". Falling back to the default "colibris".`); + if (countPieces !== 1) { + logger.error( + `skinName must be the name of a directory under "${skinBasePath}". This is ` + + `not valid: "${exports.skinName}". Falling back to the default "colibris".`, + ); - exports.skinName = 'colibris'; - } + exports.skinName = "colibris"; + } - // informative variable, just for the log messages - let skinPath = path.join(skinBasePath, exports.skinName); + // informative variable, just for the log messages + let skinPath = path.join(skinBasePath, exports.skinName); - // what if someone sets skinName == ".." or "."? We catch him! - if (absolutePaths.isSubdir(skinBasePath, skinPath) === false) { - logger.error(`Skin path ${skinPath} must be a subdirectory of ${skinBasePath}. ` + - 'Falling back to the default "colibris".'); + // what if someone sets skinName == ".." or "."? We catch him! + if (absolutePaths.isSubdir(skinBasePath, skinPath) === false) { + logger.error( + `Skin path ${skinPath} must be a subdirectory of ${skinBasePath}. ` + + 'Falling back to the default "colibris".', + ); - exports.skinName = 'colibris'; - skinPath = path.join(skinBasePath, exports.skinName); - } + exports.skinName = "colibris"; + skinPath = path.join(skinBasePath, exports.skinName); + } - if (fs.existsSync(skinPath) === false) { - logger.error(`Skin path ${skinPath} does not exist. Falling back to the default "colibris".`); - exports.skinName = 'colibris'; - skinPath = path.join(skinBasePath, exports.skinName); - } + if (fs.existsSync(skinPath) === false) { + logger.error( + `Skin path ${skinPath} does not exist. Falling back to the default "colibris".`, + ); + exports.skinName = "colibris"; + skinPath = path.join(skinBasePath, exports.skinName); + } - logger.info(`Using skin "${exports.skinName}" in dir: ${skinPath}`); - } + logger.info(`Using skin "${exports.skinName}" in dir: ${skinPath}`); + } - if (exports.abiword) { - // Check abiword actually exists - if (exports.abiword != null) { - fs.exists(exports.abiword, (exists: boolean) => { - if (!exists) { - const abiwordError = 'Abiword does not exist at this path, check your settings file.'; - if (!exports.suppressErrorsInPadText) { - exports.defaultPadText += `\nError: ${abiwordError}${suppressDisableMsg}`; - } - logger.error(`${abiwordError} File location: ${exports.abiword}`); - exports.abiword = null; - } - }); - } - } + if (exports.abiword) { + // Check abiword actually exists + if (exports.abiword != null) { + fs.exists(exports.abiword, (exists: boolean) => { + if (!exists) { + const abiwordError = + "Abiword does not exist at this path, check your settings file."; + if (!exports.suppressErrorsInPadText) { + exports.defaultPadText += `\nError: ${abiwordError}${suppressDisableMsg}`; + } + logger.error(`${abiwordError} File location: ${exports.abiword}`); + exports.abiword = null; + } + }); + } + } - if (exports.soffice) { - fs.exists(exports.soffice, (exists: boolean) => { - if (!exists) { - const sofficeError = - 'soffice (libreoffice) does not exist at this path, check your settings file.'; + if (exports.soffice) { + fs.exists(exports.soffice, (exists: boolean) => { + if (!exists) { + const sofficeError = + "soffice (libreoffice) does not exist at this path, check your settings file."; - if (!exports.suppressErrorsInPadText) { - exports.defaultPadText += `\nError: ${sofficeError}${suppressDisableMsg}`; - } - logger.error(`${sofficeError} File location: ${exports.soffice}`); - exports.soffice = null; - } - }); - } + if (!exports.suppressErrorsInPadText) { + exports.defaultPadText += `\nError: ${sofficeError}${suppressDisableMsg}`; + } + logger.error(`${sofficeError} File location: ${exports.soffice}`); + exports.soffice = null; + } + }); + } - 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 (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); - fs.writeFileSync(sessionkeyFilename, exports.sessionKey, 'utf8'); - } - } else { - logger.warn('Declaring the sessionKey in the settings.json is deprecated. ' + - 'This value is auto-generated now. Please remove the setting from the file. -- ' + - '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).'); - } + 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 (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); + fs.writeFileSync(sessionkeyFilename, exports.sessionKey, "utf8"); + } + } else { + logger.warn( + "Declaring the sessionKey in the settings.json is deprecated. " + + "This value is auto-generated now. Please remove the setting from the file. -- " + + "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.'; - if (!exports.suppressErrorsInPadText) { - exports.defaultPadText += `\nWarning: ${dirtyWarning}${suppressDisableMsg}`; - } + if (exports.dbType === "dirty") { + const dirtyWarning = + "DirtyDB is used. This is not recommended for production."; + if (!exports.suppressErrorsInPadText) { + exports.defaultPadText += `\nWarning: ${dirtyWarning}${suppressDisableMsg}`; + } - exports.dbSettings.filename = absolutePaths.makeAbsolute(exports.dbSettings.filename); - logger.warn(`${dirtyWarning} File location: ${exports.dbSettings.filename}`); - } + exports.dbSettings.filename = absolutePaths.makeAbsolute( + exports.dbSettings.filename, + ); + logger.warn( + `${dirtyWarning} File location: ${exports.dbSettings.filename}`, + ); + } - if (exports.ip === '') { - // using Unix socket for connectivity - logger.warn('The settings file contains an empty string ("") for the "ip" parameter. The ' + - '"port" parameter will be interpreted as the path to a Unix socket to bind at.'); - } + if (exports.ip === "") { + // using Unix socket for connectivity + logger.warn( + 'The settings file contains an empty string ("") for the "ip" parameter. The ' + + '"port" parameter will be interpreted as the path to a Unix socket to bind at.', + ); + } - /* - * At each start, Etherpad generates a random string and appends it as query - * parameter to the URLs of the static assets, in order to force their reload. - * Subsequent requests will be cached, as long as the server is not reloaded. - * - * For the rationale behind this choice, see - * https://github.com/ether/etherpad-lite/pull/3958 - * - * ACHTUNG: this may prevent caching HTTP proxies to work - * TODO: remove the "?v=randomstring" parameter, and replace with hashed filenames instead - */ - exports.randomVersionString = randomString(4); - logger.info(`Random string used for versioning assets: ${exports.randomVersionString}`); + /* + * At each start, Etherpad generates a random string and appends it as query + * parameter to the URLs of the static assets, in order to force their reload. + * Subsequent requests will be cached, as long as the server is not reloaded. + * + * For the rationale behind this choice, see + * https://github.com/ether/etherpad-lite/pull/3958 + * + * ACHTUNG: this may prevent caching HTTP proxies to work + * TODO: remove the "?v=randomstring" parameter, and replace with hashed filenames instead + */ + exports.randomVersionString = randomString(4); + logger.info( + `Random string used for versioning assets: ${exports.randomVersionString}`, + ); }; exports.exportedForTestingOnly = { - parseSettings, + parseSettings, }; // initially load settings diff --git a/src/node/utils/SettingsTree.ts b/src/node/utils/SettingsTree.ts index c505f2ebb..e1f3057c0 100644 --- a/src/node/utils/SettingsTree.ts +++ b/src/node/utils/SettingsTree.ts @@ -1,112 +1,118 @@ -import {MapArrayType} from "../types/MapType"; +import { MapArrayType } from "../types/MapType"; export class SettingsTree { - private children: Map; - constructor() { - this.children = new Map(); - } + private children: Map; + constructor() { + this.children = new Map(); + } - public addChild(key: string, value: string) { - this.children.set(key, new SettingsNode(key, value)); - } + public addChild(key: string, value: string) { + this.children.set(key, new SettingsNode(key, value)); + } - public removeChild(key: string) { - this.children.delete(key); - } + public removeChild(key: string) { + this.children.delete(key); + } - public getChild(key: string) { - return this.children.get(key); - } + public getChild(key: string) { + return this.children.get(key); + } - public hasChild(key: string) { - return this.children.has(key); - } + public hasChild(key: string) { + return this.children.has(key); + } } - export class SettingsNode { - private readonly key: string; - private value: string | number | boolean | null | undefined; - private children: MapArrayType; + private readonly key: string; + private value: string | number | boolean | null | undefined; + private children: MapArrayType; - constructor(key: string, value?: string | number | boolean | null | undefined) { - this.key = key; - this.value = value; - this.children = {} - } + constructor( + key: string, + value?: string | number | boolean | null | undefined, + ) { + this.key = key; + this.value = value; + this.children = {}; + } - public addChild(path: string[], value: string) { - let currentNode:SettingsNode = this; - for (let i = 0; i < path.length; i++) { - const key = path[i]; - /* + public addChild(path: string[], value: string) { + let currentNode: SettingsNode = this; + for (let i = 0; i < path.length; i++) { + const key = path[i]; + /* Skip the current node if the key is the same as the current node's key */ - if (key === this.key ) { - continue - } - /* + if (key === this.key) { + continue; + } + /* If the current node does not have a child with the key, create a new node with the key */ - if (!currentNode.hasChild(key)) { - currentNode = currentNode.children[key] = new SettingsNode(key, this.coerceValue(value)); - } else { - /* + if (!currentNode.hasChild(key)) { + currentNode = currentNode.children[key] = new SettingsNode( + key, + this.coerceValue(value), + ); + } else { + /* Else move to the child node */ - currentNode = currentNode.getChild(key); - } - } - } + currentNode = currentNode.getChild(key); + } + } + } + public collectFromLeafsUpwards() { + let collected: MapArrayType = {}; + for (const key in this.children) { + const child = this.children[key]; + if (child.hasChildren()) { + collected[key] = child.collectFromLeafsUpwards(); + } else { + collected[key] = child.value; + } + } + return collected; + } - public collectFromLeafsUpwards() { - let collected:MapArrayType = {}; - for (const key in this.children) { - const child = this.children[key]; - if (child.hasChildren()) { - collected[key] = child.collectFromLeafsUpwards(); - } else { - collected[key] = child.value; - } - } - return collected; - } + coerceValue = (stringValue: string) => { + // cooked from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number + // @ts-ignore + const isNumeric = + !isNaN(stringValue) && + !isNaN(parseFloat(stringValue) && isFinite(stringValue)); - coerceValue = (stringValue: string) => { - // cooked from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number - // @ts-ignore - const isNumeric = !isNaN(stringValue) && !isNaN(parseFloat(stringValue) && isFinite(stringValue)); + if (isNumeric) { + // detected numeric string. Coerce to a number - if (isNumeric) { - // detected numeric string. Coerce to a number + return +stringValue; + } - return +stringValue; - } + switch (stringValue) { + case "true": + return true; + case "false": + return false; + case "undefined": + return undefined; + case "null": + return null; + default: + return stringValue; + } + }; - switch (stringValue) { - case 'true': - return true; - case 'false': - return false; - case 'undefined': - return undefined; - case 'null': - return null; - default: - return stringValue; - } - }; + public hasChildren() { + return Object.keys(this.children).length > 0; + } - public hasChildren() { - return Object.keys(this.children).length > 0; - } + public getChild(key: string) { + return this.children[key]; + } - public getChild(key: string) { - return this.children[key]; - } - - public hasChild(key: string) { - return this.children[key] !== undefined; - } + public hasChild(key: string) { + return this.children[key] !== undefined; + } } diff --git a/src/node/utils/Stream.ts b/src/node/utils/Stream.ts index 36fde1ac7..44c1035a7 100644 --- a/src/node/utils/Stream.ts +++ b/src/node/utils/Stream.ts @@ -1,139 +1,155 @@ -'use strict'; +"use strict"; /** * Wrapper around any iterable that adds convenience methods that standard JavaScript iterable * objects lack. */ class Stream { - private _iter - private _next: any - /** - * @returns {Stream} A Stream that yields values in the half-open range [start, end). - */ - static range(start: number, end: number) { - return new Stream((function* () { for (let i = start; i < end; ++i) yield i; })()); - } + private _iter; + private _next: any; + /** + * @returns {Stream} A Stream that yields values in the half-open range [start, end). + */ + static range(start: number, end: number) { + return new Stream( + (function* () { + for (let i = start; i < end; ++i) yield i; + })(), + ); + } - /** - * @param {Iterable} values - Any iterable of values. - */ - constructor(values: Iterable) { - this._iter = values[Symbol.iterator](); - this._next = null; - } + /** + * @param {Iterable} values - Any iterable of values. + */ + constructor(values: Iterable) { + this._iter = values[Symbol.iterator](); + this._next = null; + } - /** - * Read values a chunk at a time from the underlying iterable. Once a full batch is read (or there - * aren't enough values to make a full batch), all of the batch's values are yielded before the - * next batch is read. - * - * This is useful for triggering groups of asynchronous tasks via Promises yielded from a - * synchronous generator. A for-await-of (or for-of with an await) loop consumes those Promises - * and automatically triggers the next batch of tasks when needed. For example: - * - * const resources = (function* () { - * for (let i = 0; i < 100; ++i) yield fetchResource(i); - * }).call(this); - * - * // Fetch 10 items at a time so that the fetch engine can bundle multiple requests into a - * // single query message. - * for await (const r of new Stream(resources).batch(10)) { - * processResource(r); - * } - * - * Chaining .buffer() after .batch() like stream.batch(n).buffer(m) will fetch in batches of n as - * needed to ensure that at least m are in flight at all times. - * - * Any Promise yielded by the underlying iterable has its rejection suppressed to prevent - * unhandled rejection errors while the Promise is sitting in the batch waiting to be yielded. It - * is assumed that the consumer of any yielded Promises will await the Promise (or call .catch() - * or .then()) to prevent the rejection from going unnoticed. If iteration is aborted early, any - * Promises read from the underlying iterable that have not yet been yielded will have their - * rejections un-suppressed to trigger unhandled rejection errors. - * - * @param {number} size - The number of values to read at a time. - * @returns {Stream} A new Stream that gets its values from this Stream. - */ - batch(size: number) { - return new Stream((function* () { - const b = []; - try { - // @ts-ignore - for (const v of this) { - Promise.resolve(v).catch(() => {}); // Suppress unhandled rejection errors. - b.push(v); - if (b.length < size) continue; - while (b.length) yield b.shift(); - } - while (b.length) yield b.shift(); - } finally { - for (const v of b) Promise.resolve(v).then(() => {}); // Un-suppress unhandled rejections. - } - }).call(this)); - } + /** + * Read values a chunk at a time from the underlying iterable. Once a full batch is read (or there + * aren't enough values to make a full batch), all of the batch's values are yielded before the + * next batch is read. + * + * This is useful for triggering groups of asynchronous tasks via Promises yielded from a + * synchronous generator. A for-await-of (or for-of with an await) loop consumes those Promises + * and automatically triggers the next batch of tasks when needed. For example: + * + * const resources = (function* () { + * for (let i = 0; i < 100; ++i) yield fetchResource(i); + * }).call(this); + * + * // Fetch 10 items at a time so that the fetch engine can bundle multiple requests into a + * // single query message. + * for await (const r of new Stream(resources).batch(10)) { + * processResource(r); + * } + * + * Chaining .buffer() after .batch() like stream.batch(n).buffer(m) will fetch in batches of n as + * needed to ensure that at least m are in flight at all times. + * + * Any Promise yielded by the underlying iterable has its rejection suppressed to prevent + * unhandled rejection errors while the Promise is sitting in the batch waiting to be yielded. It + * is assumed that the consumer of any yielded Promises will await the Promise (or call .catch() + * or .then()) to prevent the rejection from going unnoticed. If iteration is aborted early, any + * Promises read from the underlying iterable that have not yet been yielded will have their + * rejections un-suppressed to trigger unhandled rejection errors. + * + * @param {number} size - The number of values to read at a time. + * @returns {Stream} A new Stream that gets its values from this Stream. + */ + batch(size: number) { + return new Stream( + function* () { + const b = []; + try { + // @ts-ignore + for (const v of this) { + Promise.resolve(v).catch(() => {}); // Suppress unhandled rejection errors. + b.push(v); + if (b.length < size) continue; + while (b.length) yield b.shift(); + } + while (b.length) yield b.shift(); + } finally { + for (const v of b) Promise.resolve(v).then(() => {}); // Un-suppress unhandled rejections. + } + }.call(this), + ); + } - /** - * Pre-fetch a certain number of values from the underlying iterable before yielding the first - * value. Each time a value is yielded (consumed from the buffer), another value is read from the - * underlying iterable and added to the buffer. - * - * This is useful for maintaining a constant number of in-flight asynchronous tasks via Promises - * yielded from a synchronous generator. A for-await-of (or for-of with an await) loop should be - * used to control the scheduling of the next task. For example: - * - * const resources = (function* () { - * for (let i = 0; i < 100; ++i) yield fetchResource(i); - * }).call(this); - * - * // Fetching a resource is high latency, so keep multiple in flight at all times until done. - * for await (const r of new Stream(resources).buffer(10)) { - * processResource(r); - * } - * - * Chaining after .batch() like stream.batch(n).buffer(m) will fetch in batches of n as needed to - * ensure that at least m are in flight at all times. - * - * Any Promise yielded by the underlying iterable has its rejection suppressed to prevent - * unhandled rejection errors while the Promise is sitting in the batch waiting to be yielded. It - * is assumed that the consumer of any yielded Promises will await the Promise (or call .catch() - * or .then()) to prevent the rejection from going unnoticed. If iteration is aborted early, any - * Promises read from the underlying iterable that have not yet been yielded will have their - * rejections un-suppressed to trigger unhandled rejection errors. - * - * @param {number} capacity - The number of values to keep buffered. - * @returns {Stream} A new Stream that gets its values from this Stream. - */ - buffer(capacity: number) { - return new Stream((function* () { - const b = []; - try { - // @ts-ignore - for (const v of this) { - Promise.resolve(v).catch(() => {}); // Suppress unhandled rejection errors. - // Note: V8 has good Array push+shift optimization. - while (b.length >= capacity) yield b.shift(); - b.push(v); - } - while (b.length) yield b.shift(); - } finally { - for (const v of b) Promise.resolve(v).then(() => {}); // Un-suppress unhandled rejections. - } - }).call(this)); - } + /** + * Pre-fetch a certain number of values from the underlying iterable before yielding the first + * value. Each time a value is yielded (consumed from the buffer), another value is read from the + * underlying iterable and added to the buffer. + * + * This is useful for maintaining a constant number of in-flight asynchronous tasks via Promises + * yielded from a synchronous generator. A for-await-of (or for-of with an await) loop should be + * used to control the scheduling of the next task. For example: + * + * const resources = (function* () { + * for (let i = 0; i < 100; ++i) yield fetchResource(i); + * }).call(this); + * + * // Fetching a resource is high latency, so keep multiple in flight at all times until done. + * for await (const r of new Stream(resources).buffer(10)) { + * processResource(r); + * } + * + * Chaining after .batch() like stream.batch(n).buffer(m) will fetch in batches of n as needed to + * ensure that at least m are in flight at all times. + * + * Any Promise yielded by the underlying iterable has its rejection suppressed to prevent + * unhandled rejection errors while the Promise is sitting in the batch waiting to be yielded. It + * is assumed that the consumer of any yielded Promises will await the Promise (or call .catch() + * or .then()) to prevent the rejection from going unnoticed. If iteration is aborted early, any + * Promises read from the underlying iterable that have not yet been yielded will have their + * rejections un-suppressed to trigger unhandled rejection errors. + * + * @param {number} capacity - The number of values to keep buffered. + * @returns {Stream} A new Stream that gets its values from this Stream. + */ + buffer(capacity: number) { + return new Stream( + function* () { + const b = []; + try { + // @ts-ignore + for (const v of this) { + Promise.resolve(v).catch(() => {}); // Suppress unhandled rejection errors. + // Note: V8 has good Array push+shift optimization. + while (b.length >= capacity) yield b.shift(); + b.push(v); + } + while (b.length) yield b.shift(); + } finally { + for (const v of b) Promise.resolve(v).then(() => {}); // Un-suppress unhandled rejections. + } + }.call(this), + ); + } - /** - * Like Array.map(). - * - * @param {(v: any) => any} fn - Value transformation function. - * @returns {Stream} A new Stream that yields this Stream's values, transformed by `fn`. - */ - map(fn:Function) { return new Stream((function* () { // @ts-ignore - for (const v of this) yield fn(v); }).call(this)); } + /** + * Like Array.map(). + * + * @param {(v: any) => any} fn - Value transformation function. + * @returns {Stream} A new Stream that yields this Stream's values, transformed by `fn`. + */ + map(fn: Function) { + return new Stream( + function* () { + // @ts-ignore + for (const v of this) yield fn(v); + }.call(this), + ); + } - /** - * Implements the JavaScript iterable protocol. - */ - [Symbol.iterator]() { return this._iter; } + /** + * Implements the JavaScript iterable protocol. + */ + [Symbol.iterator]() { + return this._iter; + } } module.exports = Stream; diff --git a/src/node/utils/UpdateCheck.ts b/src/node/utils/UpdateCheck.ts index 534c5c640..3abc8b73b 100644 --- a/src/node/utils/UpdateCheck.ts +++ b/src/node/utils/UpdateCheck.ts @@ -1,63 +1,67 @@ -'use strict'; -const semver = require('semver'); -const settings = require('./Settings'); -import axios from 'axios'; +"use strict"; +const semver = require("semver"); +const settings = require("./Settings"); +import axios from "axios"; const headers = { - 'User-Agent': 'Etherpad/' + settings.getEpVersion(), -} + "User-Agent": "Etherpad/" + settings.getEpVersion(), +}; type Infos = { - latestVersion: string -} - + latestVersion: string; +}; const updateInterval = 60 * 60 * 1000; // 1 hour let infos: Infos; let lastLoadingTime: number | null = null; const loadEtherpadInformations = () => { - if (lastLoadingTime !== null && Date.now() - lastLoadingTime < updateInterval) { - return infos; - } + if ( + lastLoadingTime !== null && + Date.now() - lastLoadingTime < updateInterval + ) { + return infos; + } - return axios.get('https://static.etherpad.org/info.json', {headers: headers}) - .then(async (resp: any) => { - infos = await resp.data; - if (infos === undefined || infos === null) { - await Promise.reject("Could not retrieve current version") - return - } - - lastLoadingTime = Date.now(); - return infos; - }) - .catch(async (err: Error) => { - throw err; - }); -} + return axios + .get("https://static.etherpad.org/info.json", { headers: headers }) + .then(async (resp: any) => { + infos = await resp.data; + if (infos === undefined || infos === null) { + await Promise.reject("Could not retrieve current version"); + return; + } + lastLoadingTime = Date.now(); + return infos; + }) + .catch(async (err: Error) => { + throw err; + }); +}; exports.getLatestVersion = () => { - exports.needsUpdate().catch(); - return infos?.latestVersion; + exports.needsUpdate().catch(); + return infos?.latestVersion; }; exports.needsUpdate = async (cb?: Function) => { - try { - const info = await loadEtherpadInformations() - if (semver.gt(info!.latestVersion, settings.getEpVersion())) { - if (cb) return cb(true); - } - } catch (err) { - console.error(`Can not perform Etherpad update check: ${err}`); - if (cb) return cb(false); - } + try { + const info = await loadEtherpadInformations(); + if (semver.gt(info!.latestVersion, settings.getEpVersion())) { + if (cb) return cb(true); + } + } catch (err) { + console.error(`Can not perform Etherpad update check: ${err}`); + if (cb) return cb(false); + } }; exports.check = () => { - exports.needsUpdate((needsUpdate: boolean) => { - if (needsUpdate) { - console.warn(`Update available: Download the actual version ${infos.latestVersion}`); - } - }); + exports.needsUpdate((needsUpdate: boolean) => { + if (needsUpdate) { + console.warn( + `Update available: Download the actual version ${infos.latestVersion}`, + ); + } + }); }; diff --git a/src/node/utils/caching_middleware.ts b/src/node/utils/caching_middleware.ts index 74712871c..1316453c4 100644 --- a/src/node/utils/caching_middleware.ts +++ b/src/node/utils/caching_middleware.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /* * 2011 Peter 'Pita' Martischka (Primary Technology Ltd) @@ -16,14 +16,14 @@ * limitations under the License. */ -import {Buffer} from 'node:buffer' -import fs from 'fs'; +import { Buffer } from "node:buffer"; +import fs from "fs"; const fsp = fs.promises; -import path from 'path'; -import zlib from 'zlib'; -const settings = require('./Settings'); -const existsSync = require('./path_exists'); -import util from 'util'; +import path from "path"; +import zlib from "zlib"; +const settings = require("./Settings"); +const existsSync = require("./path_exists"); +import util from "util"; /* * The crypto module can be absent on reduced node installations. @@ -36,44 +36,44 @@ import util from 'util'; * */ +import _crypto from "crypto"; -import _crypto from 'crypto'; - - -let CACHE_DIR: string|undefined = path.join(settings.root, 'var/'); +let CACHE_DIR: string | undefined = path.join(settings.root, "var/"); CACHE_DIR = existsSync(CACHE_DIR) ? CACHE_DIR : undefined; type Headers = { - [id: string]: string -} + [id: string]: string; +}; type ResponseCache = { - [id: string]: { - statusCode: number - headers: Headers - } -} + [id: string]: { + statusCode: number; + headers: Headers; + }; +}; const responseCache: ResponseCache = {}; const djb2Hash = (data: string) => { - const chars = data.split('').map((str) => str.charCodeAt(0)); - return `${chars.reduce((prev, curr) => ((prev << 5) + prev) + curr, 5381)}`; + const chars = data.split("").map((str) => str.charCodeAt(0)); + return `${chars.reduce((prev, curr) => (prev << 5) + prev + curr, 5381)}`; }; -const generateCacheKeyWithSha256 = - (path: string) => _crypto.createHash('sha256').update(path).digest('hex'); +const generateCacheKeyWithSha256 = (path: string) => + _crypto.createHash("sha256").update(path).digest("hex"); -const generateCacheKeyWithDjb2 = - (path: string) => Buffer.from(djb2Hash(path)).toString('hex'); +const generateCacheKeyWithDjb2 = (path: string) => + Buffer.from(djb2Hash(path)).toString("hex"); -let generateCacheKey: (path: string)=>string; +let generateCacheKey: (path: string) => string; if (_crypto) { - generateCacheKey = generateCacheKeyWithSha256; + generateCacheKey = generateCacheKeyWithSha256; } else { - generateCacheKey = generateCacheKeyWithDjb2; - console.warn('No crypto support in this nodejs runtime. Djb2 (weaker) will be used.'); + generateCacheKey = generateCacheKeyWithDjb2; + console.warn( + "No crypto support in this nodejs runtime. Djb2 (weaker) will be used.", + ); } // MIMIC https://github.com/microsoft/TypeScript/commit/9677b0641cc5ba7d8b701b4f892ed7e54ceaee9a - END @@ -85,127 +85,143 @@ if (_crypto) { */ export default class CachingMiddleware { - handle(req: any, res: any, next: any) { - this._handle(req, res, next).catch((err) => next(err || new Error(err))); - } + handle(req: any, res: any, next: any) { + this._handle(req, res, next).catch((err) => next(err || new Error(err))); + } - async _handle(req: any, res: any, next: any) { - if (!(req.method === 'GET' || req.method === 'HEAD') || !CACHE_DIR) { - return next(undefined, req, res); - } + async _handle(req: any, res: any, next: any) { + if (!(req.method === "GET" || req.method === "HEAD") || !CACHE_DIR) { + return next(undefined, req, res); + } - const oldReq:ResponseCache = {}; - const oldRes:ResponseCache = {}; + const oldReq: ResponseCache = {}; + const oldRes: ResponseCache = {}; - const supportsGzip = - (req.get('Accept-Encoding') || '').indexOf('gzip') !== -1; + const supportsGzip = + (req.get("Accept-Encoding") || "").indexOf("gzip") !== -1; - const url = new URL(req.url, 'http://localhost'); - const cacheKey = generateCacheKey(url.pathname + url.search); + const url = new URL(req.url, "http://localhost"); + const cacheKey = generateCacheKey(url.pathname + url.search); - const stats = await fsp.stat(`${CACHE_DIR}minified_${cacheKey}`).catch(() => {}); - const modifiedSince = - req.headers['if-modified-since'] && new Date(req.headers['if-modified-since']); - if (stats != null && stats.mtime && responseCache[cacheKey]) { - req.headers['if-modified-since'] = stats.mtime.toUTCString(); - } else { - delete req.headers['if-modified-since']; - } + const stats = await fsp + .stat(`${CACHE_DIR}minified_${cacheKey}`) + .catch(() => {}); + const modifiedSince = + req.headers["if-modified-since"] && + new Date(req.headers["if-modified-since"]); + if (stats != null && stats.mtime && responseCache[cacheKey]) { + req.headers["if-modified-since"] = stats.mtime.toUTCString(); + } else { + delete req.headers["if-modified-since"]; + } - // Always issue get to downstream. - oldReq.method = req.method; - req.method = 'GET'; + // Always issue get to downstream. + oldReq.method = req.method; + req.method = "GET"; - // This handles read/write synchronization as well as its predecessor, - // which is to say, not at all. - // TODO: Implement locking on write or ditch caching of gzip and use - // existing middlewares. - const respond = () => { - req.method = oldReq.method || req.method; - res.write = oldRes.write || res.write; - res.end = oldRes.end || res.end; + // This handles read/write synchronization as well as its predecessor, + // which is to say, not at all. + // TODO: Implement locking on write or ditch caching of gzip and use + // existing middlewares. + const respond = () => { + req.method = oldReq.method || req.method; + res.write = oldRes.write || res.write; + res.end = oldRes.end || res.end; - const headers: Headers = {}; - Object.assign(headers, (responseCache[cacheKey].headers || {})); - const statusCode = responseCache[cacheKey].statusCode; + const headers: Headers = {}; + Object.assign(headers, responseCache[cacheKey].headers || {}); + const statusCode = responseCache[cacheKey].statusCode; - let pathStr = `${CACHE_DIR}minified_${cacheKey}`; - if (supportsGzip && /application\/javascript/.test(headers['content-type'])) { - pathStr += '.gz'; - headers['content-encoding'] = 'gzip'; - } + let pathStr = `${CACHE_DIR}minified_${cacheKey}`; + if ( + supportsGzip && + /application\/javascript/.test(headers["content-type"]) + ) { + pathStr += ".gz"; + headers["content-encoding"] = "gzip"; + } - const lastModified = headers['last-modified'] && new Date(headers['last-modified']); + const lastModified = + headers["last-modified"] && new Date(headers["last-modified"]); - if (statusCode === 200 && lastModified <= modifiedSince) { - res.writeHead(304, headers); - res.end(); - } else if (req.method === 'GET') { - const readStream = fs.createReadStream(pathStr); - res.writeHead(statusCode, headers); - readStream.pipe(res); - } else { - res.writeHead(statusCode, headers); - res.end(); - } - }; + if (statusCode === 200 && lastModified <= modifiedSince) { + res.writeHead(304, headers); + res.end(); + } else if (req.method === "GET") { + const readStream = fs.createReadStream(pathStr); + res.writeHead(statusCode, headers); + readStream.pipe(res); + } else { + res.writeHead(statusCode, headers); + res.end(); + } + }; - const expirationDate = new Date(((responseCache[cacheKey] || {}).headers || {}).expires); - if (expirationDate > new Date()) { - // Our cached version is still valid. - return respond(); - } + const expirationDate = new Date( + ((responseCache[cacheKey] || {}).headers || {}).expires, + ); + if (expirationDate > new Date()) { + // Our cached version is still valid. + return respond(); + } - const _headers:Headers = {}; - oldRes.setHeader = res.setHeader; - res.setHeader = (key: string, value: string) => { - // Don't set cookies, see issue #707 - if (key.toLowerCase() === 'set-cookie') return; + const _headers: Headers = {}; + oldRes.setHeader = res.setHeader; + res.setHeader = (key: string, value: string) => { + // Don't set cookies, see issue #707 + if (key.toLowerCase() === "set-cookie") return; - _headers[key.toLowerCase()] = value; - // @ts-ignore - oldRes.setHeader.call(res, key, value); - }; + _headers[key.toLowerCase()] = value; + // @ts-ignore + oldRes.setHeader.call(res, key, value); + }; - oldRes.writeHead = res.writeHead; - res.writeHead = (status: number, headers: Headers) => { - res.writeHead = oldRes.writeHead; - if (status === 200) { - // Update cache - let buffer = ''; + oldRes.writeHead = res.writeHead; + res.writeHead = (status: number, headers: Headers) => { + res.writeHead = oldRes.writeHead; + if (status === 200) { + // Update cache + let buffer = ""; - Object.keys(headers || {}).forEach((key) => { - res.setHeader(key, headers[key]); - }); - headers = _headers; + Object.keys(headers || {}).forEach((key) => { + res.setHeader(key, headers[key]); + }); + headers = _headers; - oldRes.write = res.write; - oldRes.end = res.end; - res.write = (data: number, encoding: number) => { - buffer += data.toString(encoding); - }; - res.end = async (data: number, encoding: number) => { - await Promise.all([ - fsp.writeFile(`${CACHE_DIR}minified_${cacheKey}`, buffer).catch(() => {}), - util.promisify(zlib.gzip)(buffer) - // @ts-ignore - .then((content: string) => fsp.writeFile(`${CACHE_DIR}minified_${cacheKey}.gz`, content)) - .catch(() => {}), - ]); - responseCache[cacheKey] = {statusCode: status, headers}; - respond(); - }; - } else if (status === 304) { - // Nothing new changed from the cached version. - oldRes.write = res.write; - oldRes.end = res.end; - res.write = (data: number, encoding: number) => {}; - res.end = (data: number, encoding: number) => { respond(); }; - } else { - res.writeHead(status, headers); - } - }; + oldRes.write = res.write; + oldRes.end = res.end; + res.write = (data: number, encoding: number) => { + buffer += data.toString(encoding); + }; + res.end = async (data: number, encoding: number) => { + await Promise.all([ + fsp + .writeFile(`${CACHE_DIR}minified_${cacheKey}`, buffer) + .catch(() => {}), + util + .promisify(zlib.gzip)(buffer) + // @ts-ignore + .then((content: string) => + fsp.writeFile(`${CACHE_DIR}minified_${cacheKey}.gz`, content), + ) + .catch(() => {}), + ]); + responseCache[cacheKey] = { statusCode: status, headers }; + respond(); + }; + } else if (status === 304) { + // Nothing new changed from the cached version. + oldRes.write = res.write; + oldRes.end = res.end; + res.write = (data: number, encoding: number) => {}; + res.end = (data: number, encoding: number) => { + respond(); + }; + } else { + res.writeHead(status, headers); + } + }; - next(undefined, req, res); - } -}; + next(undefined, req, res); + } +} diff --git a/src/node/utils/checkValidRev.ts b/src/node/utils/checkValidRev.ts index 5367ddf99..b38d30ee5 100644 --- a/src/node/utils/checkValidRev.ts +++ b/src/node/utils/checkValidRev.ts @@ -1,34 +1,35 @@ -'use strict'; +"use strict"; -const CustomError = require('../utils/customError'); +const CustomError = require("../utils/customError"); // checks if a rev is a legal number // pre-condition is that `rev` is not undefined -const checkValidRev = (rev: number|string) => { - if (typeof rev !== 'number') { - rev = parseInt(rev, 10); - } +const checkValidRev = (rev: number | string) => { + if (typeof rev !== "number") { + rev = parseInt(rev, 10); + } - // check if rev is a number - if (isNaN(rev)) { - throw new CustomError('rev is not a number', 'apierror'); - } + // check if rev is a number + if (isNaN(rev)) { + throw new CustomError("rev is not a number", "apierror"); + } - // ensure this is not a negative number - if (rev < 0) { - throw new CustomError('rev is not a negative number', 'apierror'); - } + // ensure this is not a negative number + if (rev < 0) { + throw new CustomError("rev is not a negative number", "apierror"); + } - // ensure this is not a float value - if (!isInt(rev)) { - throw new CustomError('rev is a float value', 'apierror'); - } + // ensure this is not a float value + if (!isInt(rev)) { + throw new CustomError("rev is a float value", "apierror"); + } - return rev; + return rev; }; // checks if a number is an int -const isInt = (value:number) => (parseFloat(String(value)) === parseInt(String(value), 10)) && !isNaN(value); +const isInt = (value: number) => + parseFloat(String(value)) === parseInt(String(value), 10) && !isNaN(value); exports.isInt = isInt; exports.checkValidRev = checkValidRev; diff --git a/src/node/utils/customError.ts b/src/node/utils/customError.ts index c58360269..b6b97976b 100644 --- a/src/node/utils/customError.ts +++ b/src/node/utils/customError.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * CustomError * @@ -8,17 +8,17 @@ * @extends {Error} */ class CustomError extends Error { - /** - * Creates an instance of CustomError. - * @param {string} message - * @param {string} [name='Error'] a custom name for the error object - * @memberof CustomError - */ - constructor(message:string, name: string = 'Error') { - super(message); - this.name = name; - Error.captureStackTrace(this, this.constructor); - } + /** + * Creates an instance of CustomError. + * @param {string} message + * @param {string} [name='Error'] a custom name for the error object + * @memberof CustomError + */ + constructor(message: string, name: string = "Error") { + super(message); + this.name = name; + Error.captureStackTrace(this, this.constructor); + } } module.exports = CustomError; diff --git a/src/node/utils/padDiff.ts b/src/node/utils/padDiff.ts index d731ebbe4..1e7691972 100644 --- a/src/node/utils/padDiff.ts +++ b/src/node/utils/padDiff.ts @@ -1,458 +1,516 @@ -'use strict'; +"use strict"; -import {PadAuthor, PadType} from "../types/PadType"; -import {MapArrayType} from "../types/MapType"; - -const AttributeMap = require('../../static/js/AttributeMap'); -const Changeset = require('../../static/js/Changeset'); -const attributes = require('../../static/js/attributes'); -const exportHtml = require('./ExportHtml'); +import { PadAuthor, PadType } from "../types/PadType"; +import { MapArrayType } from "../types/MapType"; +const AttributeMap = require("../../static/js/AttributeMap"); +const Changeset = require("../../static/js/Changeset"); +const attributes = require("../../static/js/attributes"); +const exportHtml = require("./ExportHtml"); class PadDiff { - private readonly _pad: PadType; - private readonly _fromRev: string; - private readonly _toRev: string; - private _html: any; - public _authors: any[]; - private self: PadDiff | undefined - constructor(pad: PadType, fromRev:string, toRev:string) { - // check parameters - if (!pad || !pad.id || !pad.atext || !pad.pool) { - throw new Error('Invalid pad'); - } - - const range = pad.getValidRevisionRange(fromRev, toRev); - if (!range) throw new Error(`Invalid revision range. startRev: ${fromRev} endRev: ${toRev}`); - - this._pad = pad; - this._fromRev = range.startRev; - this._toRev = range.endRev; - this._html = null; - this._authors = []; - } - _isClearAuthorship(changeset: any){ - // unpack - const unpacked = Changeset.unpack(changeset); - - // check if there is nothing in the charBank - if (unpacked.charBank !== '') { - return false; - } - - // check if oldLength == newLength - if (unpacked.oldLen !== unpacked.newLen) { - return false; - } - - const [clearOperator, anotherOp] = Changeset.deserializeOps(unpacked.ops); - - // check if there is only one operator - if (anotherOp != null) return false; - - // check if this operator doesn't change text - if (clearOperator.opcode !== '=') { - return false; - } - - // check that this operator applys to the complete text - // if the text ends with a new line, its exactly one character less, else it has the same length - if (clearOperator.chars !== unpacked.oldLen - 1 && clearOperator.chars !== unpacked.oldLen) { - return false; - } - - const [appliedAttribute, anotherAttribute] = - attributes.attribsFromString(clearOperator.attribs, this._pad.pool); - - // Check that the operation has exactly one attribute. - if (appliedAttribute == null || anotherAttribute != null) return false; - - // check if the applied attribute is an anonymous author attribute - if (appliedAttribute[0] !== 'author' || appliedAttribute[1] !== '') { - return false; - } - - return true; - } - async _createClearAuthorship(rev: any){ - const atext = await this._pad.getInternalRevisionAText(rev); - - // build clearAuthorship changeset - const builder = Changeset.builder(atext.text.length); - builder.keepText(atext.text, [['author', '']], this._pad.pool); - const changeset = builder.toString(); - - return changeset; - } - - async _createClearStartAtext(rev: any){ - // get the atext of this revision - const atext = await this._pad.getInternalRevisionAText(rev); - - // create the clearAuthorship changeset - const changeset = await this._createClearAuthorship(rev); - - // apply the clearAuthorship changeset - const newAText = Changeset.applyToAText(changeset, atext, this._pad.pool); - - return newAText; - } - async _getChangesetsInBulk(startRev: any, count: any) { - // find out which revisions we need - const revisions = []; - for (let i = startRev; i < (startRev + count) && i <= this._pad.head; i++) { - revisions.push(i); - } - - // get all needed revisions (in parallel) - const changesets:any[] = []; - const authors: any[] = []; - await Promise.all(revisions.map((rev) => this._pad.getRevision(rev).then((revision) => { - const arrayNum = rev - startRev; - changesets[arrayNum] = revision.changeset; - authors[arrayNum] = revision.meta.author; - }))); - - return {changesets, authors}; - } - _addAuthors(authors: PadAuthor[]){ - this.self = this; - - // add to array if not in the array - authors.forEach((author) => { - if (this.self!._authors.indexOf(author) === -1) { - this.self!._authors.push(author); - } - }); - } - async _createDiffAtext(){ - const bulkSize = 100; - - // get the cleaned startAText - let atext = await this._createClearStartAtext(this._fromRev); - - let superChangeset = null; - - for (let rev = this._fromRev + 1; rev <= this._toRev; rev += bulkSize) { - // get the bulk - const {changesets, authors} = await this._getChangesetsInBulk(rev, bulkSize); - - const addedAuthors = []; - - // run through all changesets - for (let i = 0; i < changesets.length && (rev + i) <= this._toRev; ++i) { - let changeset = changesets[i]; - - // skip clearAuthorship Changesets - if (this._isClearAuthorship(changeset)) { - continue; - } - - changeset = this._extendChangesetWithAuthor(changeset, authors[i], this._pad.pool); - - // add this author to the authorarray - addedAuthors.push(authors[i]); - - // compose it with the superChangset - if (superChangeset == null) { - superChangeset = changeset; - } else { - superChangeset = Changeset.compose(superChangeset, changeset, this._pad.pool); - } - } - - // add the authors to the PadDiff authorArray - this._addAuthors(addedAuthors); - } - - // if there are only clearAuthorship changesets, we don't get a superChangeset, - // so we can skip this step - if (superChangeset) { - const deletionChangeset = this._createDeletionChangeset(superChangeset, atext, this._pad.pool); - - // apply the superChangeset, which includes all addings - atext = Changeset.applyToAText(superChangeset, atext, this._pad.pool); - - // apply the deletionChangeset, which adds a deletions - atext = Changeset.applyToAText(deletionChangeset, atext, this._pad.pool); - } - - return atext; - } - async getHtml(){ - // cache the html - if (this._html != null) { - return this._html; - } - - // get the diff atext - const atext = await this._createDiffAtext(); - - // get the authorColor table - const authorColors = await this._pad.getAllAuthorColors(); - - // convert the atext to html - this._html = await exportHtml.getHTMLFromAtext(this._pad, atext, authorColors); - - return this._html; - } - - async getAuthors() { - // check if html was already produced, if not produce it, this generates - // the author array at the same time - if (this._html == null) { - await this.getHtml(); - } - - return this.self!._authors; - } - - _extendChangesetWithAuthor(changeset: any, author: any, apool: any){ - // unpack - const unpacked = Changeset.unpack(changeset); - - const assem = Changeset.opAssembler(); - - // create deleted attribs - const authorAttrib = apool.putAttrib(['author', author || '']); - const deletedAttrib = apool.putAttrib(['removed', true]); - const attribs = `*${Changeset.numToString(authorAttrib)}*${Changeset.numToString(deletedAttrib)}`; - - for (const operator of Changeset.deserializeOps(unpacked.ops)) { - if (operator.opcode === '-') { - // this is a delete operator, extend it with the author - operator.attribs = attribs; - } else if (operator.opcode === '=' && operator.attribs) { - // this is operator changes only attributes, let's mark which author did that - operator.attribs += `*${Changeset.numToString(authorAttrib)}`; - } - - // append the new operator to our assembler - assem.append(operator); - } - - // return the modified changeset - return Changeset.pack(unpacked.oldLen, unpacked.newLen, assem.toString(), unpacked.charBank); - } - _createDeletionChangeset(cs: any, startAText: any, apool: any){ - const lines = Changeset.splitTextLines(startAText.text); - const alines = Changeset.splitAttributionLines(startAText.attribs, startAText.text); - - // lines and alines are what the exports is meant to apply to. - // They may be arrays or objects with .get(i) and .length methods. - // They include final newlines on lines. - - const linesGet = (idx: number) => { - if (lines.get) { - return lines.get(idx); - } else { - return lines[idx]; - } - }; - - const aLinesGet = (idx: number) => { - if (alines.get) { - return alines.get(idx); - } else { - return alines[idx]; - } - }; - - let curLine = 0; - let curChar = 0; - let curLineOps: { next: () => any; } | null = null; - let curLineOpsNext: { done: any; value: any; } | null = null; - let curLineOpsLine: number; - let curLineNextOp = new Changeset.Op('+'); - - const unpacked = Changeset.unpack(cs); - const builder = Changeset.builder(unpacked.newLen); - - const consumeAttribRuns = (numChars: number, func: Function /* (len, attribs, endsLine)*/) => { - if (!curLineOps || curLineOpsLine !== curLine) { - curLineOps = Changeset.deserializeOps(aLinesGet(curLine)); - curLineOpsNext = curLineOps!.next(); - curLineOpsLine = curLine; - let indexIntoLine = 0; - while (!curLineOpsNext!.done) { - curLineNextOp = curLineOpsNext!.value; - curLineOpsNext = curLineOps!.next(); - if (indexIntoLine + curLineNextOp.chars >= curChar) { - curLineNextOp.chars -= (curChar - indexIntoLine); - break; - } - indexIntoLine += curLineNextOp.chars; - } - } - - while (numChars > 0) { - if (!curLineNextOp.chars && curLineOpsNext!.done) { - curLine++; - curChar = 0; - curLineOpsLine = curLine; - curLineNextOp.chars = 0; - curLineOps = Changeset.deserializeOps(aLinesGet(curLine)); - curLineOpsNext = curLineOps!.next(); - } - - if (!curLineNextOp.chars) { - if (curLineOpsNext!.done) { - curLineNextOp = new Changeset.Op(); - } else { - curLineNextOp = curLineOpsNext!.value; - curLineOpsNext = curLineOps!.next(); - } - } - - const charsToUse = Math.min(numChars, curLineNextOp.chars); - - func(charsToUse, curLineNextOp.attribs, - charsToUse === curLineNextOp.chars && curLineNextOp.lines > 0); - numChars -= charsToUse; - curLineNextOp.chars -= charsToUse; - curChar += charsToUse; - } - - if (!curLineNextOp.chars && curLineOpsNext!.done) { - curLine++; - curChar = 0; - } - }; - - const skip = (N:number, L:number) => { - if (L) { - curLine += L; - curChar = 0; - } else if (curLineOps && curLineOpsLine === curLine) { - consumeAttribRuns(N, () => {}); - } else { - curChar += N; - } - }; - - const nextText = (numChars: number) => { - let len = 0; - const assem = Changeset.stringAssembler(); - const firstString = linesGet(curLine).substring(curChar); - len += firstString.length; - assem.append(firstString); - - let lineNum = curLine + 1; - - while (len < numChars) { - const nextString = linesGet(lineNum); - len += nextString.length; - assem.append(nextString); - lineNum++; - } - - return assem.toString().substring(0, numChars); - }; - - const cachedStrFunc = (func:Function) => { - const cache:MapArrayType = {}; - - return (s:string) => { - if (!cache[s]) { - cache[s] = func(s); - } - return cache[s]; - }; - }; - - for (const csOp of Changeset.deserializeOps(unpacked.ops)) { - if (csOp.opcode === '=') { - const textBank = nextText(csOp.chars); - - // decide if this equal operator is an attribution change or not. - // We can see this by checkinf if attribs is set. - // If the text this operator applies to is only a star, - // than this is a false positive and should be ignored - if (csOp.attribs && textBank !== '*') { - const attribs = AttributeMap.fromString(csOp.attribs, apool); - const undoBackToAttribs = cachedStrFunc((oldAttribsStr: string) => { - const oldAttribs = AttributeMap.fromString(oldAttribsStr, apool); - const backAttribs = new AttributeMap(apool) - .set('author', '') - .set('removed', 'true'); - for (const [key, value] of attribs) { - const oldValue = oldAttribs.get(key); - if (oldValue !== value) backAttribs.set(key, oldValue); - } - // TODO: backAttribs does not restore removed attributes (it is missing attributes that - // are in oldAttribs but not in attribs). I don't know if that is intentional. - return backAttribs.toString(); - }); - - let textLeftToProcess = textBank; - - while (textLeftToProcess.length > 0) { - // process till the next line break or process only one line break - let lengthToProcess = textLeftToProcess.indexOf('\n'); - let lineBreak = false; - switch (lengthToProcess) { - case -1: - lengthToProcess = textLeftToProcess.length; - break; - case 0: - lineBreak = true; - lengthToProcess = 1; - break; - } - - // get the text we want to procceed in this step - const processText = textLeftToProcess.substr(0, lengthToProcess); - - textLeftToProcess = textLeftToProcess.substr(lengthToProcess); - - if (lineBreak) { - builder.keep(1, 1); // just skip linebreaks, don't do a insert + keep for a linebreak - - // consume the attributes of this linebreak - consumeAttribRuns(1, () => {}); - } else { - // add the old text via an insert, but add a deletion attribute + - // the author attribute of the author who deleted it - let textBankIndex = 0; - consumeAttribRuns(lengthToProcess, (len: number, attribs:string, endsLine: string) => { - // get the old attributes back - const oldAttribs = undoBackToAttribs(attribs); - - builder.insert(processText.substr(textBankIndex, len), oldAttribs); - textBankIndex += len; - }); - - builder.keep(lengthToProcess, 0); - } - } - } else { - skip(csOp.chars, csOp.lines); - builder.keep(csOp.chars, csOp.lines); - } - } else if (csOp.opcode === '+') { - builder.keep(csOp.chars, csOp.lines); - } else if (csOp.opcode === '-') { - const textBank = nextText(csOp.chars); - let textBankIndex = 0; - - consumeAttribRuns(csOp.chars, (len: number, attribs: string[], endsLine: string) => { - builder.insert(textBank.substr(textBankIndex, len), attribs + csOp.attribs); - textBankIndex += len; - }); - } - } - - return Changeset.checkRep(builder.toString()); - } - + private readonly _pad: PadType; + private readonly _fromRev: string; + private readonly _toRev: string; + private _html: any; + public _authors: any[]; + private self: PadDiff | undefined; + constructor(pad: PadType, fromRev: string, toRev: string) { + // check parameters + if (!pad || !pad.id || !pad.atext || !pad.pool) { + throw new Error("Invalid pad"); + } + + const range = pad.getValidRevisionRange(fromRev, toRev); + if (!range) + throw new Error( + `Invalid revision range. startRev: ${fromRev} endRev: ${toRev}`, + ); + + this._pad = pad; + this._fromRev = range.startRev; + this._toRev = range.endRev; + this._html = null; + this._authors = []; + } + _isClearAuthorship(changeset: any) { + // unpack + const unpacked = Changeset.unpack(changeset); + + // check if there is nothing in the charBank + if (unpacked.charBank !== "") { + return false; + } + + // check if oldLength == newLength + if (unpacked.oldLen !== unpacked.newLen) { + return false; + } + + const [clearOperator, anotherOp] = Changeset.deserializeOps(unpacked.ops); + + // check if there is only one operator + if (anotherOp != null) return false; + + // check if this operator doesn't change text + if (clearOperator.opcode !== "=") { + return false; + } + + // check that this operator applys to the complete text + // if the text ends with a new line, its exactly one character less, else it has the same length + if ( + clearOperator.chars !== unpacked.oldLen - 1 && + clearOperator.chars !== unpacked.oldLen + ) { + return false; + } + + const [appliedAttribute, anotherAttribute] = attributes.attribsFromString( + clearOperator.attribs, + this._pad.pool, + ); + + // Check that the operation has exactly one attribute. + if (appliedAttribute == null || anotherAttribute != null) return false; + + // check if the applied attribute is an anonymous author attribute + if (appliedAttribute[0] !== "author" || appliedAttribute[1] !== "") { + return false; + } + + return true; + } + async _createClearAuthorship(rev: any) { + const atext = await this._pad.getInternalRevisionAText(rev); + + // build clearAuthorship changeset + const builder = Changeset.builder(atext.text.length); + builder.keepText(atext.text, [["author", ""]], this._pad.pool); + const changeset = builder.toString(); + + return changeset; + } + + async _createClearStartAtext(rev: any) { + // get the atext of this revision + const atext = await this._pad.getInternalRevisionAText(rev); + + // create the clearAuthorship changeset + const changeset = await this._createClearAuthorship(rev); + + // apply the clearAuthorship changeset + const newAText = Changeset.applyToAText(changeset, atext, this._pad.pool); + + return newAText; + } + async _getChangesetsInBulk(startRev: any, count: any) { + // find out which revisions we need + const revisions = []; + for (let i = startRev; i < startRev + count && i <= this._pad.head; i++) { + revisions.push(i); + } + + // get all needed revisions (in parallel) + const changesets: any[] = []; + const authors: any[] = []; + await Promise.all( + revisions.map((rev) => + this._pad.getRevision(rev).then((revision) => { + const arrayNum = rev - startRev; + changesets[arrayNum] = revision.changeset; + authors[arrayNum] = revision.meta.author; + }), + ), + ); + + return { changesets, authors }; + } + _addAuthors(authors: PadAuthor[]) { + this.self = this; + + // add to array if not in the array + authors.forEach((author) => { + if (this.self!._authors.indexOf(author) === -1) { + this.self!._authors.push(author); + } + }); + } + async _createDiffAtext() { + const bulkSize = 100; + + // get the cleaned startAText + let atext = await this._createClearStartAtext(this._fromRev); + + let superChangeset = null; + + for (let rev = this._fromRev + 1; rev <= this._toRev; rev += bulkSize) { + // get the bulk + const { changesets, authors } = await this._getChangesetsInBulk( + rev, + bulkSize, + ); + + const addedAuthors = []; + + // run through all changesets + for (let i = 0; i < changesets.length && rev + i <= this._toRev; ++i) { + let changeset = changesets[i]; + + // skip clearAuthorship Changesets + if (this._isClearAuthorship(changeset)) { + continue; + } + + changeset = this._extendChangesetWithAuthor( + changeset, + authors[i], + this._pad.pool, + ); + + // add this author to the authorarray + addedAuthors.push(authors[i]); + + // compose it with the superChangset + if (superChangeset == null) { + superChangeset = changeset; + } else { + superChangeset = Changeset.compose( + superChangeset, + changeset, + this._pad.pool, + ); + } + } + + // add the authors to the PadDiff authorArray + this._addAuthors(addedAuthors); + } + + // if there are only clearAuthorship changesets, we don't get a superChangeset, + // so we can skip this step + if (superChangeset) { + const deletionChangeset = this._createDeletionChangeset( + superChangeset, + atext, + this._pad.pool, + ); + + // apply the superChangeset, which includes all addings + atext = Changeset.applyToAText(superChangeset, atext, this._pad.pool); + + // apply the deletionChangeset, which adds a deletions + atext = Changeset.applyToAText(deletionChangeset, atext, this._pad.pool); + } + + return atext; + } + async getHtml() { + // cache the html + if (this._html != null) { + return this._html; + } + + // get the diff atext + const atext = await this._createDiffAtext(); + + // get the authorColor table + const authorColors = await this._pad.getAllAuthorColors(); + + // convert the atext to html + this._html = await exportHtml.getHTMLFromAtext( + this._pad, + atext, + authorColors, + ); + + return this._html; + } + + async getAuthors() { + // check if html was already produced, if not produce it, this generates + // the author array at the same time + if (this._html == null) { + await this.getHtml(); + } + + return this.self!._authors; + } + + _extendChangesetWithAuthor(changeset: any, author: any, apool: any) { + // unpack + const unpacked = Changeset.unpack(changeset); + + const assem = Changeset.opAssembler(); + + // create deleted attribs + const authorAttrib = apool.putAttrib(["author", author || ""]); + const deletedAttrib = apool.putAttrib(["removed", true]); + const attribs = `*${Changeset.numToString( + authorAttrib, + )}*${Changeset.numToString(deletedAttrib)}`; + + for (const operator of Changeset.deserializeOps(unpacked.ops)) { + if (operator.opcode === "-") { + // this is a delete operator, extend it with the author + operator.attribs = attribs; + } else if (operator.opcode === "=" && operator.attribs) { + // this is operator changes only attributes, let's mark which author did that + operator.attribs += `*${Changeset.numToString(authorAttrib)}`; + } + + // append the new operator to our assembler + assem.append(operator); + } + + // return the modified changeset + return Changeset.pack( + unpacked.oldLen, + unpacked.newLen, + assem.toString(), + unpacked.charBank, + ); + } + _createDeletionChangeset(cs: any, startAText: any, apool: any) { + const lines = Changeset.splitTextLines(startAText.text); + const alines = Changeset.splitAttributionLines( + startAText.attribs, + startAText.text, + ); + + // lines and alines are what the exports is meant to apply to. + // They may be arrays or objects with .get(i) and .length methods. + // They include final newlines on lines. + + const linesGet = (idx: number) => { + if (lines.get) { + return lines.get(idx); + } else { + return lines[idx]; + } + }; + + const aLinesGet = (idx: number) => { + if (alines.get) { + return alines.get(idx); + } else { + return alines[idx]; + } + }; + + let curLine = 0; + let curChar = 0; + let curLineOps: { next: () => any } | null = null; + let curLineOpsNext: { done: any; value: any } | null = null; + let curLineOpsLine: number; + let curLineNextOp = new Changeset.Op("+"); + + const unpacked = Changeset.unpack(cs); + const builder = Changeset.builder(unpacked.newLen); + + const consumeAttribRuns = ( + numChars: number, + func: Function /* (len, attribs, endsLine)*/, + ) => { + if (!curLineOps || curLineOpsLine !== curLine) { + curLineOps = Changeset.deserializeOps(aLinesGet(curLine)); + curLineOpsNext = curLineOps!.next(); + curLineOpsLine = curLine; + let indexIntoLine = 0; + while (!curLineOpsNext!.done) { + curLineNextOp = curLineOpsNext!.value; + curLineOpsNext = curLineOps!.next(); + if (indexIntoLine + curLineNextOp.chars >= curChar) { + curLineNextOp.chars -= curChar - indexIntoLine; + break; + } + indexIntoLine += curLineNextOp.chars; + } + } + + while (numChars > 0) { + if (!curLineNextOp.chars && curLineOpsNext!.done) { + curLine++; + curChar = 0; + curLineOpsLine = curLine; + curLineNextOp.chars = 0; + curLineOps = Changeset.deserializeOps(aLinesGet(curLine)); + curLineOpsNext = curLineOps!.next(); + } + + if (!curLineNextOp.chars) { + if (curLineOpsNext!.done) { + curLineNextOp = new Changeset.Op(); + } else { + curLineNextOp = curLineOpsNext!.value; + curLineOpsNext = curLineOps!.next(); + } + } + + const charsToUse = Math.min(numChars, curLineNextOp.chars); + + func( + charsToUse, + curLineNextOp.attribs, + charsToUse === curLineNextOp.chars && curLineNextOp.lines > 0, + ); + numChars -= charsToUse; + curLineNextOp.chars -= charsToUse; + curChar += charsToUse; + } + + if (!curLineNextOp.chars && curLineOpsNext!.done) { + curLine++; + curChar = 0; + } + }; + + const skip = (N: number, L: number) => { + if (L) { + curLine += L; + curChar = 0; + } else if (curLineOps && curLineOpsLine === curLine) { + consumeAttribRuns(N, () => {}); + } else { + curChar += N; + } + }; + + const nextText = (numChars: number) => { + let len = 0; + const assem = Changeset.stringAssembler(); + const firstString = linesGet(curLine).substring(curChar); + len += firstString.length; + assem.append(firstString); + + let lineNum = curLine + 1; + + while (len < numChars) { + const nextString = linesGet(lineNum); + len += nextString.length; + assem.append(nextString); + lineNum++; + } + + return assem.toString().substring(0, numChars); + }; + + const cachedStrFunc = (func: Function) => { + const cache: MapArrayType = {}; + + return (s: string) => { + if (!cache[s]) { + cache[s] = func(s); + } + return cache[s]; + }; + }; + + for (const csOp of Changeset.deserializeOps(unpacked.ops)) { + if (csOp.opcode === "=") { + const textBank = nextText(csOp.chars); + + // decide if this equal operator is an attribution change or not. + // We can see this by checkinf if attribs is set. + // If the text this operator applies to is only a star, + // than this is a false positive and should be ignored + if (csOp.attribs && textBank !== "*") { + const attribs = AttributeMap.fromString(csOp.attribs, apool); + const undoBackToAttribs = cachedStrFunc((oldAttribsStr: string) => { + const oldAttribs = AttributeMap.fromString(oldAttribsStr, apool); + const backAttribs = new AttributeMap(apool) + .set("author", "") + .set("removed", "true"); + for (const [key, value] of attribs) { + const oldValue = oldAttribs.get(key); + if (oldValue !== value) backAttribs.set(key, oldValue); + } + // TODO: backAttribs does not restore removed attributes (it is missing attributes that + // are in oldAttribs but not in attribs). I don't know if that is intentional. + return backAttribs.toString(); + }); + + let textLeftToProcess = textBank; + + while (textLeftToProcess.length > 0) { + // process till the next line break or process only one line break + let lengthToProcess = textLeftToProcess.indexOf("\n"); + let lineBreak = false; + switch (lengthToProcess) { + case -1: + lengthToProcess = textLeftToProcess.length; + break; + case 0: + lineBreak = true; + lengthToProcess = 1; + break; + } + + // get the text we want to procceed in this step + const processText = textLeftToProcess.substr(0, lengthToProcess); + + textLeftToProcess = textLeftToProcess.substr(lengthToProcess); + + if (lineBreak) { + builder.keep(1, 1); // just skip linebreaks, don't do a insert + keep for a linebreak + + // consume the attributes of this linebreak + consumeAttribRuns(1, () => {}); + } else { + // add the old text via an insert, but add a deletion attribute + + // the author attribute of the author who deleted it + let textBankIndex = 0; + consumeAttribRuns( + lengthToProcess, + (len: number, attribs: string, endsLine: string) => { + // get the old attributes back + const oldAttribs = undoBackToAttribs(attribs); + + builder.insert( + processText.substr(textBankIndex, len), + oldAttribs, + ); + textBankIndex += len; + }, + ); + + builder.keep(lengthToProcess, 0); + } + } + } else { + skip(csOp.chars, csOp.lines); + builder.keep(csOp.chars, csOp.lines); + } + } else if (csOp.opcode === "+") { + builder.keep(csOp.chars, csOp.lines); + } else if (csOp.opcode === "-") { + const textBank = nextText(csOp.chars); + let textBankIndex = 0; + + consumeAttribRuns( + csOp.chars, + (len: number, attribs: string[], endsLine: string) => { + builder.insert( + textBank.substr(textBankIndex, len), + attribs + csOp.attribs, + ); + textBankIndex += len; + }, + ); + } + } + + return Changeset.checkRep(builder.toString()); + } } - // this method is 80% like Changeset.inverse. I just changed so instead of reverting, // it adds deletions and attribute changes to the atext. -PadDiff.prototype._createDeletionChangeset = function (cs, startAText, apool) { - -}; +PadDiff.prototype._createDeletionChangeset = function ( + cs, + startAText, + apool, +) {}; // export the constructor module.exports = PadDiff; diff --git a/src/node/utils/path_exists.ts b/src/node/utils/path_exists.ts index 354cd3cc7..a942a949b 100644 --- a/src/node/utils/path_exists.ts +++ b/src/node/utils/path_exists.ts @@ -1,16 +1,16 @@ -'use strict'; -const fs = require('fs'); +"use strict"; +const fs = require("fs"); -const check = (path:string) => { - const existsSync = fs.statSync || fs.existsSync; +const check = (path: string) => { + const existsSync = fs.statSync || fs.existsSync; - let result; - try { - result = existsSync(path); - } catch (e) { - result = false; - } - return result; + let result; + try { + result = existsSync(path); + } catch (e) { + result = false; + } + return result; }; module.exports = check; diff --git a/src/node/utils/promises.ts b/src/node/utils/promises.ts index 701c5da89..f6e6c8cdb 100644 --- a/src/node/utils/promises.ts +++ b/src/node/utils/promises.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * Helpers to manipulate promises (like async but for promises). */ @@ -7,35 +7,42 @@ // `predicate`. Resolves to `undefined` if none of the Promises satisfy `predicate`, or if // `promises` is empty. If `predicate` is nullish, the truthiness of the resolved value is used as // the predicate. -exports.firstSatisfies = (promises: Promise[], predicate: null|Function) => { - if (predicate == null) { - predicate = (x: any) => x; - } +exports.firstSatisfies = ( + promises: Promise[], + predicate: null | Function, +) => { + if (predicate == null) { + predicate = (x: any) => x; + } - // Transform each original Promise into a Promise that never resolves if the original resolved - // value does not satisfy `predicate`. These transformed Promises will be passed to Promise.race, - // yielding the first resolved value that satisfies `predicate`. - const newPromises = promises.map((p) => - new Promise((resolve, reject) => p.then((v) => predicate!(v) && resolve(v), reject))); + // Transform each original Promise into a Promise that never resolves if the original resolved + // value does not satisfy `predicate`. These transformed Promises will be passed to Promise.race, + // yielding the first resolved value that satisfies `predicate`. + const newPromises = promises.map( + (p) => + new Promise((resolve, reject) => + p.then((v) => predicate!(v) && resolve(v), reject), + ), + ); - // If `promises` is an empty array or if none of them resolve to a value that satisfies - // `predicate`, then `Promise.race(newPromises)` will never resolve. To handle that, add another - // Promise that resolves to `undefined` after all of the original Promises resolve. - // - // Note: If all of the original Promises simultaneously resolve to a value that satisfies - // `predicate` (perhaps they were already resolved when this function was called), then this - // Promise will resolve too, and with a value of `undefined`. There is no concern that this - // Promise will win the race and thus cause an erroneous `undefined` result. This is because - // a resolved Promise's `.then()` function is scheduled for execution -- not executed right away - // -- and ES guarantees in-order execution of the enqueued invocations. Each of the above - // transformed Promises has a `.then()` chain of length one, while the Promise added here has a - // `.then()` chain of length two or more (at least one `.then()` that is internal to - // `Promise.all()`, plus the `.then()` function added here). By the time the `.then()` function - // added here executes, all of the above transformed Promises will have already resolved and one - // will have been chosen as the winner. - newPromises.push(Promise.all(promises).then(() => {})); + // If `promises` is an empty array or if none of them resolve to a value that satisfies + // `predicate`, then `Promise.race(newPromises)` will never resolve. To handle that, add another + // Promise that resolves to `undefined` after all of the original Promises resolve. + // + // Note: If all of the original Promises simultaneously resolve to a value that satisfies + // `predicate` (perhaps they were already resolved when this function was called), then this + // Promise will resolve too, and with a value of `undefined`. There is no concern that this + // Promise will win the race and thus cause an erroneous `undefined` result. This is because + // a resolved Promise's `.then()` function is scheduled for execution -- not executed right away + // -- and ES guarantees in-order execution of the enqueued invocations. Each of the above + // transformed Promises has a `.then()` chain of length one, while the Promise added here has a + // `.then()` chain of length two or more (at least one `.then()` that is internal to + // `Promise.all()`, plus the `.then()` function added here). By the time the `.then()` function + // added here executes, all of the above transformed Promises will have already resolved and one + // will have been chosen as the winner. + newPromises.push(Promise.all(promises).then(() => {})); - return Promise.race(newPromises); + return Promise.race(newPromises); }; // Calls `promiseCreator(i)` a total number of `total` times, where `i` is 0 through `total - 1` (in @@ -44,17 +51,23 @@ exports.firstSatisfies = (promises: Promise[], predicate: null|Function) = // `total` is greater than `concurrency`, then `concurrency` Promises will be created right away, // and each remaining Promise will be created once one of the earlier Promises resolves.) This async // function resolves once all `total` Promises have resolved. -exports.timesLimit = async (total: number, concurrency: number, promiseCreator: Function) => { - if (total > 0 && concurrency <= 0) throw new RangeError('concurrency must be positive'); - let next = 0; - const addAnother = () => promiseCreator(next++).finally(() => { - if (next < total) return addAnother(); - }); - const promises = []; - for (let i = 0; i < concurrency && i < total; i++) { - promises.push(addAnother()); - } - await Promise.all(promises); +exports.timesLimit = async ( + total: number, + concurrency: number, + promiseCreator: Function, +) => { + if (total > 0 && concurrency <= 0) + throw new RangeError("concurrency must be positive"); + let next = 0; + const addAnother = () => + promiseCreator(next++).finally(() => { + if (next < total) return addAnother(); + }); + const promises = []; + for (let i = 0; i < concurrency && i < total; i++) { + promises.push(addAnother()); + } + await Promise.all(promises); }; /** @@ -62,17 +75,19 @@ exports.timesLimit = async (total: number, concurrency: number, promiseCreator: * properties. */ class Gate extends Promise { - // Coax `.then()` into returning an ordinary Promise, not a Gate. See - // https://stackoverflow.com/a/65669070 for the rationale. - static get [Symbol.species]() { return Promise; } + // Coax `.then()` into returning an ordinary Promise, not a Gate. See + // https://stackoverflow.com/a/65669070 for the rationale. + static get [Symbol.species]() { + return Promise; + } - constructor() { - // `this` is assigned when `super()` returns, not when it is called, so it is not acceptable to - // do the following because it will throw a ReferenceError when it dereferences `this`: - // super((resolve, reject) => Object.assign(this, {resolve, reject})); - let props: any; - super((resolve, reject) => props = {resolve, reject}); - Object.assign(this, props); - } + constructor() { + // `this` is assigned when `super()` returns, not when it is called, so it is not acceptable to + // do the following because it will throw a ReferenceError when it dereferences `this`: + // super((resolve, reject) => Object.assign(this, {resolve, reject})); + let props: any; + super((resolve, reject) => (props = { resolve, reject })); + Object.assign(this, props); + } } exports.Gate = Gate; diff --git a/src/node/utils/randomstring.ts b/src/node/utils/randomstring.ts index a86d28566..709662fb2 100644 --- a/src/node/utils/randomstring.ts +++ b/src/node/utils/randomstring.ts @@ -1,10 +1,11 @@ -'use strict'; +"use strict"; /** * Generates a random String with the given length. Is needed to generate the * Author, Group, readonly, session Ids */ -const cryptoMod = require('crypto'); +const cryptoMod = require("crypto"); -const randomString = (len: number) => cryptoMod.randomBytes(len).toString('hex'); +const randomString = (len: number) => + cryptoMod.randomBytes(len).toString("hex"); module.exports = randomString; diff --git a/src/node/utils/run_cmd.ts b/src/node/utils/run_cmd.ts index 463b0f076..c9ca8d105 100644 --- a/src/node/utils/run_cmd.ts +++ b/src/node/utils/run_cmd.ts @@ -1,35 +1,42 @@ -'use strict'; +"use strict"; -import {ErrorExtended, RunCMDOptions, RunCMDPromise} from "../types/RunCMDOptions"; -import {ChildProcess} from "node:child_process"; -import {PromiseWithStd} from "../types/PromiseWithStd"; -import {Readable} from "node:stream"; +import { + ErrorExtended, + RunCMDOptions, + RunCMDPromise, +} from "../types/RunCMDOptions"; +import { ChildProcess } from "node:child_process"; +import { PromiseWithStd } from "../types/PromiseWithStd"; +import { Readable } from "node:stream"; -const spawn = require('cross-spawn'); -const log4js = require('log4js'); -const path = require('path'); -const settings = require('./Settings'); +const spawn = require("cross-spawn"); +const log4js = require("log4js"); +const path = require("path"); +const settings = require("./Settings"); -const logger = log4js.getLogger('runCmd'); +const logger = log4js.getLogger("runCmd"); -const logLines = (readable: undefined | Readable | null, logLineFn: (arg0: (string | undefined)) => void) => { - readable!.setEncoding('utf8'); - // The process won't necessarily write full lines every time -- it might write a part of a line - // then write the rest of the line later. - let leftovers: string| undefined = ''; - readable!.on('data', (chunk) => { - const lines = chunk.split('\n'); - if (lines.length === 0) return; - lines[0] = leftovers + lines[0]; - leftovers = lines.pop(); - for (const line of lines) { - logLineFn(line); - } - }); - readable!.on('end', () => { - if (leftovers !== '') logLineFn(leftovers); - leftovers = ''; - }); +const logLines = ( + readable: undefined | Readable | null, + logLineFn: (arg0: string | undefined) => void, +) => { + readable!.setEncoding("utf8"); + // The process won't necessarily write full lines every time -- it might write a part of a line + // then write the rest of the line later. + let leftovers: string | undefined = ""; + readable!.on("data", (chunk) => { + const lines = chunk.split("\n"); + if (lines.length === 0) return; + lines[0] = leftovers + lines[0]; + leftovers = lines.pop(); + for (const line of lines) { + logLineFn(line); + } + }); + readable!.on("end", () => { + if (leftovers !== "") logLineFn(leftovers); + leftovers = ""; + }); }; /** @@ -74,90 +81,101 @@ const logLines = (readable: undefined | Readable | null, logLineFn: (arg0: (stri * - `stderr`: Similar to `stdout` but for stderr. * - `child`: The ChildProcess object. */ -module.exports = exports = (args: string[], opts:RunCMDOptions = {}) => { - logger.debug(`Executing command: ${args.join(' ')}`); +module.exports = exports = (args: string[], opts: RunCMDOptions = {}) => { + logger.debug(`Executing command: ${args.join(" ")}`); - opts = {cwd: settings.root, ...opts}; - logger.debug(`cwd: ${opts.cwd}`); + opts = { cwd: settings.root, ...opts }; + logger.debug(`cwd: ${opts.cwd}`); - // Log stdout and stderr by default. - const stdio = - Array.isArray(opts.stdio) ? opts.stdio.slice() // Copy to avoid mutating the caller's array. - : typeof opts.stdio === 'function' ? [null, opts.stdio, opts.stdio] - : opts.stdio === 'string' ? [null, 'string', 'string'] - : Array(3).fill(opts.stdio); - const cmdLogger = log4js.getLogger(`runCmd|${args[0]}`); - if (stdio[1] == null) stdio[1] = (line: string) => cmdLogger.info(line); - if (stdio[2] == null) stdio[2] = (line: string) => cmdLogger.error(line); - const stdioLoggers = []; - const stdioSaveString = []; - for (const fd of [1, 2]) { - if (typeof stdio[fd] === 'function') { - stdioLoggers[fd] = stdio[fd]; - stdio[fd] = 'pipe'; - } else if (stdio[fd] === 'string') { - stdioSaveString[fd] = true; - stdio[fd] = 'pipe'; - } - } - opts.stdio = stdio; + // Log stdout and stderr by default. + const stdio = Array.isArray(opts.stdio) + ? opts.stdio.slice() // Copy to avoid mutating the caller's array. + : typeof opts.stdio === "function" + ? [null, opts.stdio, opts.stdio] + : opts.stdio === "string" + ? [null, "string", "string"] + : Array(3).fill(opts.stdio); + const cmdLogger = log4js.getLogger(`runCmd|${args[0]}`); + if (stdio[1] == null) stdio[1] = (line: string) => cmdLogger.info(line); + if (stdio[2] == null) stdio[2] = (line: string) => cmdLogger.error(line); + const stdioLoggers = []; + const stdioSaveString = []; + for (const fd of [1, 2]) { + if (typeof stdio[fd] === "function") { + stdioLoggers[fd] = stdio[fd]; + stdio[fd] = "pipe"; + } else if (stdio[fd] === "string") { + stdioSaveString[fd] = true; + stdio[fd] = "pipe"; + } + } + opts.stdio = stdio; - // On Windows the PATH environment var might be spelled "Path". - const pathVarName = - Object.keys(process.env).filter((k) => k.toUpperCase() === 'PATH')[0] || 'PATH'; - // Set PATH so that utilities from installed dependencies (e.g., npm) are preferred over system - // (global) utilities. - const {env = process.env} = opts; - const {[pathVarName]: PATH} = env; - opts.env = { - ...env, // Copy env to avoid modifying process.env or the caller's supplied env. - [pathVarName]: [ - path.join(settings.root, 'src', 'node_modules', '.bin'), - path.join(settings.root, 'node_modules', '.bin'), - ...(PATH ? PATH.split(path.delimiter) : []), - ].join(path.delimiter), - }; - logger.debug(`${pathVarName}=${opts.env[pathVarName]}`); + // On Windows the PATH environment var might be spelled "Path". + const pathVarName = + Object.keys(process.env).filter((k) => k.toUpperCase() === "PATH")[0] || + "PATH"; + // Set PATH so that utilities from installed dependencies (e.g., npm) are preferred over system + // (global) utilities. + const { env = process.env } = opts; + const { [pathVarName]: PATH } = env; + opts.env = { + ...env, // Copy env to avoid modifying process.env or the caller's supplied env. + [pathVarName]: [ + path.join(settings.root, "src", "node_modules", ".bin"), + path.join(settings.root, "node_modules", ".bin"), + ...(PATH ? PATH.split(path.delimiter) : []), + ].join(path.delimiter), + }; + logger.debug(`${pathVarName}=${opts.env[pathVarName]}`); - // Create an error object to use in case the process fails. This is done here rather than in the - // process's `exit` handler so that we get a useful stack trace. - const procFailedErr: Error & ErrorExtended = new Error(); + // Create an error object to use in case the process fails. This is done here rather than in the + // process's `exit` handler so that we get a useful stack trace. + const procFailedErr: Error & ErrorExtended = new Error(); - const proc: ChildProcess = spawn(args[0], args.slice(1), opts); - const streams:[undefined, Readable|null, Readable|null] = [undefined, proc.stdout, proc.stderr]; + const proc: ChildProcess = spawn(args[0], args.slice(1), opts); + const streams: [undefined, Readable | null, Readable | null] = [ + undefined, + proc.stdout, + proc.stderr, + ]; - let px: { reject: any; resolve: any; }; - const p:PromiseWithStd = new Promise((resolve, reject) => { px = {resolve, reject}; }); - [, p.stdout, p.stderr] = streams; - p.child = proc; + let px: { reject: any; resolve: any }; + const p: PromiseWithStd = new Promise((resolve, reject) => { + px = { resolve, reject }; + }); + [, p.stdout, p.stderr] = streams; + p.child = proc; - const stdioStringPromises = [undefined, Promise.resolve(), Promise.resolve()]; - for (const fd of [1, 2]) { - if (streams[fd] == null) continue; - if (stdioLoggers[fd] != null) { - logLines(streams[fd], stdioLoggers[fd]); - } else if (stdioSaveString[fd]) { - // @ts-ignore - p[[null, 'stdout', 'stderr'][fd]] = stdioStringPromises[fd] = (async () => { - const chunks = []; - for await (const chunk of streams[fd]!) chunks.push(chunk); - return Buffer.concat(chunks).toString().replace(/\n+$/g, ''); - })(); - } - } + const stdioStringPromises = [undefined, Promise.resolve(), Promise.resolve()]; + for (const fd of [1, 2]) { + if (streams[fd] == null) continue; + if (stdioLoggers[fd] != null) { + logLines(streams[fd], stdioLoggers[fd]); + } else if (stdioSaveString[fd]) { + // @ts-ignore + p[[null, "stdout", "stderr"][fd]] = stdioStringPromises[fd] = + (async () => { + const chunks = []; + for await (const chunk of streams[fd]!) chunks.push(chunk); + return Buffer.concat(chunks).toString().replace(/\n+$/g, ""); + })(); + } + } - proc.on('exit', async (code, signal) => { - const [, stdout] = await Promise.all(stdioStringPromises); - if (code !== 0) { - procFailedErr.message = - `Command exited ${code ? `with code ${code}` : `on signal ${signal}`}: ${args.join(' ')}`; - procFailedErr.code = code; - procFailedErr.signal = signal; - logger.debug(procFailedErr.stack); - return px.reject(procFailedErr); - } - logger.debug(`Command returned successfully: ${args.join(' ')}`); - px.resolve(stdout); - }); - return p; + proc.on("exit", async (code, signal) => { + const [, stdout] = await Promise.all(stdioStringPromises); + if (code !== 0) { + procFailedErr.message = `Command exited ${ + code ? `with code ${code}` : `on signal ${signal}` + }: ${args.join(" ")}`; + procFailedErr.code = code; + procFailedErr.signal = signal; + logger.debug(procFailedErr.stack); + return px.reject(procFailedErr); + } + logger.debug(`Command returned successfully: ${args.join(" ")}`); + px.resolve(stdout); + }); + return p; }; diff --git a/src/node/utils/sanitizePathname.ts b/src/node/utils/sanitizePathname.ts index 2932b913d..2da5e1190 100644 --- a/src/node/utils/sanitizePathname.ts +++ b/src/node/utils/sanitizePathname.ts @@ -1,23 +1,25 @@ -'use strict'; +"use strict"; -const path = require('path'); +const path = require("path"); // Normalizes p and ensures that it is a relative path that does not reach outside. See // https://nvd.nist.gov/vuln/detail/CVE-2015-3297 for additional context. module.exports = (p: string, pathApi = path) => { - // The documentation for path.normalize() says that it resolves '..' and '.' segments. The word - // "resolve" implies that it examines the filesystem to resolve symbolic links, so 'a/../b' might - // not be the same thing as 'b'. Most path normalization functions from other libraries (e.g., - // Python's os.path.normpath()) clearly state that they do not examine the filesystem. Here we - // assume Node.js's path.normalize() does the same; that it is only a simple string manipulation. - p = pathApi.normalize(p); - if (pathApi.isAbsolute(p)) throw new Error(`absolute paths are forbidden: ${p}`); - if (p.split(pathApi.sep)[0] === '..') throw new Error(`directory traversal: ${p}`); - // On Windows, path normalization replaces forwardslashes with backslashes. Convert them back to - // forwardslashes. Node.js treats both the backlash and the forwardslash characters as pathname - // component separators on Windows so this does not change the meaning of the pathname on Windows. - // THIS CONVERSION MUST ONLY BE DONE ON WINDOWS, otherwise on POSIXish systems '..\\' in the input - // pathname would not be normalized away before being converted to '../'. - if (pathApi.sep === '\\') p = p.replace(/\\/g, '/'); - return p; + // The documentation for path.normalize() says that it resolves '..' and '.' segments. The word + // "resolve" implies that it examines the filesystem to resolve symbolic links, so 'a/../b' might + // not be the same thing as 'b'. Most path normalization functions from other libraries (e.g., + // Python's os.path.normpath()) clearly state that they do not examine the filesystem. Here we + // assume Node.js's path.normalize() does the same; that it is only a simple string manipulation. + p = pathApi.normalize(p); + if (pathApi.isAbsolute(p)) + throw new Error(`absolute paths are forbidden: ${p}`); + if (p.split(pathApi.sep)[0] === "..") + throw new Error(`directory traversal: ${p}`); + // On Windows, path normalization replaces forwardslashes with backslashes. Convert them back to + // forwardslashes. Node.js treats both the backlash and the forwardslash characters as pathname + // component separators on Windows so this does not change the meaning of the pathname on Windows. + // THIS CONVERSION MUST ONLY BE DONE ON WINDOWS, otherwise on POSIXish systems '..\\' in the input + // pathname would not be normalized away before being converted to '../'. + if (pathApi.sep === "\\") p = p.replace(/\\/g, "/"); + return p; }; diff --git a/src/node/utils/tar.json b/src/node/utils/tar.json index 08ae93f6b..8665e41b4 100644 --- a/src/node/utils/tar.json +++ b/src/node/utils/tar.json @@ -1,101 +1,101 @@ { - "pad.js": [ - "pad.js" - , "pad_utils.js" - , "$js-cookie/dist/js.cookie.js" - , "security.js" - , "$security.js" - , "vendors/browser.js" - , "pad_cookie.js" - , "pad_editor.js" - , "pad_editbar.js" - , "vendors/nice-select.js" - , "pad_modals.js" - , "pad_automatic_reconnect.js" - , "ace.js" - , "collab_client.js" - , "cssmanager.js" - , "pad_userlist.js" - , "pad_impexp.js" - , "pad_savedrevs.js" - , "pad_connectionstatus.js" - , "ChatMessage.js" - , "chat.js" - , "vendors/gritter.js" - , "$js-cookie/dist/js.cookie.js" - , "$tinycon/tinycon.js" - , "vendors/farbtastic.js" - , "skin_variants.js" - , "socketio.js" - , "colorutils.js" - ] -, "timeslider.js": [ - "timeslider.js" - , "colorutils.js" - , "draggable.js" - , "pad_utils.js" - , "$js-cookie/dist/js.cookie.js" - , "vendors/browser.js" - , "pad_cookie.js" - , "pad_editor.js" - , "pad_editbar.js" - , "vendors/nice-select.js" - , "pad_modals.js" - , "pad_automatic_reconnect.js" - , "pad_savedrevs.js" - , "pad_impexp.js" - , "AttributePool.js" - , "Changeset.js" - , "domline.js" - , "linestylefilter.js" - , "cssmanager.js" - , "broadcast.js" - , "broadcast_slider.js" - , "broadcast_revisions.js" - , "socketio.js" - , "AttributeManager.js" - , "AttributeMap.js" - , "attributes.js" - , "ChangesetUtils.js" - ] -, "ace2_inner.js": [ - "ace2_inner.js" - , "vendors/browser.js" - , "AttributePool.js" - , "Changeset.js" - , "ChangesetUtils.js" - , "skiplist.js" - , "colorutils.js" - , "undomodule.js" - , "$unorm/lib/unorm.js" - , "contentcollector.js" - , "changesettracker.js" - , "linestylefilter.js" - , "domline.js" - , "AttributeManager.js" - , "AttributeMap.js" - , "attributes.js" - , "scroll.js" - , "caretPosition.js" - , "pad_utils.js" - , "$js-cookie/dist/js.cookie.js" - , "security.js" - , "$security.js" - ] -, "ace2_common.js": [ - "ace2_common.js" - , "vendors/browser.js" - , "vendors/jquery.js" - , "rjquery.js" - , "$async.js" - , "underscore.js" - , "$underscore.js" - , "$underscore/underscore.js" - , "security.js" - , "$security.js" - , "pluginfw/client_plugins.js" - , "pluginfw/plugin_defs.js" - , "pluginfw/shared.js" - , "pluginfw/hooks.js" - ] + "pad.js": [ + "pad.js", + "pad_utils.js", + "$js-cookie/dist/js.cookie.js", + "security.js", + "$security.js", + "vendors/browser.js", + "pad_cookie.js", + "pad_editor.js", + "pad_editbar.js", + "vendors/nice-select.js", + "pad_modals.js", + "pad_automatic_reconnect.js", + "ace.js", + "collab_client.js", + "cssmanager.js", + "pad_userlist.js", + "pad_impexp.js", + "pad_savedrevs.js", + "pad_connectionstatus.js", + "ChatMessage.js", + "chat.js", + "vendors/gritter.js", + "$js-cookie/dist/js.cookie.js", + "$tinycon/tinycon.js", + "vendors/farbtastic.js", + "skin_variants.js", + "socketio.js", + "colorutils.js" + ], + "timeslider.js": [ + "timeslider.js", + "colorutils.js", + "draggable.js", + "pad_utils.js", + "$js-cookie/dist/js.cookie.js", + "vendors/browser.js", + "pad_cookie.js", + "pad_editor.js", + "pad_editbar.js", + "vendors/nice-select.js", + "pad_modals.js", + "pad_automatic_reconnect.js", + "pad_savedrevs.js", + "pad_impexp.js", + "AttributePool.js", + "Changeset.js", + "domline.js", + "linestylefilter.js", + "cssmanager.js", + "broadcast.js", + "broadcast_slider.js", + "broadcast_revisions.js", + "socketio.js", + "AttributeManager.js", + "AttributeMap.js", + "attributes.js", + "ChangesetUtils.js" + ], + "ace2_inner.js": [ + "ace2_inner.js", + "vendors/browser.js", + "AttributePool.js", + "Changeset.js", + "ChangesetUtils.js", + "skiplist.js", + "colorutils.js", + "undomodule.js", + "$unorm/lib/unorm.js", + "contentcollector.js", + "changesettracker.js", + "linestylefilter.js", + "domline.js", + "AttributeManager.js", + "AttributeMap.js", + "attributes.js", + "scroll.js", + "caretPosition.js", + "pad_utils.js", + "$js-cookie/dist/js.cookie.js", + "security.js", + "$security.js" + ], + "ace2_common.js": [ + "ace2_common.js", + "vendors/browser.js", + "vendors/jquery.js", + "rjquery.js", + "$async.js", + "underscore.js", + "$underscore.js", + "$underscore/underscore.js", + "security.js", + "$security.js", + "pluginfw/client_plugins.js", + "pluginfw/plugin_defs.js", + "pluginfw/shared.js", + "pluginfw/hooks.js" + ] } diff --git a/src/node/utils/toolbar.ts b/src/node/utils/toolbar.ts index aac3fb3d3..3aaeaf34a 100644 --- a/src/node/utils/toolbar.ts +++ b/src/node/utils/toolbar.ts @@ -1,305 +1,332 @@ -'use strict'; +"use strict"; /** * The Toolbar Module creates and renders the toolbars and buttons */ -const _ = require('underscore'); +const _ = require("underscore"); const removeItem = (array: string[], what: string) => { - let ax; - while ((ax = array.indexOf(what)) !== -1) { - array.splice(ax, 1); - } - return array; + let ax; + while ((ax = array.indexOf(what)) !== -1) { + array.splice(ax, 1); + } + return array; }; const defaultButtonAttributes = (name: string, overrides?: boolean) => ({ - command: name, - localizationId: `pad.toolbar.${name}.title`, - class: `buttonicon buttonicon-${name}`, + command: name, + localizationId: `pad.toolbar.${name}.title`, + class: `buttonicon buttonicon-${name}`, }); const tag = (name: string, attributes: AttributeObj, contents?: string) => { - const aStr = tagAttributes(attributes); + const aStr = tagAttributes(attributes); - if (_.isString(contents) && contents!.length > 0) { - return `<${name}${aStr}>${contents}`; - } else { - return `<${name}${aStr}>`; - } + if (_.isString(contents) && contents!.length > 0) { + return `<${name}${aStr}>${contents}`; + } else { + return `<${name}${aStr}>`; + } }; - type AttributeObj = { - [id: string]: string -} + [id: string]: string; +}; const tagAttributes = (attributes: AttributeObj) => { - attributes = _.reduce(attributes || {}, (o: AttributeObj, val: string, name: string) => { - if (!_.isUndefined(val)) { - o[name] = val; - } - return o; - }, {}); + attributes = _.reduce( + attributes || {}, + (o: AttributeObj, val: string, name: string) => { + if (!_.isUndefined(val)) { + o[name] = val; + } + return o; + }, + {}, + ); - return ` ${_.map(attributes, (val: string, name: string) => `${name}="${_.escape(val)}"`).join(' ')}`; + return ` ${_.map( + attributes, + (val: string, name: string) => `${name}="${_.escape(val)}"`, + ).join(" ")}`; }; type ButtonGroupType = { - grouping: string, - render: Function -} + grouping: string; + render: Function; +}; class ButtonGroup { - private buttons: Button[] + private buttons: Button[]; - constructor() { - this.buttons = [] - } + constructor() { + this.buttons = []; + } - public static fromArray = function (array: string[]) { - const btnGroup = new ButtonGroup(); - _.each(array, (btnName: string) => { - const button = Button.load(btnName) as Button - btnGroup.addButton(button); - }); - return btnGroup; - } + public static fromArray = function (array: string[]) { + const btnGroup = new ButtonGroup(); + _.each(array, (btnName: string) => { + const button = Button.load(btnName) as Button; + btnGroup.addButton(button); + }); + return btnGroup; + }; - private addButton(button: Button) { - this.buttons.push(button); - return this; - } + private addButton(button: Button) { + this.buttons.push(button); + return this; + } - render() { - if (this.buttons && this.buttons.length === 1) { - this.buttons[0].grouping = ''; - } else if (this.buttons && this.buttons.length > 1) { - _.first(this.buttons).grouping = 'grouped-left'; - _.last(this.buttons).grouping = 'grouped-right'; - _.each(this.buttons.slice(1, -1), (btn: Button) => { - btn.grouping = 'grouped-middle'; - }); - } + render() { + if (this.buttons && this.buttons.length === 1) { + this.buttons[0].grouping = ""; + } else if (this.buttons && this.buttons.length > 1) { + _.first(this.buttons).grouping = "grouped-left"; + _.last(this.buttons).grouping = "grouped-right"; + _.each(this.buttons.slice(1, -1), (btn: Button) => { + btn.grouping = "grouped-middle"; + }); + } - return _.map(this.buttons, (btn: ButtonGroup) => { - if (btn) return btn.render(); - }).join('\n'); - } + return _.map(this.buttons, (btn: ButtonGroup) => { + if (btn) return btn.render(); + }).join("\n"); + } } - class Button { - protected attributes: AttributeObj - grouping: string + protected attributes: AttributeObj; + grouping: string; - constructor(attributes: AttributeObj) { - this.attributes = attributes - this.grouping = "" - } + constructor(attributes: AttributeObj) { + this.attributes = attributes; + this.grouping = ""; + } - public static load(btnName: string) { - const button = module.exports.availableButtons[btnName]; - try { - if (button.constructor === Button || button.constructor === SelectButton) { - return button; - } else { - return new Button(button); - } - } catch (e) { - console.warn('Error loading button', btnName); - return false; - } - } + public static load(btnName: string) { + const button = module.exports.availableButtons[btnName]; + try { + if ( + button.constructor === Button || + button.constructor === SelectButton + ) { + return button; + } else { + return new Button(button); + } + } catch (e) { + console.warn("Error loading button", btnName); + return false; + } + } - render() { - const liAttributes = { - 'data-type': 'button', - 'data-key': this.attributes.command, - }; - return tag('li', liAttributes, - tag('a', {'class': this.grouping, 'data-l10n-id': this.attributes.localizationId}, - tag('button', { - 'class': ` ${this.attributes.class}`, - 'data-l10n-id': this.attributes.localizationId, - }))); - } + render() { + const liAttributes = { + "data-type": "button", + "data-key": this.attributes.command, + }; + return tag( + "li", + liAttributes, + tag( + "a", + { + class: this.grouping, + "data-l10n-id": this.attributes.localizationId, + }, + tag("button", { + class: ` ${this.attributes.class}`, + "data-l10n-id": this.attributes.localizationId, + }), + ), + ); + } } type SelectButtonOptions = { - value: string, - text: string, - attributes: AttributeObj -} + value: string; + text: string; + attributes: AttributeObj; +}; class SelectButton extends Button { - private readonly options: SelectButtonOptions[]; + private readonly options: SelectButtonOptions[]; - constructor(attrs: AttributeObj) { - super(attrs); - this.options = [] - } + constructor(attrs: AttributeObj) { + super(attrs); + this.options = []; + } - addOption(value: string, text: string, attributes: AttributeObj) { - this.options.push({ - value, - text, - attributes, - }) - return this; - } + addOption(value: string, text: string, attributes: AttributeObj) { + this.options.push({ + value, + text, + attributes, + }); + return this; + } - select(attributes: AttributeObj) { - const options: string[] = []; + select(attributes: AttributeObj) { + const options: string[] = []; - _.each(this.options, (opt: AttributeSelect) => { - const a = _.extend({ - value: opt.value, - }, opt.attributes); + _.each(this.options, (opt: AttributeSelect) => { + const a = _.extend( + { + value: opt.value, + }, + opt.attributes, + ); - options.push(tag('option', a, opt.text)); - }); - return tag('select', attributes, options.join('')); - } + options.push(tag("option", a, opt.text)); + }); + return tag("select", attributes, options.join("")); + } - render() { - const attributes = { - 'id': this.attributes.id, - 'data-key': this.attributes.command, - 'data-type': 'select', - }; - return tag('li', attributes, this.select({id: this.attributes.selectId})); - } + render() { + const attributes = { + id: this.attributes.id, + "data-key": this.attributes.command, + "data-type": "select", + }; + return tag("li", attributes, this.select({ id: this.attributes.selectId })); + } } - type AttributeSelect = { - value: string, - attributes: AttributeObj, - text: string -} + value: string; + attributes: AttributeObj; + text: string; +}; class Separator { - constructor() { - } + constructor() {} - public render() { - return tag('li', {class: 'separator'}); - - } + public render() { + return tag("li", { class: "separator" }); + } } module.exports = { - availableButtons: { - bold: defaultButtonAttributes('bold'), - italic: defaultButtonAttributes('italic'), - underline: defaultButtonAttributes('underline'), - strikethrough: defaultButtonAttributes('strikethrough'), + availableButtons: { + bold: defaultButtonAttributes("bold"), + italic: defaultButtonAttributes("italic"), + underline: defaultButtonAttributes("underline"), + strikethrough: defaultButtonAttributes("strikethrough"), - orderedlist: { - command: 'insertorderedlist', - localizationId: 'pad.toolbar.ol.title', - class: 'buttonicon buttonicon-insertorderedlist', - }, + orderedlist: { + command: "insertorderedlist", + localizationId: "pad.toolbar.ol.title", + class: "buttonicon buttonicon-insertorderedlist", + }, - unorderedlist: { - command: 'insertunorderedlist', - localizationId: 'pad.toolbar.ul.title', - class: 'buttonicon buttonicon-insertunorderedlist', - }, + unorderedlist: { + command: "insertunorderedlist", + localizationId: "pad.toolbar.ul.title", + class: "buttonicon buttonicon-insertunorderedlist", + }, - indent: defaultButtonAttributes('indent'), - outdent: { - command: 'outdent', - localizationId: 'pad.toolbar.unindent.title', - class: 'buttonicon buttonicon-outdent', - }, + indent: defaultButtonAttributes("indent"), + outdent: { + command: "outdent", + localizationId: "pad.toolbar.unindent.title", + class: "buttonicon buttonicon-outdent", + }, - undo: defaultButtonAttributes('undo'), - redo: defaultButtonAttributes('redo'), + undo: defaultButtonAttributes("undo"), + redo: defaultButtonAttributes("redo"), - clearauthorship: { - command: 'clearauthorship', - localizationId: 'pad.toolbar.clearAuthorship.title', - class: 'buttonicon buttonicon-clearauthorship', - }, + clearauthorship: { + command: "clearauthorship", + localizationId: "pad.toolbar.clearAuthorship.title", + class: "buttonicon buttonicon-clearauthorship", + }, - importexport: { - command: 'import_export', - localizationId: 'pad.toolbar.import_export.title', - class: 'buttonicon buttonicon-import_export', - }, + importexport: { + command: "import_export", + localizationId: "pad.toolbar.import_export.title", + class: "buttonicon buttonicon-import_export", + }, - timeslider: { - command: 'showTimeSlider', - localizationId: 'pad.toolbar.timeslider.title', - class: 'buttonicon buttonicon-history', - }, + timeslider: { + command: "showTimeSlider", + localizationId: "pad.toolbar.timeslider.title", + class: "buttonicon buttonicon-history", + }, - savedrevision: defaultButtonAttributes('savedRevision'), - settings: defaultButtonAttributes('settings'), - embed: defaultButtonAttributes('embed'), - showusers: defaultButtonAttributes('showusers'), + savedrevision: defaultButtonAttributes("savedRevision"), + settings: defaultButtonAttributes("settings"), + embed: defaultButtonAttributes("embed"), + showusers: defaultButtonAttributes("showusers"), - timeslider_export: { - command: 'import_export', - localizationId: 'timeslider.toolbar.exportlink.title', - class: 'buttonicon buttonicon-import_export', - }, + timeslider_export: { + command: "import_export", + localizationId: "timeslider.toolbar.exportlink.title", + class: "buttonicon buttonicon-import_export", + }, - timeslider_settings: { - command: 'settings', - localizationId: 'pad.toolbar.settings.title', - class: 'buttonicon buttonicon-settings', - }, + timeslider_settings: { + command: "settings", + localizationId: "pad.toolbar.settings.title", + class: "buttonicon buttonicon-settings", + }, - timeslider_returnToPad: { - command: 'timeslider_returnToPad', - localizationId: 'timeslider.toolbar.returnbutton', - class: 'buttontext', - }, - }, + timeslider_returnToPad: { + command: "timeslider_returnToPad", + localizationId: "timeslider.toolbar.returnbutton", + class: "buttontext", + }, + }, - registerButton(buttonName: string, buttonInfo: any) { - this.availableButtons[buttonName] = buttonInfo; - }, + registerButton(buttonName: string, buttonInfo: any) { + this.availableButtons[buttonName] = buttonInfo; + }, - button: (attributes: AttributeObj) => new Button(attributes), + button: (attributes: AttributeObj) => new Button(attributes), - separator: () => (new Separator()).render(), + separator: () => new Separator().render(), - selectButton: (attributes: AttributeObj) => new SelectButton(attributes), + selectButton: (attributes: AttributeObj) => new SelectButton(attributes), - /* - * Valid values for whichMenu: 'left' | 'right' | 'timeslider-right' - * Valid values for page: 'pad' | 'timeslider' - */ - menu(buttons: string[][], isReadOnly: boolean, whichMenu: string, page: string) { - if (isReadOnly) { - // The best way to detect if it's the left editbar is to check for a bold button - if (buttons[0].indexOf('bold') !== -1) { - // Clear all formatting buttons - buttons = []; - } else { - // Remove Save Revision from the right menu - removeItem(buttons[0], 'savedrevision'); - } - } else if ((buttons[0].indexOf('savedrevision') === -1) && - (whichMenu === 'right') && (page === 'pad')) { - /* - * This pad is not read only - * - * Add back the savedrevision button (the "star") if is not already there, - * but only on the right toolbar, and only if we are showing a pad (dont't - * do it in the timeslider). - * - * This is a quick fix for #3702 (and subsequent issue #3767): it was - * sufficient to visit a single read only pad to cause the disappearence - * of the star button from all the pads. - */ - buttons[0].push('savedrevision'); - } + /* + * Valid values for whichMenu: 'left' | 'right' | 'timeslider-right' + * Valid values for page: 'pad' | 'timeslider' + */ + menu( + buttons: string[][], + isReadOnly: boolean, + whichMenu: string, + page: string, + ) { + if (isReadOnly) { + // The best way to detect if it's the left editbar is to check for a bold button + if (buttons[0].indexOf("bold") !== -1) { + // Clear all formatting buttons + buttons = []; + } else { + // Remove Save Revision from the right menu + removeItem(buttons[0], "savedrevision"); + } + } else if ( + buttons[0].indexOf("savedrevision") === -1 && + whichMenu === "right" && + page === "pad" + ) { + /* + * This pad is not read only + * + * Add back the savedrevision button (the "star") if is not already there, + * but only on the right toolbar, and only if we are showing a pad (dont't + * do it in the timeslider). + * + * This is a quick fix for #3702 (and subsequent issue #3767): it was + * sufficient to visit a single read only pad to cause the disappearence + * of the star button from all the pads. + */ + buttons[0].push("savedrevision"); + } - const groups = _.map(buttons, (group: string[]) => ButtonGroup.fromArray(group).render()); - return groups.join(this.separator()); - }, + const groups = _.map(buttons, (group: string[]) => + ButtonGroup.fromArray(group).render(), + ); + return groups.join(this.separator()); + }, }; diff --git a/src/package.json b/src/package.json index 2c9c2f5bf..ac162f299 100644 --- a/src/package.json +++ b/src/package.json @@ -1,137 +1,133 @@ { - "name": "ep_etherpad-lite", - "description": "A free and open source realtime collaborative editor", - "homepage": "https://etherpad.org", - "keywords": [ - "etherpad", - "realtime", - "collaborative", - "editor" - ], - "author": "Etherpad Foundation", - "contributors": [ - { - "name": "John McLear" - }, - { - "name": "Antonio Muci" - }, - { - "name": "Hans Pinckaers" - }, - { - "name": "Robin Buse" - }, - { - "name": "Marcel Klehr" - }, - { - "name": "Peter Martischka" - } - ], - "dependencies": { - "async": "^3.2.5", - "axios": "^1.6.8", - "clean-css": "^5.3.3", - "cookie-parser": "^1.4.6", - "cross-spawn": "^7.0.3", - "ejs": "^3.1.10", - "etherpad-require-kernel": "^1.0.16", - "etherpad-yajsml": "0.0.12", - "express": "4.19.2", - "express-rate-limit": "^7.2.0", - "express-session": "npm:@etherpad/express-session@^1.18.2", - "fast-deep-equal": "^3.1.3", - "find-root": "1.1.0", - "formidable": "^3.5.1", - "http-errors": "^2.0.0", - "jose": "^5.2.4", - "js-cookie": "^3.0.5", - "jsdom": "^24.0.0", - "jsonminify": "0.4.2", - "jsonwebtoken": "^9.0.2", - "languages4translatewiki": "0.1.3", - "live-plugin-manager": "^0.19.0", - "lodash.clonedeep": "4.5.0", - "log4js": "^6.9.1", - "measured-core": "^2.0.0", - "mime-types": "^2.1.35", - "oidc-provider": "^8.4.5", - "openapi-backend": "^5.10.6", - "proxy-addr": "^2.0.7", - "rate-limiter-flexible": "^5.0.0", - "rehype": "^13.0.1", - "rehype-minify-whitespace": "^6.0.0", - "resolve": "1.22.8", - "security": "1.0.0", - "semver": "^7.6.0", - "socket.io": "^4.7.5", - "socket.io-client": "^4.7.5", - "superagent": "^8.1.2", - "terser": "^5.30.3", - "threads": "^1.7.0", - "tinycon": "0.6.8", - "tsx": "^4.7.2", - "ueberdb2": "^4.2.63", - "underscore": "1.13.6", - "unorm": "1.6.0", - "wtfnode": "^0.9.2", - "lru-cache": "^10.2.0" - }, - "bin": { - "etherpad-healthcheck": "../bin/etherpad-healthcheck", - "etherpad-lite": "node/server.ts" - }, - "devDependencies": { - "@playwright/test": "^1.43.1", - "@types/async": "^3.2.24", - "@types/express": "^4.17.21", - "@types/formidable": "^3.4.5", - "@types/http-errors": "^2.0.4", - "@types/jsdom": "^21.1.6", - "@types/jsonwebtoken": "^9.0.6", - "@types/mocha": "^10.0.6", - "@types/node": "^20.12.7", - "@types/oidc-provider": "^8.4.4", - "@types/semver": "^7.5.8", - "@types/sinon": "^17.0.3", - "@types/supertest": "^6.0.2", - "@types/underscore": "^1.11.15", - "eslint": "^9.0.0", - "eslint-config-etherpad": "^4.0.4", - "etherpad-cli-client": "^3.0.2", - "mocha": "^10.4.0", - "mocha-froth": "^0.2.10", - "nodeify": "^1.0.1", - "openapi-schema-validation": "^0.4.2", - "set-cookie-parser": "^2.6.0", - "sinon": "^17.0.1", - "split-grid": "^1.0.11", - "supertest": "^6.3.4", - "typescript": "^5.4.5" - }, - "engines": { - "node": ">=18.18.2", - "npm": ">=6.14.0", - "pnpm": ">=8.3.0" - }, - "repository": { - "type": "git", - "url": "https://github.com/ether/etherpad-lite.git" - }, - "scripts": { - "lint": "eslint .", - "test": "mocha --import=tsx --timeout 120000 --recursive tests/backend/specs/**.ts ../node_modules/ep_*/static/tests/backend/specs/**", - "test-container": "mocha --import=tsx --timeout 5000 tests/container/specs/api", - "dev": "node --import tsx node/server.ts", - "prod": "node --import tsx node/server.ts", - "ts-check": "tsc --noEmit", - "ts-check:watch": "tsc --noEmit --watch", - "test-ui": "npx playwright test tests/frontend-new/specs", - "test-ui:ui": "npx playwright test tests/frontend-new/specs --ui", - "test-admin": "npx playwright test tests/frontend-new/admin-spec --workers 1", - "test-admin:ui": "npx playwright test tests/frontend-new/admin-spec --ui --workers 1" - }, - "version": "2.0.2", - "license": "Apache-2.0" + "name": "ep_etherpad-lite", + "description": "A free and open source realtime collaborative editor", + "homepage": "https://etherpad.org", + "keywords": ["etherpad", "realtime", "collaborative", "editor"], + "author": "Etherpad Foundation", + "contributors": [ + { + "name": "John McLear" + }, + { + "name": "Antonio Muci" + }, + { + "name": "Hans Pinckaers" + }, + { + "name": "Robin Buse" + }, + { + "name": "Marcel Klehr" + }, + { + "name": "Peter Martischka" + } + ], + "dependencies": { + "async": "^3.2.5", + "axios": "^1.6.8", + "clean-css": "^5.3.3", + "cookie-parser": "^1.4.6", + "cross-spawn": "^7.0.3", + "ejs": "^3.1.10", + "etherpad-require-kernel": "^1.0.16", + "etherpad-yajsml": "0.0.12", + "express": "4.19.2", + "express-rate-limit": "^7.2.0", + "express-session": "npm:@etherpad/express-session@^1.18.2", + "fast-deep-equal": "^3.1.3", + "find-root": "1.1.0", + "formidable": "^3.5.1", + "http-errors": "^2.0.0", + "jose": "^5.2.4", + "js-cookie": "^3.0.5", + "jsdom": "^24.0.0", + "jsonminify": "0.4.2", + "jsonwebtoken": "^9.0.2", + "languages4translatewiki": "0.1.3", + "live-plugin-manager": "^0.19.0", + "lodash.clonedeep": "4.5.0", + "log4js": "^6.9.1", + "lru-cache": "^10.2.0", + "measured-core": "^2.0.0", + "mime-types": "^2.1.35", + "oidc-provider": "^8.4.5", + "openapi-backend": "^5.10.6", + "proxy-addr": "^2.0.7", + "rate-limiter-flexible": "^5.0.0", + "rehype": "^13.0.1", + "rehype-minify-whitespace": "^6.0.0", + "resolve": "1.22.8", + "security": "1.0.0", + "semver": "^7.6.0", + "socket.io": "^4.7.5", + "socket.io-client": "^4.7.5", + "superagent": "^8.1.2", + "terser": "^5.30.3", + "threads": "^1.7.0", + "tinycon": "0.6.8", + "tsx": "^4.7.2", + "ueberdb2": "^4.2.63", + "underscore": "1.13.6", + "unorm": "1.6.0", + "wtfnode": "^0.9.2" + }, + "bin": { + "etherpad-healthcheck": "../bin/etherpad-healthcheck", + "etherpad-lite": "node/server.ts" + }, + "devDependencies": { + "@biomejs/biome": "1.7.0", + "@playwright/test": "^1.43.1", + "@types/async": "^3.2.24", + "@types/express": "^4.17.21", + "@types/formidable": "^3.4.5", + "@types/http-errors": "^2.0.4", + "@types/jsdom": "^21.1.6", + "@types/jsonwebtoken": "^9.0.6", + "@types/mocha": "^10.0.6", + "@types/node": "^20.12.7", + "@types/oidc-provider": "^8.4.4", + "@types/semver": "^7.5.8", + "@types/sinon": "^17.0.3", + "@types/supertest": "^6.0.2", + "@types/underscore": "^1.11.15", + "etherpad-cli-client": "^3.0.2", + "mocha": "^10.4.0", + "mocha-froth": "^0.2.10", + "nodeify": "^1.0.1", + "openapi-schema-validation": "^0.4.2", + "set-cookie-parser": "^2.6.0", + "sinon": "^17.0.1", + "split-grid": "^1.0.11", + "supertest": "^6.3.4", + "typescript": "^5.4.5" + }, + "engines": { + "node": ">=18.18.2", + "npm": ">=6.14.0", + "pnpm": ">=8.3.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/ether/etherpad-lite.git" + }, + "scripts": { + "lint": "npx @biomejs/biome format --write ./ ", + "lint:check": "npx @biomejs/biome format --check ./ ", + "lint:fix": "npx @biomejs/biome format --write ./ ", + "test": "mocha --import=tsx --timeout 120000 --recursive tests/backend/specs/**.ts ../node_modules/ep_*/static/tests/backend/specs/**", + "test-container": "mocha --import=tsx --timeout 5000 tests/container/specs/api", + "dev": "node --import tsx node/server.ts", + "prod": "node --import tsx node/server.ts", + "ts-check": "tsc --noEmit", + "ts-check:watch": "tsc --noEmit --watch", + "test-ui": "npx playwright test tests/frontend-new/specs", + "test-ui:ui": "npx playwright test tests/frontend-new/specs --ui", + "test-admin": "npx playwright test tests/frontend-new/admin-spec --workers 1", + "test-admin:ui": "npx playwright test tests/frontend-new/admin-spec --ui --workers 1" + }, + "version": "2.0.2", + "license": "Apache-2.0" } diff --git a/src/playwright.config.ts b/src/playwright.config.ts index 20b0361ba..e08442116 100644 --- a/src/playwright.config.ts +++ b/src/playwright.config.ts @@ -1,79 +1,77 @@ -import {defineConfig, devices, test} from '@playwright/test'; +import { defineConfig, devices, test } from "@playwright/test"; - -export const defaultExpectTimeout = process.env.CI ? 20 * 1000 : 5000 -export const defaultTestTimeout = 90 * 1000 +export const defaultExpectTimeout = process.env.CI ? 20 * 1000 : 5000; +export const defaultTestTimeout = 90 * 1000; /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: './tests/frontend-new/', - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: process.env.CI ? 'github' : 'html', - expect: { timeout: defaultExpectTimeout }, - timeout: defaultTestTimeout, - retries: 2, - workers: 20, - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - // baseURL: 'http://127.0.0.1:3000', - baseURL: "localhost:9001", - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - video: 'on-first-retry', - }, + testDir: "./tests/frontend-new/", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI ? "github" : "html", + expect: { timeout: defaultExpectTimeout }, + timeout: defaultTestTimeout, + retries: 2, + workers: 20, + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + baseURL: "localhost:9001", + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + video: "on-first-retry", + }, - /* Configure projects for major browsers */ - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - { - name: 'chrome-firefox', - use: - {...devices['Desktop Firefox'], ...devices['Desktop Chrome']}, - }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "chrome-firefox", + use: { ...devices["Desktop Firefox"], ...devices["Desktop Chrome"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, - ], + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], - /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // url: 'http://127.0.0.1:3000', - // reuseExistingServer: !process.env.CI, - // }, + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, }); diff --git a/src/static/font/config.json b/src/static/font/config.json index b480d070f..3561ad319 100644 --- a/src/static/font/config.json +++ b/src/static/font/config.json @@ -1,862 +1,748 @@ { - "name": "fontawesome-etherpad", - "css_prefix_text": "buttonicon-", - "css_use_suffix": false, - "hinting": true, - "units_per_em": 1000, - "ascent": 850, - "glyphs": [ - { - "uid": "bf882b30900da12fca090d9796bc3030", - "css": "mail", - "code": 59402, - "src": "fontawesome" - }, - { - "uid": "7277ded7695b2a307a5f9d50097bb64c", - "css": "print", - "code": 59393, - "src": "fontawesome" - }, - { - "uid": "9396b2d8849e0213a0f11c5fd7fcc522", - "css": "tasks", - "code": 59442, - "src": "fontawesome" - }, - { - "uid": "fa9a0b7e788c2d78e24cef1de6b70e80", - "css": "brush", - "code": 59440, - "src": "fontawesome" - }, - { - "uid": "be13b8c668eb18839d5d53107725f1de", - "css": "slideshare", - "code": 59441, - "src": "fontawesome" - }, - { - "uid": "d35a1d35efeb784d1dc9ac18b9b6c2b6", - "css": "pencil", - "code": 59449, - "src": "fontawesome" - }, - { - "uid": "8fb55fd696d9a0f58f3b27c1d8633750", - "css": "table", - "code": 61646, - "src": "fontawesome" - }, - { - "uid": "1569a5b2bebe7e28bb0d26ddeca34fc8", - "css": "video", - "code": 59451, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M656.6 125H93.4C41.8 125 0 166.8 0 218.4V781.6C0 833.2 41.8 875 93.4 875H656.6C708.2 875 750 833.2 750 781.6V218.4C750 166.8 708.2 125 656.6 125ZM1026.6 198.6L812.5 346.3V653.7L1026.6 801.2C1068 829.7 1125 800.6 1125 750.8V249C1125 199.4 1068.2 170.1 1026.6 198.6Z", - "width": 1125 - }, - "search": [ - "video" - ] - }, - { - "uid": "8fe2c571b78d019e24cab0b780cb61d6", - "css": "video-slash", - "code": 59452, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M1237.9 894.7L1130.5 811.7C1160.5 809 1187.5 785 1187.5 751V249C1187.5 199.2 1130.7 170.1 1089.1 198.6L875 346.3V614.3L812.5 566V218.4C812.5 166.8 770.7 125 719.1 125H242L88.9 6.6C75.2-3.9 55.7-1.6 44.9 12.1L6.6 61.3C-3.9 75-1.6 94.5 12.1 105.1L83.4 160.2 812.5 723.8 1161.1 993.4C1174.8 1003.9 1194.3 1001.6 1205.1 987.9L1243.4 938.5C1254.1 925 1251.6 905.3 1237.9 894.7ZM62.5 781.6C62.5 833.2 104.3 875 155.9 875H719.1C741 875 760.9 867.2 777 854.5L62.5 302.1V781.6Z", - "width": 1250 - }, - "search": [ - "video-slash" - ] - }, - { - "uid": "d8020fccc088a524f7cc6db1f329cb3e", - "css": "microphone-alt", - "code": 59453, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M656.3 375H625C607.7 375 593.8 389 593.8 406.3V500C593.8 646.1 467.8 763.3 318.8 748.8 188.9 736.1 93.8 619.4 93.8 488.9V406.3C93.8 389 79.8 375 62.5 375H31.3C14 375 0 389 0 406.3V484.7C0 659.8 124.9 815.8 296.9 839.6V906.3H187.5C170.2 906.3 156.3 920.2 156.3 937.5V968.8C156.3 986 170.2 1000 187.5 1000H500C517.3 1000 531.3 986 531.3 968.8V937.5C531.3 920.2 517.3 906.3 500 906.3H390.6V840.3C558 817.3 687.5 673.6 687.5 500V406.3C687.5 389 673.5 375 656.3 375ZM343.8 687.5C447.3 687.5 531.3 603.6 531.3 500H364.6C353.1 500 343.8 493 343.8 484.4V453.1C343.8 444.5 353.1 437.5 364.6 437.5H531.3V375H364.6C353.1 375 343.8 368 343.8 359.4V328.1C343.8 319.5 353.1 312.5 364.6 312.5H531.3V250H364.6C353.1 250 343.8 243 343.8 234.4V203.1C343.8 194.5 353.1 187.5 364.6 187.5H531.3C531.3 83.9 447.3 0 343.8 0S156.3 83.9 156.3 187.5V500C156.3 603.6 240.2 687.5 343.8 687.5Z", - "width": 688 - }, - "search": [ - "microphone-alt" - ] - }, - { - "uid": "7d9dd931e0e6305cc5eed55efa435d7c", - "css": "microphone-alt-slash", - "code": 59454, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M1237.9 894.7L930.2 656.9C954.6 609.8 968.8 556.6 968.8 500V406.3C968.8 389 954.8 375 937.5 375H906.3C889 375 875 389 875 406.3V500C875 535 867.3 568 854.1 598L802.2 558C808.3 539.6 812.5 520.4 812.5 500H727.2L646.4 437.5H812.5V375H645.8C634.3 375 625 368 625 359.4V328.1C625 319.5 634.3 312.5 645.8 312.5H812.5V250H645.8C634.3 250 625 243 625 234.4V203.1C625 194.5 634.3 187.5 645.8 187.5H812.5C812.5 84 728.6 0 625 0S437.5 84 437.5 187.5V276.1L88.8 6.6C75.2-4 55.5-1.6 44.9 12.1L6.6 61.4C-4 75-1.6 94.7 12.1 105.3L1161.2 993.4C1174.8 1004 1194.5 1001.6 1205.1 987.9L1243.4 938.6C1254 925 1251.6 905.3 1237.9 894.7ZM781.3 906.3H671.9V840.3C694.7 837.1 717 831.9 738.2 824.5L639.8 748.4C626.7 749.2 613.6 750.1 600 748.8 490.9 738.1 407.2 653.8 382.9 549.9L281.3 471.3V484.7C281.3 659.8 406.2 815.8 578.1 839.6V906.3H468.8C451.5 906.3 437.5 920.2 437.5 937.5V968.8C437.5 986 451.5 1000 468.8 1000H781.3C798.5 1000 812.5 986 812.5 968.8V937.5C812.5 920.2 798.5 906.3 781.3 906.3Z", - "width": 1250 - }, - "search": [ - "microphone-alt-slash" - ] - }, - { - "uid": "63aa8ba99d3f31973dd2ef65274a03bd", - "css": "compress", - "code": 59455, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M851.6 375H609.4C583.4 375 562.5 354.1 562.5 328.1V85.9C562.5 73 573 62.5 585.9 62.5H664.1C677 62.5 687.5 73 687.5 85.9V250H851.6C864.5 250 875 260.5 875 273.4V351.6C875 364.5 864.5 375 851.6 375ZM312.5 328.1V85.9C312.5 73 302 62.5 289.1 62.5H210.9C198 62.5 187.5 73 187.5 85.9V250H23.4C10.5 250 0 260.5 0 273.4V351.6C0 364.5 10.5 375 23.4 375H265.6C291.6 375 312.5 354.1 312.5 328.1ZM312.5 914.1V671.9C312.5 645.9 291.6 625 265.6 625H23.4C10.5 625 0 635.5 0 648.4V726.6C0 739.5 10.5 750 23.4 750H187.5V914.1C187.5 927 198 937.5 210.9 937.5H289.1C302 937.5 312.5 927 312.5 914.1ZM687.5 914.1V750H851.6C864.5 750 875 739.5 875 726.6V648.4C875 635.5 864.5 625 851.6 625H609.4C583.4 625 562.5 645.9 562.5 671.9V914.1C562.5 927 573 937.5 585.9 937.5H664.1C677 937.5 687.5 927 687.5 914.1Z", - "width": 875 - }, - "search": [ - "compress" - ] - }, - { - "uid": "d71c270fcbdffa89ee7b646e9d5a2667", - "css": "expand", - "code": 59456, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M0 351.6V109.4C0 83.4 20.9 62.5 46.9 62.5H289.1C302 62.5 312.5 73 312.5 85.9V164.1C312.5 177 302 187.5 289.1 187.5H125V351.6C125 364.5 114.5 375 101.6 375H23.4C10.5 375 0 364.5 0 351.6ZM562.5 85.9V164.1C562.5 177 573 187.5 585.9 187.5H750V351.6C750 364.5 760.5 375 773.4 375H851.6C864.5 375 875 364.5 875 351.6V109.4C875 83.4 854.1 62.5 828.1 62.5H585.9C573 62.5 562.5 73 562.5 85.9ZM851.6 625H773.4C760.5 625 750 635.5 750 648.4V812.5H585.9C573 812.5 562.5 823 562.5 835.9V914.1C562.5 927 573 937.5 585.9 937.5H828.1C854.1 937.5 875 916.6 875 890.6V648.4C875 635.5 864.5 625 851.6 625ZM312.5 914.1V835.9C312.5 823 302 812.5 289.1 812.5H125V648.4C125 635.5 114.5 625 101.6 625H23.4C10.5 625 0 635.5 0 648.4V890.6C0 916.6 20.9 937.5 46.9 937.5H289.1C302 937.5 312.5 927 312.5 914.1Z", - "width": 875 - }, - "search": [ - "expand" - ] - }, - { - "uid": "5d2d07f112b8de19f2c0dbfec3e42c05", - "css": "spin5", - "code": 59457, - "src": "fontelico" - }, - { - "uid": "54cecf7a3401a3458fe7ea001e622d39", - "css": "trash", - "code": 59406, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M843.8 62.5H609.4L591 26A46.9 46.9 0 0 0 549 0H325.8A46.3 46.3 0 0 0 284 26L265.6 62.5H31.3A31.3 31.3 0 0 0 0 93.8V156.3A31.3 31.3 0 0 0 31.3 187.5H843.8A31.3 31.3 0 0 0 875 156.3V93.8A31.3 31.3 0 0 0 843.8 62.5ZM103.9 912.1A93.8 93.8 0 0 0 197.5 1000H677.5A93.8 93.8 0 0 0 771.1 912.1L812.5 250H62.5Z", - "width": 875 - }, - "search": [ - "trash" - ] - }, - { - "uid": "f99ec3e571ced9cd747e2b34d8c03436", - "css": "list-ul", - "code": 59434, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M187.5 187.5C187.5 239.3 145.5 281.3 93.8 281.3S0 239.3 0 187.5 42 93.8 93.8 93.8 187.5 135.7 187.5 187.5ZM93.8 406.3C42 406.3 0 448.2 0 500S42 593.8 93.8 593.8 187.5 551.8 187.5 500 145.5 406.3 93.8 406.3ZM93.8 718.8C42 718.8 0 760.7 0 812.5S42 906.3 93.8 906.3 187.5 864.3 187.5 812.5 145.5 718.8 93.8 718.8ZM281.3 257.8H968.8C986 257.8 1000 243.8 1000 226.6V148.4C1000 131.2 986 117.2 968.8 117.2H281.3C264 117.2 250 131.2 250 148.4V226.6C250 243.8 264 257.8 281.3 257.8ZM281.3 570.3H968.8C986 570.3 1000 556.3 1000 539.1V460.9C1000 443.7 986 429.7 968.8 429.7H281.3C264 429.7 250 443.7 250 460.9V539.1C250 556.3 264 570.3 281.3 570.3ZM281.3 882.8H968.8C986 882.8 1000 868.8 1000 851.6V773.4C1000 756.2 986 742.2 968.8 742.2H281.3C264 742.2 250 756.2 250 773.4V851.6C250 868.8 264 882.8 281.3 882.8Z", - "width": 1000 - }, - "search": [ - "list-ul" - ] - }, - { - "uid": "d921283a409a4e9a51ff1632b200c23d", - "css": "eye-slash", - "code": 59459, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M625 781.3C476.9 781.3 356.9 666.6 345.9 521.3L141 362.9C114.1 396.7 89.3 432.4 69.3 471.5A63.2 63.2 0 0 0 69.3 528.5C175.2 735.2 384.9 875 625 875 677.6 875 728.3 867.2 777.1 854.6L675.8 776.2A281.5 281.5 0 0 1 625 781.3ZM1237.9 894.7L1022 727.9A647 647 0 0 0 1180.7 528.5 63.2 63.2 0 0 0 1180.7 471.5C1074.8 264.8 865.1 125 625 125A601.9 601.9 0 0 0 337.3 198.6L88.8 6.6A31.3 31.3 0 0 0 44.9 12.1L6.6 61.4A31.3 31.3 0 0 0 12.1 105.3L1161.2 993.4A31.3 31.3 0 0 0 1205.1 987.9L1243.4 938.6A31.3 31.3 0 0 0 1237.9 894.7ZM879.1 617.4L802.3 558A185.1 185.1 0 0 0 812.5 500 185.1 185.1 0 0 0 575.6 319.9 93.1 93.1 0 0 1 593.8 375 91.1 91.1 0 0 1 590.7 394.5L447 283.4A277.9 277.9 0 0 1 625 218.8 281.1 281.1 0 0 1 906.3 500C906.3 542.2 895.9 581.6 879.1 617.4Z", - "width": 1250 - }, - "search": [ - "eye-slash" - ] - }, - { - "uid": "9f79bb02a62542500d6396747bfbdad5", - "css": "list-ol", - "code": 59460, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M6.4 272.5C6.4 257.9 14 249.9 29 249.9H58.5V172C58.5 161.9 59.6 151.5 59.6 151.5H58.9S55.4 156.7 53.3 158.8C44.6 167.2 32.8 167.5 22.7 156.7L11.9 144.6C1.5 134.1 2.2 122.7 13 112.6L55.4 73.6C64.1 65.6 71.7 62.5 83.6 62.5H107.2C122.2 62.5 130.2 70.1 130.2 85.1V249.9H160.4C175.4 249.9 183 257.9 183 272.5V289.9C183 304.5 175.4 312.5 160.4 312.5H29C14 312.5 6.4 304.5 6.4 289.9V272.5ZM4.3 594.9C4.3 502.6 103.8 484.8 103.8 459.8 103.8 445.8 92.2 442.7 85.7 442.7 79.6 442.7 73.1 444.8 67.2 450.2 57.3 459.8 46.7 463.9 35.8 455L19 441.7C7.7 432.8 5 422.5 13.6 411.6 26.5 394.5 50.8 375 92.6 375 130.5 375 179.4 395.5 179.4 452.3 179.4 527.2 88.1 542.9 84.3 563.4H160.6C175.3 563.4 183.2 571.3 183.2 585.7V602.8C183.2 617.1 175.3 625 160.6 625H27.9C14.2 625 4.3 617.1 4.3 602.8V594.9ZM11 887.9L22 869.8C29.5 856.8 39.8 856.1 52.4 863.6 62 867.7 71.2 869.8 80.5 869.8 100.3 869.8 108.5 862.9 108.5 853.7 108.5 840.7 97.6 835.9 77.4 835.9H68.2C56.5 835.9 50 831.8 44.2 820.5L42.2 816.8C37.4 807.5 39.8 797.6 47.6 787.7L58.6 774C71.9 757.6 82.5 747.7 82.5 747.7V747S74.3 749.1 57.9 749.1H32.6C17.9 749.1 10.4 741.2 10.4 726.8V709.7C10.4 695 17.9 687.5 32.6 687.5H146.8C161.5 687.5 169 695.4 169 709.7V716.2C169 727.5 166.3 735.4 159.1 743.9L124.9 783.3C163.2 793.2 181 823.3 181 851.3 181 893 153 937.5 86.3 937.5 53.8 937.5 31.2 928.3 16.2 919 4.9 910.8 3.9 899.9 11 887.9ZM281.3 257.8H968.8C986 257.8 1000 243.8 1000 226.6V148.4C1000 131.2 986 117.2 968.8 117.2H281.3C264 117.2 250 131.2 250 148.4V226.6C250 243.8 264 257.8 281.3 257.8ZM281.3 570.3H968.8C986 570.3 1000 556.3 1000 539.1V460.9C1000 443.7 986 429.7 968.8 429.7H281.3C264 429.7 250 443.7 250 460.9V539.1C250 556.3 264 570.3 281.3 570.3ZM281.3 882.8H968.8C986 882.8 1000 868.8 1000 851.6V773.4C1000 756.2 986 742.2 968.8 742.2H281.3C264 742.2 250 756.2 250 773.4V851.6C250 868.8 264 882.8 281.3 882.8Z", - "width": 1000 - }, - "search": [ - "list-ol" - ] - }, - { - "uid": "216f7d72d19fbfc4e319fe70240dc9fe", - "css": "bold", - "code": 59461, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M595.3 476.3C661 440.1 700.1 370.6 700.1 289.4 700.1 195.2 648.8 118.3 566.1 86 517.8 66.4 470.4 62.5 409.5 62.5H46.9C29.6 62.5 15.6 76.5 15.6 93.8V158.3C15.6 175.6 29.6 189.5 46.9 189.5H111.5V811.7H46.9C29.6 811.7 15.6 825.7 15.6 842.9V906.3C15.6 923.5 29.6 937.5 46.9 937.5H429.1C476.4 937.5 516.6 935 559.7 922.7 659.2 893 734.4 802 734.4 683.6 734.4 581.7 682.5 504.6 595.3 476.3ZM277.8 196.9H409.5C441.3 196.9 463.3 200.8 482.8 210 513.7 226.6 531.4 261.8 531.4 306.6 531.4 375 491.7 417.5 427.9 417.5H277.8V196.9ZM497.8 793.5C478 801.4 453.5 803.1 436.4 803.1H277.8V550.7H442.5C520 550.7 565.7 600.2 565.7 673.8 565.7 729.3 539 776.3 497.8 793.5Z", - "width": 750 - }, - "search": [ - "bold" - ] - }, - { - "uid": "0dbd89c5def7ede2cbbe99ef8effcbda", - "css": "underline", - "code": 59462, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M438 758.3C259 758.3 132.2 658.3 132.2 462.6V125H76.9C59.6 125 45.6 111 45.6 93.8V31.3C45.6 14 59.6 0 76.9 0H345.2C362.5 0 376.5 14 376.5 31.3V93.8C376.5 111 362.5 125 345.2 125H289V462.6C289 567.5 344.3 617.8 438 617.8 529.7 617.8 586.1 568.1 586.1 461.6V125H530.8C513.5 125 499.5 111 499.5 93.8V31.3C499.5 14 513.5 0 530.8 0H798.1C815.4 0 829.4 14 829.4 31.3V93.8C829.4 111 815.4 125 798.1 125H742.9V462.6C742.9 656.7 616.1 758.3 438 758.3ZM31.3 875H843.8C861 875 875 889 875 906.3V968.8C875 986 861 1000 843.8 1000H31.3C14 1000 0 986 0 968.8V906.3C0 889 14 875 31.3 875Z", - "width": 875 - }, - "search": [ - "underline" - ] - }, - { - "uid": "daa7f27064d8c218bf22731012103675", - "css": "italic", - "code": 59463, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M399.9 812.5H333.8L455.1 187.5H534.6A31.3 31.3 0 0 0 565.3 162.2L577.5 99.7C581.2 80.4 566.5 62.5 546.8 62.5H234.8A31.3 31.3 0 0 0 204.2 87.8L192 150.3C188.2 169.6 203 187.5 222.6 187.5H288.7L167.5 812.5H90.4A31.3 31.3 0 0 0 59.7 837.8L47.5 900.3C43.8 919.6 58.5 937.5 78.2 937.5H387.7A31.3 31.3 0 0 0 418.4 912.2L430.6 849.7C434.4 830.4 419.6 812.5 399.9 812.5Z", - "width": 625 - }, - "search": [ - "italic" - ] - }, - { - "uid": "638e629bf04f06f100d42a3b6c3afeaa", - "css": "strikethrough", - "code": 59464, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M968.8 562.5H31.3C14 562.5 0 548.5 0 531.3V468.8C0 451.5 14 437.5 31.3 437.5H968.8C986 437.5 1000 451.5 1000 468.8V531.3C1000 548.5 986 562.5 968.8 562.5ZM549.5 593.8C602.7 619 640.3 649.8 640.3 703.6 640.3 768.3 583.8 808.4 492.7 808.4 429.5 808.4 342.5 784.8 342.5 722V718.8C342.5 701.5 328.5 687.5 311.3 687.5H222.2C204.9 687.5 190.9 701.5 190.9 718.8V756.3C190.9 886.8 342.7 955.1 492.7 955.1 665.7 955.1 809.1 866.4 809.1 692.6 809.1 653.9 802 621.5 789.3 593.8H549.5ZM489 406.3C425.7 379.9 378 349.7 378 289.7 378 223.4 438.4 197.1 504.9 197.1 588.2 197.1 631.8 229.5 631.8 261.5V265.6C631.8 282.9 645.8 296.9 663 296.9H752.1C769.4 296.9 783.4 282.9 783.4 265.6V206.4C783.4 104 643.3 50.4 504.9 50.4 338.5 50.4 210.5 130.4 210.5 295.8 210.5 340.2 219.6 376.2 235.5 406.3H489Z", - "width": 1000 - }, - "search": [ - "strikethrough" - ] - }, - { - "uid": "1a1fa90cbaa7da526141f8be54d5491b", - "css": "indent", - "code": 59465, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M0 164.1V85.9C0 68.7 14 54.7 31.3 54.7H843.8C861 54.7 875 68.7 875 85.9V164.1C875 181.3 861 195.3 843.8 195.3H31.3C14 195.3 0 181.3 0 164.1ZM343.8 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H343.8C326.5 304.7 312.5 318.7 312.5 335.9V414.1C312.5 431.3 326.5 445.3 343.8 445.3ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM343.8 695.3H843.8C861 695.3 875 681.3 875 664.1V585.9C875 568.7 861 554.7 843.8 554.7H343.8C326.5 554.7 312.5 568.7 312.5 585.9V664.1C312.5 681.3 326.5 695.3 343.8 695.3ZM240.8 477.9L53.3 290.4C33.7 270.8 0 284.7 0 312.5V687.5C0 715.5 33.8 729.1 53.3 709.6L240.8 522.1C253.1 509.9 253.1 490.1 240.8 477.9Z", - "width": 875 - }, - "search": [ - "indent" - ] - }, - { - "uid": "8d1d056ea637f2f25e905cd5beac310e", - "css": "outdent", - "code": 59466, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M0 164.1V85.9C0 68.7 14 54.7 31.3 54.7H843.8C861 54.7 875 68.7 875 85.9V164.1C875 181.3 861 195.3 843.8 195.3H31.3C14 195.3 0 181.3 0 164.1ZM406.3 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H406.3C389 304.7 375 318.7 375 335.9V414.1C375 431.3 389 445.3 406.3 445.3ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM406.3 695.3H843.8C861 695.3 875 681.3 875 664.1V585.9C875 568.7 861 554.7 843.8 554.7H406.3C389 554.7 375 568.7 375 585.9V664.1C375 681.3 389 695.3 406.3 695.3ZM9.2 522.1L196.7 709.6C216.3 729.2 250 715.3 250 687.5V312.5C250 284.5 216.2 270.9 196.7 290.4L9.2 477.9C-3.1 490.1-3.1 509.9 9.2 522.1Z", - "width": 875 - }, - "search": [ - "outdent" - ] - }, - { - "uid": "097d911c1839d50e7183cfb6e7c16934", - "css": "undo-alt", - "code": 59467, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M499.1 15.6C369.7 15.9 252.2 66.9 165.4 149.8L95.6 80C66.1 50.5 15.6 71.4 15.6 113.2V375C15.6 400.9 36.6 421.9 62.5 421.9H324.3C366.1 421.9 387 371.4 357.5 341.9L275.9 260.3C336.2 203.9 414.2 172.6 497.1 171.9 677.6 170.3 829.7 316.4 828.1 502.8 826.6 679.7 683.2 828.1 500 828.1 419.7 828.1 343.8 799.5 283.9 747 274.7 738.8 260.7 739.3 252 748L174.5 825.5C165 835 165.4 850.5 175.4 859.6 261.3 937.1 375.1 984.4 500 984.4 767.5 984.4 984.4 767.5 984.4 500 984.4 232.8 766.3 15.1 499.1 15.6Z", - "width": 1000 - }, - "search": [ - "undo-alt" - ] - }, - { - "uid": "4bd031cc742bc0605f0d2a6c13eeb789", - "css": "redo-alt", - "code": 59468, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M500.9 15.6C630.3 15.9 747.8 66.9 834.6 149.8L904.4 80C933.9 50.5 984.4 71.4 984.4 113.2V375C984.4 400.9 963.4 421.9 937.5 421.9H675.7C633.9 421.9 613 371.4 642.5 341.9L724.1 260.3C663.8 203.9 585.8 172.6 502.9 171.9 322.4 170.3 170.3 316.4 171.9 502.8 173.4 679.7 316.8 828.1 500 828.1 580.3 828.1 656.2 799.5 716.1 747 725.3 738.8 739.3 739.3 748 748L825.5 825.5C835 835 834.6 850.5 824.6 859.6 738.7 937.1 624.9 984.4 500 984.4 232.5 984.4 15.6 767.5 15.6 500 15.6 232.8 233.7 15.1 500.9 15.6Z", - "width": 1000 - }, - "search": [ - "redo-alt" - ] - }, - { - "uid": "8a69d07fcdeb0deda9048dffdfeb03d3", - "css": "link", - "code": 59469, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M637.9 362.1C754.6 478.9 753 666.2 638.6 781.2 638.4 781.4 638.1 781.7 637.9 781.9L506.7 913.2C390.9 1028.9 202.6 1028.9 86.8 913.2-28.9 797.4-28.9 609.1 86.8 493.3L159.3 420.9C178.5 401.7 211.6 414.4 212.6 441.6 213.9 476.2 220.1 511 231.5 544.6 235.4 555.9 232.6 568.5 224.1 577L198.6 602.6C143.8 657.3 142.1 746.4 196.3 801.7 251.1 857.5 341 857.9 396.2 802.7L527.4 671.5C582.5 616.4 582.3 527.4 527.4 472.6 520.2 465.4 512.9 459.8 507.2 455.8A31.3 31.3 0 0 1 493.7 431.2C492.9 410.6 500.2 389.3 516.5 373L557.6 331.9C568.4 321.1 585.3 319.8 597.8 328.5A297.8 297.8 0 0 1 637.9 362.1ZM913.2 86.8C797.4-28.9 609.1-28.9 493.3 86.8L362.1 218.1C361.8 218.3 361.6 218.6 361.4 218.8 247 333.8 245.4 521.1 362.1 637.9A297.8 297.8 0 0 0 402.2 671.5C414.7 680.2 431.6 678.9 442.4 668.1L483.5 627C499.8 610.7 507.1 589.4 506.3 568.8A31.3 31.3 0 0 0 492.8 544.2C487.1 540.2 479.8 534.6 472.6 527.4 417.7 472.6 417.5 383.6 472.6 328.5L603.8 197.3C659 142.1 748.9 142.5 803.7 198.3 857.9 253.6 856.2 342.7 801.4 397.4L775.9 423C767.4 431.5 764.6 444.1 768.5 455.4 779.9 489 786.1 523.8 787.4 558.4 788.4 585.6 821.5 598.3 840.7 579.1L913.2 506.7C1028.9 390.9 1028.9 202.6 913.2 86.8Z", - "width": 1000 - }, - "search": [ - "link" - ] - }, - { - "uid": "195e10d964b70c44cde9513ec217cba4", - "css": "font", - "code": 59470, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M843.8 812.5H791.6L538.1 83.4C533.8 70.9 521.9 62.5 508.6 62.5H366.4C353.1 62.5 341.2 70.9 336.9 83.4L83.4 812.5H31.3C14.1 812.5 0 826.6 0 843.8V906.3C0 923.4 14.1 937.5 31.3 937.5H296.9C314.1 937.5 328.1 923.4 328.1 906.3V843.8C328.1 826.6 314.1 812.5 296.9 812.5H250L302 654.7H571.9L623.8 812.5H578.1C560.9 812.5 546.9 826.6 546.9 843.8V906.3C546.9 923.4 560.9 937.5 578.1 937.5H843.8C860.9 937.5 875 923.4 875 906.3V843.8C875 826.6 860.9 812.5 843.8 812.5ZM340.6 524L422.7 281.6C431.1 252 435.5 226.6 437.5 214.1 439.1 226.8 443.2 252.1 452.5 281.8L533.2 524Z", - "width": 875 - }, - "search": [ - "font" - ] - }, - { - "uid": "e9352fe9c753373d14694398ce8044fe", - "css": "comment-medical", - "code": 59471, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M500 62.5C223.9 62.5 0 244.4 0 468.8 0 565.5 41.8 654.3 111.3 724.1 86.8 822.4 5.3 910.2 4.3 911.1A15.6 15.6 0 0 0 15.6 937.5C145 937.5 242.2 875.5 290.2 837.1A595 595 0 0 0 500 875C776.2 875 1000 693.1 1000 468.8S776.2 62.5 500 62.5ZM687.5 515.6A15.6 15.6 0 0 1 671.9 531.3H562.5V640.6A15.6 15.6 0 0 1 546.9 656.3H453.1A15.6 15.6 0 0 1 437.5 640.6V531.3H328.1A15.6 15.6 0 0 1 312.5 515.6V421.9A15.6 15.6 0 0 1 328.1 406.3H437.5V296.9A15.6 15.6 0 0 1 453.1 281.3H546.9A15.6 15.6 0 0 1 562.5 296.9V406.3H671.9A15.6 15.6 0 0 1 687.5 421.9Z", - "width": 1000 - }, - "search": [ - "comment-medical" - ] - }, - { - "uid": "a5c7ef2089dd63c12d3328563fee2330", - "css": "comment", - "code": 59472, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M500 62.5C223.8 62.5 0 244.3 0 468.8 0 565.6 41.8 654.3 111.3 724 86.9 822.5 5.3 910.2 4.3 911.1 0 915.6-1.2 922.3 1.4 928.1S9.4 937.5 15.6 937.5C145.1 937.5 242.2 875.4 290.2 837.1 354.1 861.1 425 875 500 875 776.2 875 1000 693.2 1000 468.8S776.2 62.5 500 62.5Z", - "width": 1000 - }, - "search": [ - "comment" - ] - }, - { - "uid": "5455d3369b90673f0404f9290f40f074", - "css": "cog", - "code": 59473, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M952 616.6L868.7 568.6C877.1 523.2 877.1 476.8 868.7 431.4L952 383.4C961.5 377.9 965.8 366.6 962.7 356.1 941 286.5 904.1 223.6 855.9 171.3 848.4 163.3 836.3 161.3 827 166.8L743.8 214.8C708.8 184.8 668.6 161.5 625 146.3V50.4C625 39.5 617.4 29.9 606.6 27.5 535 11.5 461.5 12.3 393.4 27.5 382.6 29.9 375 39.5 375 50.4V146.5C331.6 161.9 291.4 185.2 256.3 215L173.2 167C163.7 161.5 151.8 163.3 144.3 171.5 96.1 223.6 59.2 286.5 37.5 356.2 34.2 366.8 38.7 378.1 48.2 383.6L131.4 431.6C123 477 123 523.4 131.4 568.8L48.2 616.8C38.7 622.3 34.4 633.6 37.5 644.1 59.2 713.7 96.1 776.6 144.3 828.9 151.8 836.9 163.9 838.9 173.2 833.4L256.4 785.4C291.4 815.4 331.6 838.7 375.2 853.9V950C375.2 960.9 382.8 970.5 393.6 972.9 465.2 988.9 538.7 988.1 606.8 972.9 617.6 970.5 625.2 960.9 625.2 950V853.9C668.6 838.5 708.8 815.2 743.9 785.4L827.1 833.4C836.7 838.9 848.6 837.1 856.1 828.9 904.3 776.8 941.2 713.9 962.9 644.1 965.8 633.4 961.5 622.1 952 616.6ZM500 656.3C413.9 656.3 343.8 586.1 343.8 500S413.9 343.8 500 343.8 656.3 413.9 656.3 500 586.1 656.3 500 656.3Z", - "width": 1000 - }, - "search": [ - "cog" - ] - }, - { - "uid": "320da42dd92a9773159f2e4037a1d1db", - "css": "text-height", - "code": 59474, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M31.3 62.5H593.8C611 62.5 625 76.5 625 93.8V281.3C625 298.5 611 312.5 593.8 312.5H524.4C507.2 312.5 493.2 298.5 493.2 281.3V187.5H386.2V812.5H453.1C470.4 812.5 484.4 826.5 484.4 843.8V906.3C484.4 923.5 470.4 937.5 453.1 937.5H171.9C154.6 937.5 140.6 923.5 140.6 906.3V843.8C140.6 826.5 154.6 812.5 171.9 812.5H238.8V187.5H131.8V281.3C131.8 298.5 117.8 312.5 100.6 312.5H31.3C14 312.5 0 298.5 0 281.3V93.8C0 76.5 14 62.5 31.3 62.5ZM959.6 71.7L1115.8 227.9C1135.4 247.4 1121.7 281.3 1093.7 281.3H1000V718.8H1093.8C1124.3 718.8 1134.1 753.9 1115.8 772.1L959.6 928.3C947.4 940.6 927.6 940.5 915.4 928.3L759.2 772.1C739.6 752.6 753.3 718.8 781.3 718.8H875V281.3H781.3C750.7 281.3 740.9 246.1 759.2 227.9L915.4 71.7C927.6 59.4 947.4 59.5 959.6 71.7Z", - "width": 1125 - }, - "search": [ - "text-height" - ] - }, - { - "uid": "bc0f1614c05e71b1c8beaf95bc900761", - "css": "share-alt", - "code": 59475, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M687.5 625C643.3 625 602.8 640.3 570.7 665.8L370.6 540.7A188.6 188.6 0 0 0 370.6 459.3L570.7 334.2C602.8 359.7 643.3 375 687.5 375 791.1 375 875 291.1 875 187.5S791.1 0 687.5 0 500 83.9 500 187.5C500 201.5 501.5 215.1 504.4 228.2L304.3 353.3C272.2 327.8 231.7 312.5 187.5 312.5 83.9 312.5 0 396.4 0 500S83.9 687.5 187.5 687.5C231.7 687.5 272.2 672.2 304.3 646.7L504.4 771.8A188.1 188.1 0 0 0 500 812.5C500 916.1 583.9 1000 687.5 1000S875 916.1 875 812.5 791.1 625 687.5 625Z", - "width": 875 - }, - "search": [ - "share-alt" - ] - }, - { - "uid": "ef49eade5ad70fcd1daa78d8d16bd68b", - "css": "code", - "code": 59476, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M544.7 999L425.6 964.5C413.1 960.9 406.1 947.9 409.6 935.4L676.2 17C679.7 4.5 692.8-2.5 705.3 1L824.4 35.5C836.9 39.1 843.9 52.1 840.4 64.6L573.8 983C570.1 995.5 557.2 1002.7 544.7 999ZM322.1 779.9L407 689.3C416 679.7 415.4 664.5 405.5 655.7L228.5 500 405.5 344.3C415.4 335.5 416.2 320.3 407 310.7L322.1 220.1C313.3 210.7 298.4 210.2 288.9 219.1L7.4 482.8C-2.5 492-2.5 507.8 7.4 517L288.9 780.9C298.4 789.8 313.3 789.5 322.1 779.9ZM961.1 781.1L1242.6 517.2C1252.5 508 1252.5 492.2 1242.6 483L961.1 218.9C951.8 210.2 936.9 210.5 927.9 219.9L843 310.5C834 320.1 834.6 335.4 844.5 344.1L1021.5 500 844.5 655.7C834.6 664.5 833.8 679.7 843 689.3L927.9 779.9C936.7 789.5 951.6 789.8 961.1 781.1Z", - "width": 1250 - }, - "search": [ - "code" - ] - }, - { - "uid": "b3fb5fc84c956ceabfd7ec42ee3fc5dd", - "css": "history", - "code": 59477, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M984.4 499.1C984.9 766 767.2 984.2 500.4 984.4 385.1 984.5 279.2 944.3 196 877.1 174.4 859.7 172.8 827.2 192.4 807.6L214.4 785.6C231.2 768.8 258.1 766.9 276.7 781.7 338 830.3 415.6 859.4 500 859.4 698.6 859.4 859.4 698.6 859.4 500 859.4 301.4 698.6 140.6 500 140.6 404.7 140.6 318.1 177.7 253.8 238.1L352.9 337.3C372.6 357 358.6 390.6 330.8 390.6H46.9C29.6 390.6 15.6 376.6 15.6 359.4V75.4C15.6 47.6 49.3 33.7 69 53.3L165.4 149.8C252.4 66.7 370.2 15.6 500 15.6 767.2 15.6 983.9 232 984.4 499.1ZM631 653L650.2 628.3C666.1 607.9 662.4 578.4 642 562.5L562.5 500.7V296.9C562.5 271 541.5 250 515.6 250H484.4C458.5 250 437.5 271 437.5 296.9V561.8L565.3 661.2C585.7 677.1 615.1 673.4 631 653Z", - "width": 1000 - }, - "search": [ - "history" - ] - }, - { - "uid": "bed311f2f0699a3e55a635284d86a5c7", - "css": "star", - "code": 59478, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M506.4 34.8L378.9 293.4 93.6 335C42.4 342.4 21.9 405.5 59 441.6L265.4 642.8 216.6 927C207.8 978.3 261.9 1016.8 307.2 992.8L562.5 858.6 817.8 992.8C863.1 1016.6 917.2 978.3 908.4 927L859.6 642.8 1066 441.6C1103.1 405.5 1082.6 342.4 1031.4 335L746.1 293.4 618.6 34.8C595.7-11.3 529.5-11.9 506.4 34.8Z", - "width": 1125 - }, - "search": [ - "star" - ] - }, - { - "uid": "28feb41c0766d59e9f56b2c4c9cb67a5", - "css": "file-import", - "code": 59479, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M31.3 562.5C14.1 562.5 0 576.6 0 593.8V656.3C0 673.4 14.1 687.5 31.3 687.5H250V562.5ZM986.3 205.1L795.1 13.7C786.3 4.9 774.4 0 761.9 0H750V250H1000V238.1C1000 225.8 995.1 213.9 986.3 205.1ZM687.5 265.6V0H296.9C270.9 0 250 20.9 250 46.9V562.5H500V435.2C500 407.2 533.8 393.4 553.5 413.1L740.2 601.6C753.1 614.6 753.1 635.5 740.2 648.4L553.3 836.7C533.6 856.4 499.8 842.6 499.8 814.6V687.5H250V953.1C250 979.1 270.9 1000 296.9 1000H953.1C979.1 1000 1000 979.1 1000 953.1V312.5H734.4C708.6 312.5 687.5 291.4 687.5 265.6Z", - "width": 1000 - }, - "search": [ - "file-import" - ] - }, - { - "uid": "a4382bef7f9361b8dacb8ae0b42691a4", - "css": "file-download", - "code": 59480, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M437.5 265.6V0H46.9C20.9 0 0 20.9 0 46.9V953.1C0 979.1 20.9 1000 46.9 1000H703.1C729.1 1000 750 979.1 750 953.1V312.5H484.4C458.6 312.5 437.5 291.4 437.5 265.6ZM586.8 678.4L398.5 865.4C385.5 878.3 364.5 878.3 351.5 865.4L163.2 678.4C143.4 658.8 157.3 625 185.2 625H312.5V468.8C312.5 451.5 326.5 437.5 343.8 437.5H406.3C423.5 437.5 437.5 451.5 437.5 468.8V625H564.8C592.7 625 606.6 658.8 586.8 678.4ZM736.3 205.1L545.1 13.7C536.3 4.9 524.4 0 511.9 0H500V250H750V238.1C750 225.8 745.1 213.9 736.3 205.1Z", - "width": 750 - }, - "search": [ - "file-download" - ] - }, - { - "uid": "149eec703c4bd1d93f052f3d239cce44", - "css": "file-pdf", - "code": 59481, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M355.3 500.2C345.5 468.9 345.7 408.6 351.4 408.6 367.8 408.6 366.2 480.7 355.3 500.2ZM352 592.4C336.9 631.8 318.2 677 296.5 714.8 332.2 701.2 372.7 681.3 419.3 672.1 394.5 653.3 370.7 626.4 352 592.4ZM168.2 836.1C168.2 837.7 193.9 825.6 236.3 757.6 223.2 769.9 179.5 805.5 168.2 836.1ZM484.4 312.5H750V953.1C750 979.1 729.1 1000 703.1 1000H46.9C20.9 1000 0 979.1 0 953.1V46.9C0 20.9 20.9 0 46.9 0H437.5V265.6C437.5 291.4 458.6 312.5 484.4 312.5ZM468.8 648C429.7 624.2 403.7 591.4 385.4 543 394.1 506.8 408 452 397.5 417.6 388.3 360.2 314.6 365.8 304.1 404.3 294.3 440 303.3 490.4 319.9 554.7 297.3 608.6 263.9 680.9 240.2 722.3 240 722.3 240 722.5 239.8 722.5 186.9 749.6 96.1 809.4 133.4 855.3 144.3 868.8 164.6 874.8 175.4 874.8 210.4 874.8 245.1 839.6 294.7 754.1 345.1 737.5 400.4 716.8 449 708.8 491.4 731.8 541 746.9 574 746.9 631.1 746.9 635 684.4 612.5 662.1 585.4 635.5 506.4 643.2 468.7 648ZM736.3 205.1L544.9 13.7C536.1 4.9 524.2 0 511.7 0H500V250H750V238.1C750 225.8 745.1 213.9 736.3 205.1ZM591.6 703.7C599.6 698.4 586.7 680.5 508 686.1 580.5 717 591.6 703.7 591.6 703.7Z", - "width": 750 - }, - "search": [ - "file-pdf" - ] - }, - { - "uid": "43c33879f17fb9c62a7466659a1a9347", - "css": "file-word", - "code": 59482, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M437.5 265.6V0H46.9C20.9 0 0 20.9 0 46.9V953.1C0 979.1 20.9 1000 46.9 1000H703.1C729.1 1000 750 979.1 750 953.1V312.5H484.4C458.6 312.5 437.5 291.4 437.5 265.6ZM549 500H595.7C610.7 500 621.9 513.9 618.6 528.7L544.3 856.8C542 867.6 532.4 875 521.5 875H447.3C436.5 875 427.1 867.6 424.6 857.2 374.2 655.1 384 698.6 374.6 641.4H373.6C371.5 669.3 368.9 675.4 323.6 857.2 321.1 867.6 311.7 875 301 875H228.5C217.6 875 208 867.4 205.7 856.6L131.8 528.5C128.5 513.9 139.6 500 154.7 500H202.5C213.7 500 223.4 507.8 225.6 518.9 256.1 671.3 264.8 732.8 266.6 757.6 269.7 737.7 280.9 693.8 324 518 326.6 507.4 335.9 500.2 346.9 500.2H403.7C414.6 500.2 424 507.6 426.6 518.2 473.4 714.3 482.8 760.4 484.4 770.9 484 749 479.3 736.1 526.6 518.6 528.5 507.6 538.1 500 549 500ZM750 238.1V250H500V0H511.9C524.4 0 536.3 4.9 545.1 13.7L736.3 205.1C745.1 213.9 750 225.8 750 238.1Z", - "width": 750 - }, - "search": [ - "file-word" - ] - }, - { - "uid": "5d3cbbf4f54f53889ff77614613a050d", - "css": "file-alt", - "code": 59483, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M437.5 265.6V0H46.9C20.9 0 0 20.9 0 46.9V953.1C0 979.1 20.9 1000 46.9 1000H703.1C729.1 1000 750 979.1 750 953.1V312.5H484.4C458.6 312.5 437.5 291.4 437.5 265.6ZM562.5 726.6C562.5 739.5 552 750 539.1 750H210.9C198 750 187.5 739.5 187.5 726.6V710.9C187.5 698 198 687.5 210.9 687.5H539.1C552 687.5 562.5 698 562.5 710.9V726.6ZM562.5 601.6C562.5 614.5 552 625 539.1 625H210.9C198 625 187.5 614.5 187.5 601.6V585.9C187.5 573 198 562.5 210.9 562.5H539.1C552 562.5 562.5 573 562.5 585.9V601.6ZM562.5 460.9V476.6C562.5 489.5 552 500 539.1 500H210.9C198 500 187.5 489.5 187.5 476.6V460.9C187.5 448 198 437.5 210.9 437.5H539.1C552 437.5 562.5 448 562.5 460.9ZM750 238.1V250H500V0H511.9C524.4 0 536.3 4.9 545.1 13.7L736.3 205.1C745.1 213.9 750 225.8 750 238.1Z", - "width": 750 - }, - "search": [ - "file-alt" - ] - }, - { - "uid": "c718261461d9a8046891e6c68d610118", - "css": "file", - "code": 59484, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M437.5 265.6V0H46.9C20.9 0 0 20.9 0 46.9V953.1C0 979.1 20.9 1000 46.9 1000H703.1C729.1 1000 750 979.1 750 953.1V312.5H484.4C458.6 312.5 437.5 291.4 437.5 265.6ZM750 238.1V250H500V0H511.9C524.4 0 536.3 4.9 545.1 13.7L736.3 205.1C745.1 213.9 750 225.8 750 238.1Z", - "width": 750 - }, - "search": [ - "file" - ] - }, - { - "uid": "9a14f9bdf73d4f035ecb964e16f27b5b", - "css": "users", - "code": 59445, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M187.5 437.5C256.4 437.5 312.5 381.4 312.5 312.5S256.4 187.5 187.5 187.5 62.5 243.6 62.5 312.5 118.6 437.5 187.5 437.5ZM1062.5 437.5C1131.4 437.5 1187.5 381.4 1187.5 312.5S1131.4 187.5 1062.5 187.5 937.5 243.6 937.5 312.5 993.6 437.5 1062.5 437.5ZM1125 500H1000C965.6 500 934.6 513.9 911.9 536.3 990.6 579.5 1046.5 657.4 1058.6 750H1187.5C1222.1 750 1250 722.1 1250 687.5V625C1250 556.1 1193.9 500 1125 500ZM625 500C745.9 500 843.8 402.1 843.8 281.3S745.9 62.5 625 62.5 406.3 160.4 406.3 281.3 504.1 500 625 500ZM775 562.5H758.8C718.2 582 673 593.8 625 593.8S532 582 491.2 562.5H475C350.8 562.5 250 663.3 250 787.5V843.8C250 895.5 292 937.5 343.8 937.5H906.3C958 937.5 1000 895.5 1000 843.8V787.5C1000 663.3 899.2 562.5 775 562.5ZM338.1 536.3C315.4 513.9 284.4 500 250 500H125C56.1 500 0 556.1 0 625V687.5C0 722.1 27.9 750 62.5 750H191.2C203.5 657.4 259.4 579.5 338.1 536.3Z", - "width": 1250 - }, - "search": [ - "users" - ] - }, - { - "uid": "5ce9d7d62b842d1e0b42ccb50417ed86", - "css": "pencil-alt", - "code": 59400, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M972.5 277.5L882.4 367.6C873.2 376.8 858.4 376.8 849.2 367.6L632.4 150.8C623.2 141.6 623.2 126.8 632.4 117.6L722.5 27.5C759-9 818.4-9 855.1 27.5L972.5 144.9C1009.2 181.4 1009.2 240.8 972.5 277.5ZM555.1 194.9L42.2 707.8 0.8 945.1C-4.9 977.1 23 1004.9 55.1 999.4L292.4 957.8 805.3 444.9C814.5 435.7 814.5 420.9 805.3 411.7L588.5 194.9C579.1 185.7 564.3 185.7 555.1 194.9ZM242.4 663.9C231.6 653.1 231.6 635.9 242.4 625.2L543.2 324.4C553.9 313.7 571.1 313.7 581.8 324.4S592.6 352.3 581.8 363.1L281.1 663.9C270.3 674.6 253.1 674.6 242.4 663.9ZM171.9 828.1H265.6V899L139.6 921.1 78.9 860.4 101 734.4H171.9V828.1Z", - "width": 1000 - }, - "search": [ - "pencil-alt" - ] - }, - { - "uid": "88a8e61cd1555895e8af136db8b58885", - "css": "times", - "code": 59430, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M474.1 500L669.5 304.6C693.5 280.6 693.5 241.7 669.5 217.7L626.1 174.2C602.1 150.3 563.2 150.3 539.2 174.2L343.8 369.7 148.3 174.2C124.3 150.3 85.4 150.3 61.4 174.2L18 217.7C-6 241.7-6 280.5 18 304.6L213.4 500 18 695.4C-6 719.4-6 758.3 18 782.3L61.4 825.8C85.4 849.7 124.3 849.7 148.3 825.8L343.8 630.3 539.2 825.8C563.2 849.7 602.1 849.7 626.1 825.8L669.5 782.3C693.5 758.3 693.5 719.5 669.5 695.4L474.1 500Z", - "width": 688 - }, - "search": [ - "times" - ] - }, - { - "uid": "91c50bb767ec3d33047773a7e539799e", - "css": "pause", - "code": 59433, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M281.3 935.5H93.8C42 935.5 0 893.6 0 841.8V154.3C0 102.5 42 60.5 93.8 60.5H281.3C333 60.5 375 102.5 375 154.3V841.8C375 893.6 333 935.5 281.3 935.5ZM875 841.8V154.3C875 102.5 833 60.5 781.3 60.5H593.8C542 60.5 500 102.5 500 154.3V841.8C500 893.6 542 935.5 593.8 935.5H781.3C833 935.5 875 893.6 875 841.8Z", - "width": 875 - }, - "search": [ - "pause" - ] - }, - { - "uid": "3053a00ac47ec0a6e52490d34a2251eb", - "css": "stop", - "code": 59394, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M781.3 62.5H93.8C42 62.5 0 104.5 0 156.3V843.8C0 895.5 42 937.5 93.8 937.5H781.3C833 937.5 875 895.5 875 843.8V156.3C875 104.5 833 62.5 781.3 62.5Z", - "width": 875 - }, - "search": [ - "stop" - ] - }, - { - "uid": "91b4828047e0874d4b2cfbb44dc16ff9", - "css": "step-backward", - "code": 59435, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M125 914.1V85.9C125 73 135.5 62.5 148.4 62.5H242.2C255.1 62.5 265.6 73 265.6 85.9V430.5L647.5 77C687.7 43.6 750 71.5 750 125V875C750 928.5 687.7 956.4 647.5 923L265.6 571.7V914.1C265.6 927 255.1 937.5 242.2 937.5H148.4C135.5 937.5 125 927 125 914.1Z", - "width": 875 - }, - "search": [ - "step-backward" - ] - }, - { - "uid": "9a0d3eec2bb3765a51f82dadf9a10bd1", - "css": "step-forward", - "code": 59436, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M750 85.9V914.1C750 927 739.5 937.5 726.6 937.5H632.8C619.9 937.5 609.4 927 609.4 914.1V569.5L227.5 923C187.3 956.4 125 928.5 125 875V125C125 71.5 187.3 43.6 227.5 77L609.4 428.3V85.9C609.4 73 619.9 62.5 632.8 62.5H726.6C739.5 62.5 750 73 750 85.9Z", - "width": 875 - }, - "search": [ - "step-forward" - ] - }, - { - "uid": "9f8f8db47c9da55d8ea2e0170476eb39", - "css": "play", - "code": 59395, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M828.9 419.3L141.4 12.9C85.5-20.1 0 11.9 0 93.6V906.3C0 979.5 79.5 1023.6 141.4 986.9L828.9 580.7C890.2 544.5 890.4 455.5 828.9 419.3Z", - "width": 875 - }, - "search": [ - "play" - ] - }, - { - "uid": "e0e61c06ec2c00a0c7b604fcc20b133c", - "css": "comments", - "code": 59437, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M812.5 375C812.5 202.3 630.7 62.5 406.3 62.5S0 202.3 0 375C0 442 27.5 503.7 74.2 554.7 48 613.7 4.9 660.5 4.3 661.1 0 665.6-1.2 672.3 1.4 678.1S9.4 687.5 15.6 687.5C87.1 687.5 146.3 663.5 188.9 638.7 251.8 669.3 326.2 687.5 406.3 687.5 630.7 687.5 812.5 547.7 812.5 375ZM1050.8 804.7C1097.5 753.9 1125 692 1125 625 1125 494.3 1020.5 382.4 872.5 335.7 874.2 348.6 875 361.7 875 375 875 581.8 664.6 750 406.3 750 385.2 750 364.6 748.4 344.3 746.3 405.9 858.6 550.4 937.5 718.8 937.5 798.8 937.5 873.2 919.5 936.1 888.7 978.7 913.5 1037.9 937.5 1109.4 937.5 1115.6 937.5 1121.3 933.8 1123.6 928.1 1126.2 922.5 1125 915.8 1120.7 911.1 1120.1 910.5 1077 863.9 1050.8 804.7Z", - "width": 1125 - }, - "search": [ - "comments" - ] - }, - { - "uid": "7c8b7bccd2548457f00645f3954e2863", - "css": "heading", - "code": 59438, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M968.8 156.3V93.8C968.8 76.5 954.8 62.5 937.5 62.5H625C607.7 62.5 593.8 76.5 593.8 93.8V156.3C593.8 173.5 607.7 187.5 625 187.5H698.5V437.5H301.5V187.5H375C392.3 187.5 406.3 173.5 406.3 156.3V93.8C406.3 76.5 392.3 62.5 375 62.5H62.5C45.2 62.5 31.3 76.5 31.3 93.8V156.3C31.3 173.5 45.2 187.5 62.5 187.5H135.3V812.5H62.5C45.2 812.5 31.3 826.5 31.3 843.8V906.3C31.3 923.5 45.2 937.5 62.5 937.5H375C392.3 937.5 406.3 923.5 406.3 906.3V843.8C406.3 826.5 392.3 812.5 375 812.5H301.5V562.5H698.5V812.5H625C607.7 812.5 593.8 826.5 593.8 843.8V906.3C593.8 923.5 607.7 937.5 625 937.5H937.5C954.8 937.5 968.8 923.5 968.8 906.3V843.8C968.8 826.5 954.8 812.5 937.5 812.5H864.7V187.5H937.5C954.8 187.5 968.8 173.5 968.8 156.3Z", - "width": 1000 - }, - "search": [ - "heading" - ] - }, - { - "uid": "c7ead3a5bb66fddf32a7899a0f3fbb6c", - "css": "align-center", - "code": 59396, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M687.5 85.9V164.1C687.5 181.3 673.5 195.3 656.3 195.3H218.8C201.5 195.3 187.5 181.3 187.5 164.1V85.9C187.5 68.7 201.5 54.7 218.8 54.7H656.3C673.5 54.7 687.5 68.7 687.5 85.9ZM31.3 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H31.3C14 304.7 0 318.7 0 335.9V414.1C0 431.3 14 445.3 31.3 445.3ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM656.3 554.7H218.8C201.5 554.7 187.5 568.7 187.5 585.9V664.1C187.5 681.3 201.5 695.3 218.8 695.3H656.3C673.5 695.3 687.5 681.3 687.5 664.1V585.9C687.5 568.7 673.5 554.7 656.3 554.7Z", - "width": 875 - }, - "search": [ - "align-center" - ] - }, - { - "uid": "e8e401b7ba1649fce89eb32cc85cb50d", - "css": "align-justify", - "code": 59397, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M0 164.1V85.9C0 68.7 14 54.7 31.3 54.7H843.8C861 54.7 875 68.7 875 85.9V164.1C875 181.3 861 195.3 843.8 195.3H31.3C14 195.3 0 181.3 0 164.1ZM31.3 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H31.3C14 304.7 0 318.7 0 335.9V414.1C0 431.3 14 445.3 31.3 445.3ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM31.3 695.3H843.8C861 695.3 875 681.3 875 664.1V585.9C875 568.7 861 554.7 843.8 554.7H31.3C14 554.7 0 568.7 0 585.9V664.1C0 681.3 14 695.3 31.3 695.3Z", - "width": 875 - }, - "search": [ - "align-justify" - ] - }, - { - "uid": "eb15f17c97d08c4151e60b4b2f630fb5", - "css": "align-left", - "code": 59398, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M562.5 85.9V164.1C562.5 181.3 548.5 195.3 531.3 195.3H31.3C14 195.3 0 181.3 0 164.1V85.9C0 68.7 14 54.7 31.3 54.7H531.3C548.5 54.7 562.5 68.7 562.5 85.9ZM0 335.9V414.1C0 431.3 14 445.3 31.3 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H31.3C14 304.7 0 318.7 0 335.9ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM531.3 554.7H31.3C14 554.7 0 568.7 0 585.9V664.1C0 681.3 14 695.3 31.3 695.3H531.3C548.5 695.3 562.5 681.3 562.5 664.1V585.9C562.5 568.7 548.5 554.7 531.3 554.7Z", - "width": 875 - }, - "search": [ - "align-left" - ] - }, - { - "uid": "48f22afc96cf17626d8da876b9b463dc", - "css": "align-right", - "code": 59399, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M312.5 164.1V85.9C312.5 68.7 326.5 54.7 343.8 54.7H843.8C861 54.7 875 68.7 875 85.9V164.1C875 181.3 861 195.3 843.8 195.3H343.8C326.5 195.3 312.5 181.3 312.5 164.1ZM31.3 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H31.3C14 304.7 0 318.7 0 335.9V414.1C0 431.3 14 445.3 31.3 445.3ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM343.8 695.3H843.8C861 695.3 875 681.3 875 664.1V585.9C875 568.7 861 554.7 843.8 554.7H343.8C326.5 554.7 312.5 568.7 312.5 585.9V664.1C312.5 681.3 326.5 695.3 343.8 695.3Z", - "width": 875 - }, - "search": [ - "align-right" - ] - }, - { - "uid": "b2d03fd882d7c96479a3c6c1dbc1a889", - "css": "file-powerpoint", - "code": 59485, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M378.3 529.7C395.5 529.7 408.6 535 418 545.5 436.7 566.8 437.1 609.4 417.6 631.6 408 642.6 394.3 648.2 376.4 648.2H323.8V529.7H378.3ZM736.3 205.1L544.9 13.7C536.1 4.9 524.2 0 511.7 0H500V250H750V238.1C750 225.8 745.1 213.9 736.3 205.1ZM437.5 265.6V0H46.9C20.9 0 0 20.9 0 46.9V953.1C0 979.1 20.9 1000 46.9 1000H703.1C729.1 1000 750 979.1 750 953.1V312.5H484.4C458.6 312.5 437.5 291.4 437.5 265.6ZM541 588.3C541 764.6 367.6 739.8 324 739.8V851.6C324 864.5 313.5 875 300.6 875H240.4C227.5 875 217 864.5 217 851.6V461.3C217 448.4 227.5 437.9 240.4 437.9H398.6C485.5 437.9 541 502 541 588.3Z", - "width": 750 - }, - "search": [ - "file-powerpoint" - ] - }, - { - "uid": "c59ea6604f4c8a3bebd9cb24630f0e3b", - "css": "superscript", - "code": 59443, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M531.3 375H398.8C388.1 375 377.9 380.7 372.3 389.8L293.6 516.8C289.1 523.8 284.8 531.1 281.4 537.3 278.1 531.1 274.2 524 270.3 517L192.4 389.8C186.7 380.7 176.6 375 165.8 375H31.3C14.1 375 0 389.1 0 406.3V468.8C0 485.9 14.1 500 31.3 500H90L193.2 651 82.6 812.5H31.3C14.1 812.5 0 826.6 0 843.8V906.3C0 923.4 14.1 937.5 31.3 937.5H156.3C167 937.5 177.1 931.8 182.8 922.7L270.1 781.8C274.4 774.8 278.3 767.6 281.6 761.1 285.2 767.4 289.3 774.6 293.8 781.1L383 922.9C388.7 932 398.6 937.5 409.4 937.5H531.3C548.4 937.5 562.5 923.4 562.5 906.2V843.7C562.5 826.6 548.4 812.5 531.3 812.5H488.3L373.8 647.9 476.6 500H531.3C548.4 500 562.5 485.9 562.5 468.8V406.3C562.5 389.1 548.4 375 531.3 375ZM968.8 500H771.9C778.7 479.5 808.6 458.4 842.8 436.7 875.2 416 912.1 392.6 941 360.7 975.2 323.4 991.6 282.2 991.6 234.6 991.6 116.2 892.6 62.5 800.6 62.5 717.6 62.5 651.4 105.5 616.2 160.9 607 175.2 611.1 194.1 625.2 203.7L684.4 243.4C698 252.5 716.6 249.4 726.6 236.3 742.2 216 763.3 200.8 788.5 200.8 826.4 200.8 839.8 226 839.8 247.5 839.8 318.2 606.6 358.8 606.6 560 606.6 573 607.8 585.4 609.4 597.7 611.5 613.3 624.6 624.8 640.4 624.8H968.8C985.9 624.8 1000 610.7 1000 593.6V531.1C1000 514.1 985.9 500 968.8 500Z", - "width": 1000 - }, - "search": [ - "superscript" - ] - }, - { - "uid": "cb9e27f4e2c9fe6182e2351f9ad71c14", - "css": "subscript", - "code": 59444, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M531.3 62.5H398.8C388.1 62.5 377.9 68.2 372.3 77.3L293.6 204.3C289.1 211.3 284.8 218.6 281.4 224.8 278.1 218.6 274.2 211.5 270.3 204.5L192.4 77.3C186.7 68.2 176.6 62.5 165.8 62.5H31.3C14.1 62.5 0 76.6 0 93.8V156.3C0 173.4 14.1 187.5 31.3 187.5H90L193.2 338.5 82.6 500H31.3C14.1 500 0 514.1 0 531.3V593.8C0 610.9 14.1 625 31.3 625H156.3C167 625 177.1 619.3 182.8 610.2L270.1 469.3C274.4 462.3 278.3 455.1 281.6 448.6 285.2 454.9 289.3 462.1 293.8 468.6L383 610.4C388.7 619.5 398.6 625 409.4 625H531.3C548.4 625 562.5 610.9 562.5 593.8V531.3C562.5 514.1 548.4 500 531.3 500H488.3L373.8 335.4 476.6 187.5H531.3C548.4 187.5 562.5 173.4 562.5 156.3V93.8C562.5 76.6 548.4 62.5 531.3 62.5ZM968.8 812.5H771.9C778.7 792 808.6 770.9 842.8 749.2 875.2 728.5 912.1 705.1 941 673.2 975.2 635.9 991.6 594.7 991.6 547.1 991.6 428.7 892.6 375 800.6 375 717.6 375 651.4 418 616.2 473.4 607 487.7 611.1 506.6 625.2 516.2L684.4 555.9C698 565 716.6 561.9 726.6 548.8 742.2 528.5 763.3 513.3 788.5 513.3 826.4 513.3 839.8 538.5 839.8 560 839.8 630.7 606.6 671.3 606.6 872.5 606.6 885.5 607.8 897.9 609.4 910.2 611.5 925.8 624.6 937.3 640.4 937.3H968.8C985.9 937.3 1000 923.2 1000 906.1V843.6C1000 826.6 985.9 812.5 968.8 812.5Z", - "width": 1000 - }, - "search": [ - "subscript" - ] - }, - { - "uid": "2f9853bb94503f2e5149dddae69657f6", - "css": "gauge", - "code": 59446, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M562.5 62.5C251.8 62.5 0 314.3 0 625 0 728.1 27.8 824.7 76.3 907.8 87.2 926.6 108.1 937.5 129.9 937.5H995.1C1016.9 937.5 1037.8 926.6 1048.7 907.8 1097.2 824.7 1125 728.1 1125 625 1125 314.3 873.2 62.5 562.5 62.5ZM562.5 187.5C591.2 187.5 614.4 207.3 621.7 233.7 619.6 238.1 616.6 242 615 246.7L597 300.8C587 307.6 575.5 312.5 562.5 312.5 528 312.5 500 284.5 500 250S528 187.5 562.5 187.5ZM187.5 750C153 750 125 722 125 687.5S153 625 187.5 625 250 653 250 687.5 222 750 187.5 750ZM281.3 437.5C246.7 437.5 218.8 409.5 218.8 375S246.7 312.5 281.3 312.5 343.8 340.5 343.8 375 315.8 437.5 281.3 437.5ZM763.2 296.1L643.4 655.4C670.2 678.4 687.5 712 687.5 750 687.5 772.9 680.9 794 670.2 812.5H454.8C444.1 794 437.5 772.9 437.5 750 437.5 683.7 489.3 630 554.5 625.8L674.3 266.4C682.4 241.9 708.9 228.4 733.6 236.8 758.1 245 771.4 271.5 763.2 296.1ZM791.9 407.8L822.2 316.9C828.9 314.4 836.1 312.5 843.8 312.5 878.3 312.5 906.3 340.5 906.3 375S878.3 437.5 843.8 437.5C821.5 437.5 802.9 425.3 791.9 407.8ZM937.5 750C903 750 875 722 875 687.5S903 625 937.5 625 1000 653 1000 687.5 972 750 937.5 750Z", - "width": 1125 - }, - "search": [ - "tachometer-alt" - ] - }, - { - "uid": "9f61e6a7ba9b929596aba1e946386ca1", - "css": "exchange-alt", - "code": 59447, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M0 328.1V296.9C0 271 21 250 46.9 250H750V156.3C750 114.5 800.6 93.7 830 123.1L986.3 279.4C1004.6 297.7 1004.6 327.3 986.3 345.6L830 501.9C800.7 531.2 750 510.7 750 468.8V375H46.9C21 375 0 354 0 328.1ZM953.1 625H250V531.3C250 489.6 199.5 468.6 170 498.1L13.7 654.4C-4.6 672.7-4.6 702.3 13.7 720.6L170 876.9C199.3 906.2 250 885.6 250 843.8V750H953.1C979 750 1000 729 1000 703.1V671.9C1000 646 979 625 953.1 625Z", - "width": 1000 - }, - "search": [ - "exchange-alt" - ] - }, - { - "uid": "762c1dbaf1d25d6f7365934483e90285", - "css": "text-width", - "code": 59448, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M31.3 62.5H843.8C861 62.5 875 76.5 875 93.8V281.3C875 298.5 861 312.5 843.8 312.5H774.4C757.2 312.5 743.2 298.5 743.2 281.3V187.5H511.2V437.5H578.1C595.4 437.5 609.4 451.5 609.4 468.8V531.3C609.4 548.5 595.4 562.5 578.1 562.5H296.9C279.6 562.5 265.6 548.5 265.6 531.3V468.8C265.6 451.5 279.6 437.5 296.9 437.5H363.8V187.5H131.8V281.3C131.8 298.5 117.8 312.5 100.6 312.5H31.3C14 312.5 0 298.5 0 281.3V93.8C0 76.5 14 62.5 31.3 62.5ZM865.8 727.9L709.6 571.7C691.4 553.4 656.3 563.2 656.3 593.8V687.5H218.8V593.8C218.8 565.8 184.9 552.1 165.4 571.7L9.2 727.9C-3 740.1-3.1 759.9 9.2 772.1L165.4 928.3C183.6 946.6 218.8 936.8 218.8 906.3V812.5H656.3V906.2C656.3 934.2 690.1 947.9 709.6 928.3L865.8 772.1C878 759.9 878.1 740.1 865.8 727.9Z", - "width": 875 - }, - "search": [ - "text-width" - ] - }, - { - "uid": "db94b783531717f104b39b398db3d0f2", - "css": "sync-alt", - "code": 59392, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M724.1 260.3C663 203.1 583.8 171.8 499.7 171.9 348.4 172 217.8 275.7 181.8 419.6 179.1 430.1 169.8 437.5 159 437.5H47.1C32.4 437.5 21.3 424.2 24 409.8 66.3 185.4 263.3 15.6 500 15.6 629.8 15.6 747.6 66.7 834.6 149.8L904.4 80C933.9 50.5 984.4 71.4 984.4 113.2V375C984.4 400.9 963.4 421.9 937.5 421.9H675.7C633.9 421.9 613 371.4 642.5 341.9L724.1 260.3ZM62.5 578.1H324.3C366.1 578.1 387 628.6 357.5 658.1L275.9 739.7C337 796.9 416.2 828.2 500.3 828.1 651.5 828 782.2 724.3 818.2 580.4 820.9 569.9 830.2 562.5 841 562.5H952.9C967.6 562.5 978.7 575.8 976 590.2 933.7 814.6 736.7 984.4 500 984.4 370.2 984.4 252.4 933.3 165.4 850.2L95.6 920C66.1 949.5 15.6 928.6 15.6 886.8V625C15.6 599.1 36.6 578.1 62.5 578.1Z", - "width": 1000 - }, - "search": [ - "sync-alt" - ] - }, - { - "uid": "f0ec8e32814f630fb6234b84cb5d4672", - "css": "picture", - "code": 59450, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M906.3 875H93.8C42 875 0 833 0 781.3V218.8C0 167 42 125 93.8 125H906.3C958 125 1000 167 1000 218.8V781.3C1000 833 958 875 906.3 875ZM218.8 234.4C158.3 234.4 109.4 283.3 109.4 343.8S158.3 453.1 218.8 453.1 328.1 404.2 328.1 343.8 279.2 234.4 218.8 234.4ZM125 750H875V531.3L704.1 360.3C694.9 351.2 680.1 351.2 670.9 360.3L406.3 625 297.8 516.6C288.7 507.4 273.8 507.4 264.7 516.6L125 656.3V750Z", - "width": 1000 - }, - "search": [ - "image" - ] - }, - { - "uid": "5c54164453ce690dddffa89377692bff", - "css": "file-code", - "code": 59401, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M750 238.2V250H500V0H511.8C524.3 0 536.2 4.9 545 13.7L736.3 205A46.9 46.9 0 0 1 750 238.2ZM484.4 312.5C458.6 312.5 437.5 291.4 437.5 265.6V0H46.9C21 0 0 21 0 46.9V953.1C0 979 21 1000 46.9 1000H703.1C729 1000 750 979 750 953.1V312.5H484.4ZM240.6 782.2A10.5 10.5 0 0 1 225.7 782.7L99 663.9A10.5 10.5 0 0 1 99 648.6L225.7 529.8A10.5 10.5 0 0 1 240.6 530.3L278.9 571.1A10.5 10.5 0 0 1 278.2 586.2L198.5 656.3 278.2 726.3A10.5 10.5 0 0 1 278.9 741.4L240.6 782.2ZM340.8 880.8L287.2 865.3A10.6 10.6 0 0 1 280 852.2L400 438.9A10.6 10.6 0 0 1 413.1 431.7L466.7 447.2A10.5 10.5 0 0 1 473.9 460.3L353.9 873.6A10.5 10.5 0 0 1 340.8 880.8ZM654.9 663.9L528.2 782.7A10.5 10.5 0 0 1 513.3 782.2L475 741.4A10.5 10.5 0 0 1 475.8 726.3L555.4 656.3 475.8 586.2A10.5 10.5 0 0 1 475 571.1L513.3 530.3A10.5 10.5 0 0 1 528.2 529.8L654.9 648.6A10.5 10.5 0 0 1 654.9 663.9Z", - "width": 750 - }, - "search": [ - "file-code" - ] - }, - { - "uid": "d7271d490b71df4311e32cdacae8b331", - "css": "home", - "code": 59403, - "src": "fontawesome" - } - ] -} \ No newline at end of file + "name": "fontawesome-etherpad", + "css_prefix_text": "buttonicon-", + "css_use_suffix": false, + "hinting": true, + "units_per_em": 1000, + "ascent": 850, + "glyphs": [ + { + "uid": "bf882b30900da12fca090d9796bc3030", + "css": "mail", + "code": 59402, + "src": "fontawesome" + }, + { + "uid": "7277ded7695b2a307a5f9d50097bb64c", + "css": "print", + "code": 59393, + "src": "fontawesome" + }, + { + "uid": "9396b2d8849e0213a0f11c5fd7fcc522", + "css": "tasks", + "code": 59442, + "src": "fontawesome" + }, + { + "uid": "fa9a0b7e788c2d78e24cef1de6b70e80", + "css": "brush", + "code": 59440, + "src": "fontawesome" + }, + { + "uid": "be13b8c668eb18839d5d53107725f1de", + "css": "slideshare", + "code": 59441, + "src": "fontawesome" + }, + { + "uid": "d35a1d35efeb784d1dc9ac18b9b6c2b6", + "css": "pencil", + "code": 59449, + "src": "fontawesome" + }, + { + "uid": "8fb55fd696d9a0f58f3b27c1d8633750", + "css": "table", + "code": 61646, + "src": "fontawesome" + }, + { + "uid": "1569a5b2bebe7e28bb0d26ddeca34fc8", + "css": "video", + "code": 59451, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M656.6 125H93.4C41.8 125 0 166.8 0 218.4V781.6C0 833.2 41.8 875 93.4 875H656.6C708.2 875 750 833.2 750 781.6V218.4C750 166.8 708.2 125 656.6 125ZM1026.6 198.6L812.5 346.3V653.7L1026.6 801.2C1068 829.7 1125 800.6 1125 750.8V249C1125 199.4 1068.2 170.1 1026.6 198.6Z", + "width": 1125 + }, + "search": ["video"] + }, + { + "uid": "8fe2c571b78d019e24cab0b780cb61d6", + "css": "video-slash", + "code": 59452, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M1237.9 894.7L1130.5 811.7C1160.5 809 1187.5 785 1187.5 751V249C1187.5 199.2 1130.7 170.1 1089.1 198.6L875 346.3V614.3L812.5 566V218.4C812.5 166.8 770.7 125 719.1 125H242L88.9 6.6C75.2-3.9 55.7-1.6 44.9 12.1L6.6 61.3C-3.9 75-1.6 94.5 12.1 105.1L83.4 160.2 812.5 723.8 1161.1 993.4C1174.8 1003.9 1194.3 1001.6 1205.1 987.9L1243.4 938.5C1254.1 925 1251.6 905.3 1237.9 894.7ZM62.5 781.6C62.5 833.2 104.3 875 155.9 875H719.1C741 875 760.9 867.2 777 854.5L62.5 302.1V781.6Z", + "width": 1250 + }, + "search": ["video-slash"] + }, + { + "uid": "d8020fccc088a524f7cc6db1f329cb3e", + "css": "microphone-alt", + "code": 59453, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M656.3 375H625C607.7 375 593.8 389 593.8 406.3V500C593.8 646.1 467.8 763.3 318.8 748.8 188.9 736.1 93.8 619.4 93.8 488.9V406.3C93.8 389 79.8 375 62.5 375H31.3C14 375 0 389 0 406.3V484.7C0 659.8 124.9 815.8 296.9 839.6V906.3H187.5C170.2 906.3 156.3 920.2 156.3 937.5V968.8C156.3 986 170.2 1000 187.5 1000H500C517.3 1000 531.3 986 531.3 968.8V937.5C531.3 920.2 517.3 906.3 500 906.3H390.6V840.3C558 817.3 687.5 673.6 687.5 500V406.3C687.5 389 673.5 375 656.3 375ZM343.8 687.5C447.3 687.5 531.3 603.6 531.3 500H364.6C353.1 500 343.8 493 343.8 484.4V453.1C343.8 444.5 353.1 437.5 364.6 437.5H531.3V375H364.6C353.1 375 343.8 368 343.8 359.4V328.1C343.8 319.5 353.1 312.5 364.6 312.5H531.3V250H364.6C353.1 250 343.8 243 343.8 234.4V203.1C343.8 194.5 353.1 187.5 364.6 187.5H531.3C531.3 83.9 447.3 0 343.8 0S156.3 83.9 156.3 187.5V500C156.3 603.6 240.2 687.5 343.8 687.5Z", + "width": 688 + }, + "search": ["microphone-alt"] + }, + { + "uid": "7d9dd931e0e6305cc5eed55efa435d7c", + "css": "microphone-alt-slash", + "code": 59454, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M1237.9 894.7L930.2 656.9C954.6 609.8 968.8 556.6 968.8 500V406.3C968.8 389 954.8 375 937.5 375H906.3C889 375 875 389 875 406.3V500C875 535 867.3 568 854.1 598L802.2 558C808.3 539.6 812.5 520.4 812.5 500H727.2L646.4 437.5H812.5V375H645.8C634.3 375 625 368 625 359.4V328.1C625 319.5 634.3 312.5 645.8 312.5H812.5V250H645.8C634.3 250 625 243 625 234.4V203.1C625 194.5 634.3 187.5 645.8 187.5H812.5C812.5 84 728.6 0 625 0S437.5 84 437.5 187.5V276.1L88.8 6.6C75.2-4 55.5-1.6 44.9 12.1L6.6 61.4C-4 75-1.6 94.7 12.1 105.3L1161.2 993.4C1174.8 1004 1194.5 1001.6 1205.1 987.9L1243.4 938.6C1254 925 1251.6 905.3 1237.9 894.7ZM781.3 906.3H671.9V840.3C694.7 837.1 717 831.9 738.2 824.5L639.8 748.4C626.7 749.2 613.6 750.1 600 748.8 490.9 738.1 407.2 653.8 382.9 549.9L281.3 471.3V484.7C281.3 659.8 406.2 815.8 578.1 839.6V906.3H468.8C451.5 906.3 437.5 920.2 437.5 937.5V968.8C437.5 986 451.5 1000 468.8 1000H781.3C798.5 1000 812.5 986 812.5 968.8V937.5C812.5 920.2 798.5 906.3 781.3 906.3Z", + "width": 1250 + }, + "search": ["microphone-alt-slash"] + }, + { + "uid": "63aa8ba99d3f31973dd2ef65274a03bd", + "css": "compress", + "code": 59455, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M851.6 375H609.4C583.4 375 562.5 354.1 562.5 328.1V85.9C562.5 73 573 62.5 585.9 62.5H664.1C677 62.5 687.5 73 687.5 85.9V250H851.6C864.5 250 875 260.5 875 273.4V351.6C875 364.5 864.5 375 851.6 375ZM312.5 328.1V85.9C312.5 73 302 62.5 289.1 62.5H210.9C198 62.5 187.5 73 187.5 85.9V250H23.4C10.5 250 0 260.5 0 273.4V351.6C0 364.5 10.5 375 23.4 375H265.6C291.6 375 312.5 354.1 312.5 328.1ZM312.5 914.1V671.9C312.5 645.9 291.6 625 265.6 625H23.4C10.5 625 0 635.5 0 648.4V726.6C0 739.5 10.5 750 23.4 750H187.5V914.1C187.5 927 198 937.5 210.9 937.5H289.1C302 937.5 312.5 927 312.5 914.1ZM687.5 914.1V750H851.6C864.5 750 875 739.5 875 726.6V648.4C875 635.5 864.5 625 851.6 625H609.4C583.4 625 562.5 645.9 562.5 671.9V914.1C562.5 927 573 937.5 585.9 937.5H664.1C677 937.5 687.5 927 687.5 914.1Z", + "width": 875 + }, + "search": ["compress"] + }, + { + "uid": "d71c270fcbdffa89ee7b646e9d5a2667", + "css": "expand", + "code": 59456, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M0 351.6V109.4C0 83.4 20.9 62.5 46.9 62.5H289.1C302 62.5 312.5 73 312.5 85.9V164.1C312.5 177 302 187.5 289.1 187.5H125V351.6C125 364.5 114.5 375 101.6 375H23.4C10.5 375 0 364.5 0 351.6ZM562.5 85.9V164.1C562.5 177 573 187.5 585.9 187.5H750V351.6C750 364.5 760.5 375 773.4 375H851.6C864.5 375 875 364.5 875 351.6V109.4C875 83.4 854.1 62.5 828.1 62.5H585.9C573 62.5 562.5 73 562.5 85.9ZM851.6 625H773.4C760.5 625 750 635.5 750 648.4V812.5H585.9C573 812.5 562.5 823 562.5 835.9V914.1C562.5 927 573 937.5 585.9 937.5H828.1C854.1 937.5 875 916.6 875 890.6V648.4C875 635.5 864.5 625 851.6 625ZM312.5 914.1V835.9C312.5 823 302 812.5 289.1 812.5H125V648.4C125 635.5 114.5 625 101.6 625H23.4C10.5 625 0 635.5 0 648.4V890.6C0 916.6 20.9 937.5 46.9 937.5H289.1C302 937.5 312.5 927 312.5 914.1Z", + "width": 875 + }, + "search": ["expand"] + }, + { + "uid": "5d2d07f112b8de19f2c0dbfec3e42c05", + "css": "spin5", + "code": 59457, + "src": "fontelico" + }, + { + "uid": "54cecf7a3401a3458fe7ea001e622d39", + "css": "trash", + "code": 59406, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M843.8 62.5H609.4L591 26A46.9 46.9 0 0 0 549 0H325.8A46.3 46.3 0 0 0 284 26L265.6 62.5H31.3A31.3 31.3 0 0 0 0 93.8V156.3A31.3 31.3 0 0 0 31.3 187.5H843.8A31.3 31.3 0 0 0 875 156.3V93.8A31.3 31.3 0 0 0 843.8 62.5ZM103.9 912.1A93.8 93.8 0 0 0 197.5 1000H677.5A93.8 93.8 0 0 0 771.1 912.1L812.5 250H62.5Z", + "width": 875 + }, + "search": ["trash"] + }, + { + "uid": "f99ec3e571ced9cd747e2b34d8c03436", + "css": "list-ul", + "code": 59434, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M187.5 187.5C187.5 239.3 145.5 281.3 93.8 281.3S0 239.3 0 187.5 42 93.8 93.8 93.8 187.5 135.7 187.5 187.5ZM93.8 406.3C42 406.3 0 448.2 0 500S42 593.8 93.8 593.8 187.5 551.8 187.5 500 145.5 406.3 93.8 406.3ZM93.8 718.8C42 718.8 0 760.7 0 812.5S42 906.3 93.8 906.3 187.5 864.3 187.5 812.5 145.5 718.8 93.8 718.8ZM281.3 257.8H968.8C986 257.8 1000 243.8 1000 226.6V148.4C1000 131.2 986 117.2 968.8 117.2H281.3C264 117.2 250 131.2 250 148.4V226.6C250 243.8 264 257.8 281.3 257.8ZM281.3 570.3H968.8C986 570.3 1000 556.3 1000 539.1V460.9C1000 443.7 986 429.7 968.8 429.7H281.3C264 429.7 250 443.7 250 460.9V539.1C250 556.3 264 570.3 281.3 570.3ZM281.3 882.8H968.8C986 882.8 1000 868.8 1000 851.6V773.4C1000 756.2 986 742.2 968.8 742.2H281.3C264 742.2 250 756.2 250 773.4V851.6C250 868.8 264 882.8 281.3 882.8Z", + "width": 1000 + }, + "search": ["list-ul"] + }, + { + "uid": "d921283a409a4e9a51ff1632b200c23d", + "css": "eye-slash", + "code": 59459, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M625 781.3C476.9 781.3 356.9 666.6 345.9 521.3L141 362.9C114.1 396.7 89.3 432.4 69.3 471.5A63.2 63.2 0 0 0 69.3 528.5C175.2 735.2 384.9 875 625 875 677.6 875 728.3 867.2 777.1 854.6L675.8 776.2A281.5 281.5 0 0 1 625 781.3ZM1237.9 894.7L1022 727.9A647 647 0 0 0 1180.7 528.5 63.2 63.2 0 0 0 1180.7 471.5C1074.8 264.8 865.1 125 625 125A601.9 601.9 0 0 0 337.3 198.6L88.8 6.6A31.3 31.3 0 0 0 44.9 12.1L6.6 61.4A31.3 31.3 0 0 0 12.1 105.3L1161.2 993.4A31.3 31.3 0 0 0 1205.1 987.9L1243.4 938.6A31.3 31.3 0 0 0 1237.9 894.7ZM879.1 617.4L802.3 558A185.1 185.1 0 0 0 812.5 500 185.1 185.1 0 0 0 575.6 319.9 93.1 93.1 0 0 1 593.8 375 91.1 91.1 0 0 1 590.7 394.5L447 283.4A277.9 277.9 0 0 1 625 218.8 281.1 281.1 0 0 1 906.3 500C906.3 542.2 895.9 581.6 879.1 617.4Z", + "width": 1250 + }, + "search": ["eye-slash"] + }, + { + "uid": "9f79bb02a62542500d6396747bfbdad5", + "css": "list-ol", + "code": 59460, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M6.4 272.5C6.4 257.9 14 249.9 29 249.9H58.5V172C58.5 161.9 59.6 151.5 59.6 151.5H58.9S55.4 156.7 53.3 158.8C44.6 167.2 32.8 167.5 22.7 156.7L11.9 144.6C1.5 134.1 2.2 122.7 13 112.6L55.4 73.6C64.1 65.6 71.7 62.5 83.6 62.5H107.2C122.2 62.5 130.2 70.1 130.2 85.1V249.9H160.4C175.4 249.9 183 257.9 183 272.5V289.9C183 304.5 175.4 312.5 160.4 312.5H29C14 312.5 6.4 304.5 6.4 289.9V272.5ZM4.3 594.9C4.3 502.6 103.8 484.8 103.8 459.8 103.8 445.8 92.2 442.7 85.7 442.7 79.6 442.7 73.1 444.8 67.2 450.2 57.3 459.8 46.7 463.9 35.8 455L19 441.7C7.7 432.8 5 422.5 13.6 411.6 26.5 394.5 50.8 375 92.6 375 130.5 375 179.4 395.5 179.4 452.3 179.4 527.2 88.1 542.9 84.3 563.4H160.6C175.3 563.4 183.2 571.3 183.2 585.7V602.8C183.2 617.1 175.3 625 160.6 625H27.9C14.2 625 4.3 617.1 4.3 602.8V594.9ZM11 887.9L22 869.8C29.5 856.8 39.8 856.1 52.4 863.6 62 867.7 71.2 869.8 80.5 869.8 100.3 869.8 108.5 862.9 108.5 853.7 108.5 840.7 97.6 835.9 77.4 835.9H68.2C56.5 835.9 50 831.8 44.2 820.5L42.2 816.8C37.4 807.5 39.8 797.6 47.6 787.7L58.6 774C71.9 757.6 82.5 747.7 82.5 747.7V747S74.3 749.1 57.9 749.1H32.6C17.9 749.1 10.4 741.2 10.4 726.8V709.7C10.4 695 17.9 687.5 32.6 687.5H146.8C161.5 687.5 169 695.4 169 709.7V716.2C169 727.5 166.3 735.4 159.1 743.9L124.9 783.3C163.2 793.2 181 823.3 181 851.3 181 893 153 937.5 86.3 937.5 53.8 937.5 31.2 928.3 16.2 919 4.9 910.8 3.9 899.9 11 887.9ZM281.3 257.8H968.8C986 257.8 1000 243.8 1000 226.6V148.4C1000 131.2 986 117.2 968.8 117.2H281.3C264 117.2 250 131.2 250 148.4V226.6C250 243.8 264 257.8 281.3 257.8ZM281.3 570.3H968.8C986 570.3 1000 556.3 1000 539.1V460.9C1000 443.7 986 429.7 968.8 429.7H281.3C264 429.7 250 443.7 250 460.9V539.1C250 556.3 264 570.3 281.3 570.3ZM281.3 882.8H968.8C986 882.8 1000 868.8 1000 851.6V773.4C1000 756.2 986 742.2 968.8 742.2H281.3C264 742.2 250 756.2 250 773.4V851.6C250 868.8 264 882.8 281.3 882.8Z", + "width": 1000 + }, + "search": ["list-ol"] + }, + { + "uid": "216f7d72d19fbfc4e319fe70240dc9fe", + "css": "bold", + "code": 59461, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M595.3 476.3C661 440.1 700.1 370.6 700.1 289.4 700.1 195.2 648.8 118.3 566.1 86 517.8 66.4 470.4 62.5 409.5 62.5H46.9C29.6 62.5 15.6 76.5 15.6 93.8V158.3C15.6 175.6 29.6 189.5 46.9 189.5H111.5V811.7H46.9C29.6 811.7 15.6 825.7 15.6 842.9V906.3C15.6 923.5 29.6 937.5 46.9 937.5H429.1C476.4 937.5 516.6 935 559.7 922.7 659.2 893 734.4 802 734.4 683.6 734.4 581.7 682.5 504.6 595.3 476.3ZM277.8 196.9H409.5C441.3 196.9 463.3 200.8 482.8 210 513.7 226.6 531.4 261.8 531.4 306.6 531.4 375 491.7 417.5 427.9 417.5H277.8V196.9ZM497.8 793.5C478 801.4 453.5 803.1 436.4 803.1H277.8V550.7H442.5C520 550.7 565.7 600.2 565.7 673.8 565.7 729.3 539 776.3 497.8 793.5Z", + "width": 750 + }, + "search": ["bold"] + }, + { + "uid": "0dbd89c5def7ede2cbbe99ef8effcbda", + "css": "underline", + "code": 59462, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M438 758.3C259 758.3 132.2 658.3 132.2 462.6V125H76.9C59.6 125 45.6 111 45.6 93.8V31.3C45.6 14 59.6 0 76.9 0H345.2C362.5 0 376.5 14 376.5 31.3V93.8C376.5 111 362.5 125 345.2 125H289V462.6C289 567.5 344.3 617.8 438 617.8 529.7 617.8 586.1 568.1 586.1 461.6V125H530.8C513.5 125 499.5 111 499.5 93.8V31.3C499.5 14 513.5 0 530.8 0H798.1C815.4 0 829.4 14 829.4 31.3V93.8C829.4 111 815.4 125 798.1 125H742.9V462.6C742.9 656.7 616.1 758.3 438 758.3ZM31.3 875H843.8C861 875 875 889 875 906.3V968.8C875 986 861 1000 843.8 1000H31.3C14 1000 0 986 0 968.8V906.3C0 889 14 875 31.3 875Z", + "width": 875 + }, + "search": ["underline"] + }, + { + "uid": "daa7f27064d8c218bf22731012103675", + "css": "italic", + "code": 59463, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M399.9 812.5H333.8L455.1 187.5H534.6A31.3 31.3 0 0 0 565.3 162.2L577.5 99.7C581.2 80.4 566.5 62.5 546.8 62.5H234.8A31.3 31.3 0 0 0 204.2 87.8L192 150.3C188.2 169.6 203 187.5 222.6 187.5H288.7L167.5 812.5H90.4A31.3 31.3 0 0 0 59.7 837.8L47.5 900.3C43.8 919.6 58.5 937.5 78.2 937.5H387.7A31.3 31.3 0 0 0 418.4 912.2L430.6 849.7C434.4 830.4 419.6 812.5 399.9 812.5Z", + "width": 625 + }, + "search": ["italic"] + }, + { + "uid": "638e629bf04f06f100d42a3b6c3afeaa", + "css": "strikethrough", + "code": 59464, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M968.8 562.5H31.3C14 562.5 0 548.5 0 531.3V468.8C0 451.5 14 437.5 31.3 437.5H968.8C986 437.5 1000 451.5 1000 468.8V531.3C1000 548.5 986 562.5 968.8 562.5ZM549.5 593.8C602.7 619 640.3 649.8 640.3 703.6 640.3 768.3 583.8 808.4 492.7 808.4 429.5 808.4 342.5 784.8 342.5 722V718.8C342.5 701.5 328.5 687.5 311.3 687.5H222.2C204.9 687.5 190.9 701.5 190.9 718.8V756.3C190.9 886.8 342.7 955.1 492.7 955.1 665.7 955.1 809.1 866.4 809.1 692.6 809.1 653.9 802 621.5 789.3 593.8H549.5ZM489 406.3C425.7 379.9 378 349.7 378 289.7 378 223.4 438.4 197.1 504.9 197.1 588.2 197.1 631.8 229.5 631.8 261.5V265.6C631.8 282.9 645.8 296.9 663 296.9H752.1C769.4 296.9 783.4 282.9 783.4 265.6V206.4C783.4 104 643.3 50.4 504.9 50.4 338.5 50.4 210.5 130.4 210.5 295.8 210.5 340.2 219.6 376.2 235.5 406.3H489Z", + "width": 1000 + }, + "search": ["strikethrough"] + }, + { + "uid": "1a1fa90cbaa7da526141f8be54d5491b", + "css": "indent", + "code": 59465, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M0 164.1V85.9C0 68.7 14 54.7 31.3 54.7H843.8C861 54.7 875 68.7 875 85.9V164.1C875 181.3 861 195.3 843.8 195.3H31.3C14 195.3 0 181.3 0 164.1ZM343.8 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H343.8C326.5 304.7 312.5 318.7 312.5 335.9V414.1C312.5 431.3 326.5 445.3 343.8 445.3ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM343.8 695.3H843.8C861 695.3 875 681.3 875 664.1V585.9C875 568.7 861 554.7 843.8 554.7H343.8C326.5 554.7 312.5 568.7 312.5 585.9V664.1C312.5 681.3 326.5 695.3 343.8 695.3ZM240.8 477.9L53.3 290.4C33.7 270.8 0 284.7 0 312.5V687.5C0 715.5 33.8 729.1 53.3 709.6L240.8 522.1C253.1 509.9 253.1 490.1 240.8 477.9Z", + "width": 875 + }, + "search": ["indent"] + }, + { + "uid": "8d1d056ea637f2f25e905cd5beac310e", + "css": "outdent", + "code": 59466, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M0 164.1V85.9C0 68.7 14 54.7 31.3 54.7H843.8C861 54.7 875 68.7 875 85.9V164.1C875 181.3 861 195.3 843.8 195.3H31.3C14 195.3 0 181.3 0 164.1ZM406.3 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H406.3C389 304.7 375 318.7 375 335.9V414.1C375 431.3 389 445.3 406.3 445.3ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM406.3 695.3H843.8C861 695.3 875 681.3 875 664.1V585.9C875 568.7 861 554.7 843.8 554.7H406.3C389 554.7 375 568.7 375 585.9V664.1C375 681.3 389 695.3 406.3 695.3ZM9.2 522.1L196.7 709.6C216.3 729.2 250 715.3 250 687.5V312.5C250 284.5 216.2 270.9 196.7 290.4L9.2 477.9C-3.1 490.1-3.1 509.9 9.2 522.1Z", + "width": 875 + }, + "search": ["outdent"] + }, + { + "uid": "097d911c1839d50e7183cfb6e7c16934", + "css": "undo-alt", + "code": 59467, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M499.1 15.6C369.7 15.9 252.2 66.9 165.4 149.8L95.6 80C66.1 50.5 15.6 71.4 15.6 113.2V375C15.6 400.9 36.6 421.9 62.5 421.9H324.3C366.1 421.9 387 371.4 357.5 341.9L275.9 260.3C336.2 203.9 414.2 172.6 497.1 171.9 677.6 170.3 829.7 316.4 828.1 502.8 826.6 679.7 683.2 828.1 500 828.1 419.7 828.1 343.8 799.5 283.9 747 274.7 738.8 260.7 739.3 252 748L174.5 825.5C165 835 165.4 850.5 175.4 859.6 261.3 937.1 375.1 984.4 500 984.4 767.5 984.4 984.4 767.5 984.4 500 984.4 232.8 766.3 15.1 499.1 15.6Z", + "width": 1000 + }, + "search": ["undo-alt"] + }, + { + "uid": "4bd031cc742bc0605f0d2a6c13eeb789", + "css": "redo-alt", + "code": 59468, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M500.9 15.6C630.3 15.9 747.8 66.9 834.6 149.8L904.4 80C933.9 50.5 984.4 71.4 984.4 113.2V375C984.4 400.9 963.4 421.9 937.5 421.9H675.7C633.9 421.9 613 371.4 642.5 341.9L724.1 260.3C663.8 203.9 585.8 172.6 502.9 171.9 322.4 170.3 170.3 316.4 171.9 502.8 173.4 679.7 316.8 828.1 500 828.1 580.3 828.1 656.2 799.5 716.1 747 725.3 738.8 739.3 739.3 748 748L825.5 825.5C835 835 834.6 850.5 824.6 859.6 738.7 937.1 624.9 984.4 500 984.4 232.5 984.4 15.6 767.5 15.6 500 15.6 232.8 233.7 15.1 500.9 15.6Z", + "width": 1000 + }, + "search": ["redo-alt"] + }, + { + "uid": "8a69d07fcdeb0deda9048dffdfeb03d3", + "css": "link", + "code": 59469, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M637.9 362.1C754.6 478.9 753 666.2 638.6 781.2 638.4 781.4 638.1 781.7 637.9 781.9L506.7 913.2C390.9 1028.9 202.6 1028.9 86.8 913.2-28.9 797.4-28.9 609.1 86.8 493.3L159.3 420.9C178.5 401.7 211.6 414.4 212.6 441.6 213.9 476.2 220.1 511 231.5 544.6 235.4 555.9 232.6 568.5 224.1 577L198.6 602.6C143.8 657.3 142.1 746.4 196.3 801.7 251.1 857.5 341 857.9 396.2 802.7L527.4 671.5C582.5 616.4 582.3 527.4 527.4 472.6 520.2 465.4 512.9 459.8 507.2 455.8A31.3 31.3 0 0 1 493.7 431.2C492.9 410.6 500.2 389.3 516.5 373L557.6 331.9C568.4 321.1 585.3 319.8 597.8 328.5A297.8 297.8 0 0 1 637.9 362.1ZM913.2 86.8C797.4-28.9 609.1-28.9 493.3 86.8L362.1 218.1C361.8 218.3 361.6 218.6 361.4 218.8 247 333.8 245.4 521.1 362.1 637.9A297.8 297.8 0 0 0 402.2 671.5C414.7 680.2 431.6 678.9 442.4 668.1L483.5 627C499.8 610.7 507.1 589.4 506.3 568.8A31.3 31.3 0 0 0 492.8 544.2C487.1 540.2 479.8 534.6 472.6 527.4 417.7 472.6 417.5 383.6 472.6 328.5L603.8 197.3C659 142.1 748.9 142.5 803.7 198.3 857.9 253.6 856.2 342.7 801.4 397.4L775.9 423C767.4 431.5 764.6 444.1 768.5 455.4 779.9 489 786.1 523.8 787.4 558.4 788.4 585.6 821.5 598.3 840.7 579.1L913.2 506.7C1028.9 390.9 1028.9 202.6 913.2 86.8Z", + "width": 1000 + }, + "search": ["link"] + }, + { + "uid": "195e10d964b70c44cde9513ec217cba4", + "css": "font", + "code": 59470, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M843.8 812.5H791.6L538.1 83.4C533.8 70.9 521.9 62.5 508.6 62.5H366.4C353.1 62.5 341.2 70.9 336.9 83.4L83.4 812.5H31.3C14.1 812.5 0 826.6 0 843.8V906.3C0 923.4 14.1 937.5 31.3 937.5H296.9C314.1 937.5 328.1 923.4 328.1 906.3V843.8C328.1 826.6 314.1 812.5 296.9 812.5H250L302 654.7H571.9L623.8 812.5H578.1C560.9 812.5 546.9 826.6 546.9 843.8V906.3C546.9 923.4 560.9 937.5 578.1 937.5H843.8C860.9 937.5 875 923.4 875 906.3V843.8C875 826.6 860.9 812.5 843.8 812.5ZM340.6 524L422.7 281.6C431.1 252 435.5 226.6 437.5 214.1 439.1 226.8 443.2 252.1 452.5 281.8L533.2 524Z", + "width": 875 + }, + "search": ["font"] + }, + { + "uid": "e9352fe9c753373d14694398ce8044fe", + "css": "comment-medical", + "code": 59471, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M500 62.5C223.9 62.5 0 244.4 0 468.8 0 565.5 41.8 654.3 111.3 724.1 86.8 822.4 5.3 910.2 4.3 911.1A15.6 15.6 0 0 0 15.6 937.5C145 937.5 242.2 875.5 290.2 837.1A595 595 0 0 0 500 875C776.2 875 1000 693.1 1000 468.8S776.2 62.5 500 62.5ZM687.5 515.6A15.6 15.6 0 0 1 671.9 531.3H562.5V640.6A15.6 15.6 0 0 1 546.9 656.3H453.1A15.6 15.6 0 0 1 437.5 640.6V531.3H328.1A15.6 15.6 0 0 1 312.5 515.6V421.9A15.6 15.6 0 0 1 328.1 406.3H437.5V296.9A15.6 15.6 0 0 1 453.1 281.3H546.9A15.6 15.6 0 0 1 562.5 296.9V406.3H671.9A15.6 15.6 0 0 1 687.5 421.9Z", + "width": 1000 + }, + "search": ["comment-medical"] + }, + { + "uid": "a5c7ef2089dd63c12d3328563fee2330", + "css": "comment", + "code": 59472, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M500 62.5C223.8 62.5 0 244.3 0 468.8 0 565.6 41.8 654.3 111.3 724 86.9 822.5 5.3 910.2 4.3 911.1 0 915.6-1.2 922.3 1.4 928.1S9.4 937.5 15.6 937.5C145.1 937.5 242.2 875.4 290.2 837.1 354.1 861.1 425 875 500 875 776.2 875 1000 693.2 1000 468.8S776.2 62.5 500 62.5Z", + "width": 1000 + }, + "search": ["comment"] + }, + { + "uid": "5455d3369b90673f0404f9290f40f074", + "css": "cog", + "code": 59473, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M952 616.6L868.7 568.6C877.1 523.2 877.1 476.8 868.7 431.4L952 383.4C961.5 377.9 965.8 366.6 962.7 356.1 941 286.5 904.1 223.6 855.9 171.3 848.4 163.3 836.3 161.3 827 166.8L743.8 214.8C708.8 184.8 668.6 161.5 625 146.3V50.4C625 39.5 617.4 29.9 606.6 27.5 535 11.5 461.5 12.3 393.4 27.5 382.6 29.9 375 39.5 375 50.4V146.5C331.6 161.9 291.4 185.2 256.3 215L173.2 167C163.7 161.5 151.8 163.3 144.3 171.5 96.1 223.6 59.2 286.5 37.5 356.2 34.2 366.8 38.7 378.1 48.2 383.6L131.4 431.6C123 477 123 523.4 131.4 568.8L48.2 616.8C38.7 622.3 34.4 633.6 37.5 644.1 59.2 713.7 96.1 776.6 144.3 828.9 151.8 836.9 163.9 838.9 173.2 833.4L256.4 785.4C291.4 815.4 331.6 838.7 375.2 853.9V950C375.2 960.9 382.8 970.5 393.6 972.9 465.2 988.9 538.7 988.1 606.8 972.9 617.6 970.5 625.2 960.9 625.2 950V853.9C668.6 838.5 708.8 815.2 743.9 785.4L827.1 833.4C836.7 838.9 848.6 837.1 856.1 828.9 904.3 776.8 941.2 713.9 962.9 644.1 965.8 633.4 961.5 622.1 952 616.6ZM500 656.3C413.9 656.3 343.8 586.1 343.8 500S413.9 343.8 500 343.8 656.3 413.9 656.3 500 586.1 656.3 500 656.3Z", + "width": 1000 + }, + "search": ["cog"] + }, + { + "uid": "320da42dd92a9773159f2e4037a1d1db", + "css": "text-height", + "code": 59474, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M31.3 62.5H593.8C611 62.5 625 76.5 625 93.8V281.3C625 298.5 611 312.5 593.8 312.5H524.4C507.2 312.5 493.2 298.5 493.2 281.3V187.5H386.2V812.5H453.1C470.4 812.5 484.4 826.5 484.4 843.8V906.3C484.4 923.5 470.4 937.5 453.1 937.5H171.9C154.6 937.5 140.6 923.5 140.6 906.3V843.8C140.6 826.5 154.6 812.5 171.9 812.5H238.8V187.5H131.8V281.3C131.8 298.5 117.8 312.5 100.6 312.5H31.3C14 312.5 0 298.5 0 281.3V93.8C0 76.5 14 62.5 31.3 62.5ZM959.6 71.7L1115.8 227.9C1135.4 247.4 1121.7 281.3 1093.7 281.3H1000V718.8H1093.8C1124.3 718.8 1134.1 753.9 1115.8 772.1L959.6 928.3C947.4 940.6 927.6 940.5 915.4 928.3L759.2 772.1C739.6 752.6 753.3 718.8 781.3 718.8H875V281.3H781.3C750.7 281.3 740.9 246.1 759.2 227.9L915.4 71.7C927.6 59.4 947.4 59.5 959.6 71.7Z", + "width": 1125 + }, + "search": ["text-height"] + }, + { + "uid": "bc0f1614c05e71b1c8beaf95bc900761", + "css": "share-alt", + "code": 59475, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M687.5 625C643.3 625 602.8 640.3 570.7 665.8L370.6 540.7A188.6 188.6 0 0 0 370.6 459.3L570.7 334.2C602.8 359.7 643.3 375 687.5 375 791.1 375 875 291.1 875 187.5S791.1 0 687.5 0 500 83.9 500 187.5C500 201.5 501.5 215.1 504.4 228.2L304.3 353.3C272.2 327.8 231.7 312.5 187.5 312.5 83.9 312.5 0 396.4 0 500S83.9 687.5 187.5 687.5C231.7 687.5 272.2 672.2 304.3 646.7L504.4 771.8A188.1 188.1 0 0 0 500 812.5C500 916.1 583.9 1000 687.5 1000S875 916.1 875 812.5 791.1 625 687.5 625Z", + "width": 875 + }, + "search": ["share-alt"] + }, + { + "uid": "ef49eade5ad70fcd1daa78d8d16bd68b", + "css": "code", + "code": 59476, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M544.7 999L425.6 964.5C413.1 960.9 406.1 947.9 409.6 935.4L676.2 17C679.7 4.5 692.8-2.5 705.3 1L824.4 35.5C836.9 39.1 843.9 52.1 840.4 64.6L573.8 983C570.1 995.5 557.2 1002.7 544.7 999ZM322.1 779.9L407 689.3C416 679.7 415.4 664.5 405.5 655.7L228.5 500 405.5 344.3C415.4 335.5 416.2 320.3 407 310.7L322.1 220.1C313.3 210.7 298.4 210.2 288.9 219.1L7.4 482.8C-2.5 492-2.5 507.8 7.4 517L288.9 780.9C298.4 789.8 313.3 789.5 322.1 779.9ZM961.1 781.1L1242.6 517.2C1252.5 508 1252.5 492.2 1242.6 483L961.1 218.9C951.8 210.2 936.9 210.5 927.9 219.9L843 310.5C834 320.1 834.6 335.4 844.5 344.1L1021.5 500 844.5 655.7C834.6 664.5 833.8 679.7 843 689.3L927.9 779.9C936.7 789.5 951.6 789.8 961.1 781.1Z", + "width": 1250 + }, + "search": ["code"] + }, + { + "uid": "b3fb5fc84c956ceabfd7ec42ee3fc5dd", + "css": "history", + "code": 59477, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M984.4 499.1C984.9 766 767.2 984.2 500.4 984.4 385.1 984.5 279.2 944.3 196 877.1 174.4 859.7 172.8 827.2 192.4 807.6L214.4 785.6C231.2 768.8 258.1 766.9 276.7 781.7 338 830.3 415.6 859.4 500 859.4 698.6 859.4 859.4 698.6 859.4 500 859.4 301.4 698.6 140.6 500 140.6 404.7 140.6 318.1 177.7 253.8 238.1L352.9 337.3C372.6 357 358.6 390.6 330.8 390.6H46.9C29.6 390.6 15.6 376.6 15.6 359.4V75.4C15.6 47.6 49.3 33.7 69 53.3L165.4 149.8C252.4 66.7 370.2 15.6 500 15.6 767.2 15.6 983.9 232 984.4 499.1ZM631 653L650.2 628.3C666.1 607.9 662.4 578.4 642 562.5L562.5 500.7V296.9C562.5 271 541.5 250 515.6 250H484.4C458.5 250 437.5 271 437.5 296.9V561.8L565.3 661.2C585.7 677.1 615.1 673.4 631 653Z", + "width": 1000 + }, + "search": ["history"] + }, + { + "uid": "bed311f2f0699a3e55a635284d86a5c7", + "css": "star", + "code": 59478, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M506.4 34.8L378.9 293.4 93.6 335C42.4 342.4 21.9 405.5 59 441.6L265.4 642.8 216.6 927C207.8 978.3 261.9 1016.8 307.2 992.8L562.5 858.6 817.8 992.8C863.1 1016.6 917.2 978.3 908.4 927L859.6 642.8 1066 441.6C1103.1 405.5 1082.6 342.4 1031.4 335L746.1 293.4 618.6 34.8C595.7-11.3 529.5-11.9 506.4 34.8Z", + "width": 1125 + }, + "search": ["star"] + }, + { + "uid": "28feb41c0766d59e9f56b2c4c9cb67a5", + "css": "file-import", + "code": 59479, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M31.3 562.5C14.1 562.5 0 576.6 0 593.8V656.3C0 673.4 14.1 687.5 31.3 687.5H250V562.5ZM986.3 205.1L795.1 13.7C786.3 4.9 774.4 0 761.9 0H750V250H1000V238.1C1000 225.8 995.1 213.9 986.3 205.1ZM687.5 265.6V0H296.9C270.9 0 250 20.9 250 46.9V562.5H500V435.2C500 407.2 533.8 393.4 553.5 413.1L740.2 601.6C753.1 614.6 753.1 635.5 740.2 648.4L553.3 836.7C533.6 856.4 499.8 842.6 499.8 814.6V687.5H250V953.1C250 979.1 270.9 1000 296.9 1000H953.1C979.1 1000 1000 979.1 1000 953.1V312.5H734.4C708.6 312.5 687.5 291.4 687.5 265.6Z", + "width": 1000 + }, + "search": ["file-import"] + }, + { + "uid": "a4382bef7f9361b8dacb8ae0b42691a4", + "css": "file-download", + "code": 59480, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M437.5 265.6V0H46.9C20.9 0 0 20.9 0 46.9V953.1C0 979.1 20.9 1000 46.9 1000H703.1C729.1 1000 750 979.1 750 953.1V312.5H484.4C458.6 312.5 437.5 291.4 437.5 265.6ZM586.8 678.4L398.5 865.4C385.5 878.3 364.5 878.3 351.5 865.4L163.2 678.4C143.4 658.8 157.3 625 185.2 625H312.5V468.8C312.5 451.5 326.5 437.5 343.8 437.5H406.3C423.5 437.5 437.5 451.5 437.5 468.8V625H564.8C592.7 625 606.6 658.8 586.8 678.4ZM736.3 205.1L545.1 13.7C536.3 4.9 524.4 0 511.9 0H500V250H750V238.1C750 225.8 745.1 213.9 736.3 205.1Z", + "width": 750 + }, + "search": ["file-download"] + }, + { + "uid": "149eec703c4bd1d93f052f3d239cce44", + "css": "file-pdf", + "code": 59481, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M355.3 500.2C345.5 468.9 345.7 408.6 351.4 408.6 367.8 408.6 366.2 480.7 355.3 500.2ZM352 592.4C336.9 631.8 318.2 677 296.5 714.8 332.2 701.2 372.7 681.3 419.3 672.1 394.5 653.3 370.7 626.4 352 592.4ZM168.2 836.1C168.2 837.7 193.9 825.6 236.3 757.6 223.2 769.9 179.5 805.5 168.2 836.1ZM484.4 312.5H750V953.1C750 979.1 729.1 1000 703.1 1000H46.9C20.9 1000 0 979.1 0 953.1V46.9C0 20.9 20.9 0 46.9 0H437.5V265.6C437.5 291.4 458.6 312.5 484.4 312.5ZM468.8 648C429.7 624.2 403.7 591.4 385.4 543 394.1 506.8 408 452 397.5 417.6 388.3 360.2 314.6 365.8 304.1 404.3 294.3 440 303.3 490.4 319.9 554.7 297.3 608.6 263.9 680.9 240.2 722.3 240 722.3 240 722.5 239.8 722.5 186.9 749.6 96.1 809.4 133.4 855.3 144.3 868.8 164.6 874.8 175.4 874.8 210.4 874.8 245.1 839.6 294.7 754.1 345.1 737.5 400.4 716.8 449 708.8 491.4 731.8 541 746.9 574 746.9 631.1 746.9 635 684.4 612.5 662.1 585.4 635.5 506.4 643.2 468.7 648ZM736.3 205.1L544.9 13.7C536.1 4.9 524.2 0 511.7 0H500V250H750V238.1C750 225.8 745.1 213.9 736.3 205.1ZM591.6 703.7C599.6 698.4 586.7 680.5 508 686.1 580.5 717 591.6 703.7 591.6 703.7Z", + "width": 750 + }, + "search": ["file-pdf"] + }, + { + "uid": "43c33879f17fb9c62a7466659a1a9347", + "css": "file-word", + "code": 59482, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M437.5 265.6V0H46.9C20.9 0 0 20.9 0 46.9V953.1C0 979.1 20.9 1000 46.9 1000H703.1C729.1 1000 750 979.1 750 953.1V312.5H484.4C458.6 312.5 437.5 291.4 437.5 265.6ZM549 500H595.7C610.7 500 621.9 513.9 618.6 528.7L544.3 856.8C542 867.6 532.4 875 521.5 875H447.3C436.5 875 427.1 867.6 424.6 857.2 374.2 655.1 384 698.6 374.6 641.4H373.6C371.5 669.3 368.9 675.4 323.6 857.2 321.1 867.6 311.7 875 301 875H228.5C217.6 875 208 867.4 205.7 856.6L131.8 528.5C128.5 513.9 139.6 500 154.7 500H202.5C213.7 500 223.4 507.8 225.6 518.9 256.1 671.3 264.8 732.8 266.6 757.6 269.7 737.7 280.9 693.8 324 518 326.6 507.4 335.9 500.2 346.9 500.2H403.7C414.6 500.2 424 507.6 426.6 518.2 473.4 714.3 482.8 760.4 484.4 770.9 484 749 479.3 736.1 526.6 518.6 528.5 507.6 538.1 500 549 500ZM750 238.1V250H500V0H511.9C524.4 0 536.3 4.9 545.1 13.7L736.3 205.1C745.1 213.9 750 225.8 750 238.1Z", + "width": 750 + }, + "search": ["file-word"] + }, + { + "uid": "5d3cbbf4f54f53889ff77614613a050d", + "css": "file-alt", + "code": 59483, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M437.5 265.6V0H46.9C20.9 0 0 20.9 0 46.9V953.1C0 979.1 20.9 1000 46.9 1000H703.1C729.1 1000 750 979.1 750 953.1V312.5H484.4C458.6 312.5 437.5 291.4 437.5 265.6ZM562.5 726.6C562.5 739.5 552 750 539.1 750H210.9C198 750 187.5 739.5 187.5 726.6V710.9C187.5 698 198 687.5 210.9 687.5H539.1C552 687.5 562.5 698 562.5 710.9V726.6ZM562.5 601.6C562.5 614.5 552 625 539.1 625H210.9C198 625 187.5 614.5 187.5 601.6V585.9C187.5 573 198 562.5 210.9 562.5H539.1C552 562.5 562.5 573 562.5 585.9V601.6ZM562.5 460.9V476.6C562.5 489.5 552 500 539.1 500H210.9C198 500 187.5 489.5 187.5 476.6V460.9C187.5 448 198 437.5 210.9 437.5H539.1C552 437.5 562.5 448 562.5 460.9ZM750 238.1V250H500V0H511.9C524.4 0 536.3 4.9 545.1 13.7L736.3 205.1C745.1 213.9 750 225.8 750 238.1Z", + "width": 750 + }, + "search": ["file-alt"] + }, + { + "uid": "c718261461d9a8046891e6c68d610118", + "css": "file", + "code": 59484, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M437.5 265.6V0H46.9C20.9 0 0 20.9 0 46.9V953.1C0 979.1 20.9 1000 46.9 1000H703.1C729.1 1000 750 979.1 750 953.1V312.5H484.4C458.6 312.5 437.5 291.4 437.5 265.6ZM750 238.1V250H500V0H511.9C524.4 0 536.3 4.9 545.1 13.7L736.3 205.1C745.1 213.9 750 225.8 750 238.1Z", + "width": 750 + }, + "search": ["file"] + }, + { + "uid": "9a14f9bdf73d4f035ecb964e16f27b5b", + "css": "users", + "code": 59445, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M187.5 437.5C256.4 437.5 312.5 381.4 312.5 312.5S256.4 187.5 187.5 187.5 62.5 243.6 62.5 312.5 118.6 437.5 187.5 437.5ZM1062.5 437.5C1131.4 437.5 1187.5 381.4 1187.5 312.5S1131.4 187.5 1062.5 187.5 937.5 243.6 937.5 312.5 993.6 437.5 1062.5 437.5ZM1125 500H1000C965.6 500 934.6 513.9 911.9 536.3 990.6 579.5 1046.5 657.4 1058.6 750H1187.5C1222.1 750 1250 722.1 1250 687.5V625C1250 556.1 1193.9 500 1125 500ZM625 500C745.9 500 843.8 402.1 843.8 281.3S745.9 62.5 625 62.5 406.3 160.4 406.3 281.3 504.1 500 625 500ZM775 562.5H758.8C718.2 582 673 593.8 625 593.8S532 582 491.2 562.5H475C350.8 562.5 250 663.3 250 787.5V843.8C250 895.5 292 937.5 343.8 937.5H906.3C958 937.5 1000 895.5 1000 843.8V787.5C1000 663.3 899.2 562.5 775 562.5ZM338.1 536.3C315.4 513.9 284.4 500 250 500H125C56.1 500 0 556.1 0 625V687.5C0 722.1 27.9 750 62.5 750H191.2C203.5 657.4 259.4 579.5 338.1 536.3Z", + "width": 1250 + }, + "search": ["users"] + }, + { + "uid": "5ce9d7d62b842d1e0b42ccb50417ed86", + "css": "pencil-alt", + "code": 59400, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M972.5 277.5L882.4 367.6C873.2 376.8 858.4 376.8 849.2 367.6L632.4 150.8C623.2 141.6 623.2 126.8 632.4 117.6L722.5 27.5C759-9 818.4-9 855.1 27.5L972.5 144.9C1009.2 181.4 1009.2 240.8 972.5 277.5ZM555.1 194.9L42.2 707.8 0.8 945.1C-4.9 977.1 23 1004.9 55.1 999.4L292.4 957.8 805.3 444.9C814.5 435.7 814.5 420.9 805.3 411.7L588.5 194.9C579.1 185.7 564.3 185.7 555.1 194.9ZM242.4 663.9C231.6 653.1 231.6 635.9 242.4 625.2L543.2 324.4C553.9 313.7 571.1 313.7 581.8 324.4S592.6 352.3 581.8 363.1L281.1 663.9C270.3 674.6 253.1 674.6 242.4 663.9ZM171.9 828.1H265.6V899L139.6 921.1 78.9 860.4 101 734.4H171.9V828.1Z", + "width": 1000 + }, + "search": ["pencil-alt"] + }, + { + "uid": "88a8e61cd1555895e8af136db8b58885", + "css": "times", + "code": 59430, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M474.1 500L669.5 304.6C693.5 280.6 693.5 241.7 669.5 217.7L626.1 174.2C602.1 150.3 563.2 150.3 539.2 174.2L343.8 369.7 148.3 174.2C124.3 150.3 85.4 150.3 61.4 174.2L18 217.7C-6 241.7-6 280.5 18 304.6L213.4 500 18 695.4C-6 719.4-6 758.3 18 782.3L61.4 825.8C85.4 849.7 124.3 849.7 148.3 825.8L343.8 630.3 539.2 825.8C563.2 849.7 602.1 849.7 626.1 825.8L669.5 782.3C693.5 758.3 693.5 719.5 669.5 695.4L474.1 500Z", + "width": 688 + }, + "search": ["times"] + }, + { + "uid": "91c50bb767ec3d33047773a7e539799e", + "css": "pause", + "code": 59433, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M281.3 935.5H93.8C42 935.5 0 893.6 0 841.8V154.3C0 102.5 42 60.5 93.8 60.5H281.3C333 60.5 375 102.5 375 154.3V841.8C375 893.6 333 935.5 281.3 935.5ZM875 841.8V154.3C875 102.5 833 60.5 781.3 60.5H593.8C542 60.5 500 102.5 500 154.3V841.8C500 893.6 542 935.5 593.8 935.5H781.3C833 935.5 875 893.6 875 841.8Z", + "width": 875 + }, + "search": ["pause"] + }, + { + "uid": "3053a00ac47ec0a6e52490d34a2251eb", + "css": "stop", + "code": 59394, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M781.3 62.5H93.8C42 62.5 0 104.5 0 156.3V843.8C0 895.5 42 937.5 93.8 937.5H781.3C833 937.5 875 895.5 875 843.8V156.3C875 104.5 833 62.5 781.3 62.5Z", + "width": 875 + }, + "search": ["stop"] + }, + { + "uid": "91b4828047e0874d4b2cfbb44dc16ff9", + "css": "step-backward", + "code": 59435, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M125 914.1V85.9C125 73 135.5 62.5 148.4 62.5H242.2C255.1 62.5 265.6 73 265.6 85.9V430.5L647.5 77C687.7 43.6 750 71.5 750 125V875C750 928.5 687.7 956.4 647.5 923L265.6 571.7V914.1C265.6 927 255.1 937.5 242.2 937.5H148.4C135.5 937.5 125 927 125 914.1Z", + "width": 875 + }, + "search": ["step-backward"] + }, + { + "uid": "9a0d3eec2bb3765a51f82dadf9a10bd1", + "css": "step-forward", + "code": 59436, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M750 85.9V914.1C750 927 739.5 937.5 726.6 937.5H632.8C619.9 937.5 609.4 927 609.4 914.1V569.5L227.5 923C187.3 956.4 125 928.5 125 875V125C125 71.5 187.3 43.6 227.5 77L609.4 428.3V85.9C609.4 73 619.9 62.5 632.8 62.5H726.6C739.5 62.5 750 73 750 85.9Z", + "width": 875 + }, + "search": ["step-forward"] + }, + { + "uid": "9f8f8db47c9da55d8ea2e0170476eb39", + "css": "play", + "code": 59395, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M828.9 419.3L141.4 12.9C85.5-20.1 0 11.9 0 93.6V906.3C0 979.5 79.5 1023.6 141.4 986.9L828.9 580.7C890.2 544.5 890.4 455.5 828.9 419.3Z", + "width": 875 + }, + "search": ["play"] + }, + { + "uid": "e0e61c06ec2c00a0c7b604fcc20b133c", + "css": "comments", + "code": 59437, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M812.5 375C812.5 202.3 630.7 62.5 406.3 62.5S0 202.3 0 375C0 442 27.5 503.7 74.2 554.7 48 613.7 4.9 660.5 4.3 661.1 0 665.6-1.2 672.3 1.4 678.1S9.4 687.5 15.6 687.5C87.1 687.5 146.3 663.5 188.9 638.7 251.8 669.3 326.2 687.5 406.3 687.5 630.7 687.5 812.5 547.7 812.5 375ZM1050.8 804.7C1097.5 753.9 1125 692 1125 625 1125 494.3 1020.5 382.4 872.5 335.7 874.2 348.6 875 361.7 875 375 875 581.8 664.6 750 406.3 750 385.2 750 364.6 748.4 344.3 746.3 405.9 858.6 550.4 937.5 718.8 937.5 798.8 937.5 873.2 919.5 936.1 888.7 978.7 913.5 1037.9 937.5 1109.4 937.5 1115.6 937.5 1121.3 933.8 1123.6 928.1 1126.2 922.5 1125 915.8 1120.7 911.1 1120.1 910.5 1077 863.9 1050.8 804.7Z", + "width": 1125 + }, + "search": ["comments"] + }, + { + "uid": "7c8b7bccd2548457f00645f3954e2863", + "css": "heading", + "code": 59438, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M968.8 156.3V93.8C968.8 76.5 954.8 62.5 937.5 62.5H625C607.7 62.5 593.8 76.5 593.8 93.8V156.3C593.8 173.5 607.7 187.5 625 187.5H698.5V437.5H301.5V187.5H375C392.3 187.5 406.3 173.5 406.3 156.3V93.8C406.3 76.5 392.3 62.5 375 62.5H62.5C45.2 62.5 31.3 76.5 31.3 93.8V156.3C31.3 173.5 45.2 187.5 62.5 187.5H135.3V812.5H62.5C45.2 812.5 31.3 826.5 31.3 843.8V906.3C31.3 923.5 45.2 937.5 62.5 937.5H375C392.3 937.5 406.3 923.5 406.3 906.3V843.8C406.3 826.5 392.3 812.5 375 812.5H301.5V562.5H698.5V812.5H625C607.7 812.5 593.8 826.5 593.8 843.8V906.3C593.8 923.5 607.7 937.5 625 937.5H937.5C954.8 937.5 968.8 923.5 968.8 906.3V843.8C968.8 826.5 954.8 812.5 937.5 812.5H864.7V187.5H937.5C954.8 187.5 968.8 173.5 968.8 156.3Z", + "width": 1000 + }, + "search": ["heading"] + }, + { + "uid": "c7ead3a5bb66fddf32a7899a0f3fbb6c", + "css": "align-center", + "code": 59396, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M687.5 85.9V164.1C687.5 181.3 673.5 195.3 656.3 195.3H218.8C201.5 195.3 187.5 181.3 187.5 164.1V85.9C187.5 68.7 201.5 54.7 218.8 54.7H656.3C673.5 54.7 687.5 68.7 687.5 85.9ZM31.3 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H31.3C14 304.7 0 318.7 0 335.9V414.1C0 431.3 14 445.3 31.3 445.3ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM656.3 554.7H218.8C201.5 554.7 187.5 568.7 187.5 585.9V664.1C187.5 681.3 201.5 695.3 218.8 695.3H656.3C673.5 695.3 687.5 681.3 687.5 664.1V585.9C687.5 568.7 673.5 554.7 656.3 554.7Z", + "width": 875 + }, + "search": ["align-center"] + }, + { + "uid": "e8e401b7ba1649fce89eb32cc85cb50d", + "css": "align-justify", + "code": 59397, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M0 164.1V85.9C0 68.7 14 54.7 31.3 54.7H843.8C861 54.7 875 68.7 875 85.9V164.1C875 181.3 861 195.3 843.8 195.3H31.3C14 195.3 0 181.3 0 164.1ZM31.3 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H31.3C14 304.7 0 318.7 0 335.9V414.1C0 431.3 14 445.3 31.3 445.3ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM31.3 695.3H843.8C861 695.3 875 681.3 875 664.1V585.9C875 568.7 861 554.7 843.8 554.7H31.3C14 554.7 0 568.7 0 585.9V664.1C0 681.3 14 695.3 31.3 695.3Z", + "width": 875 + }, + "search": ["align-justify"] + }, + { + "uid": "eb15f17c97d08c4151e60b4b2f630fb5", + "css": "align-left", + "code": 59398, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M562.5 85.9V164.1C562.5 181.3 548.5 195.3 531.3 195.3H31.3C14 195.3 0 181.3 0 164.1V85.9C0 68.7 14 54.7 31.3 54.7H531.3C548.5 54.7 562.5 68.7 562.5 85.9ZM0 335.9V414.1C0 431.3 14 445.3 31.3 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H31.3C14 304.7 0 318.7 0 335.9ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM531.3 554.7H31.3C14 554.7 0 568.7 0 585.9V664.1C0 681.3 14 695.3 31.3 695.3H531.3C548.5 695.3 562.5 681.3 562.5 664.1V585.9C562.5 568.7 548.5 554.7 531.3 554.7Z", + "width": 875 + }, + "search": ["align-left"] + }, + { + "uid": "48f22afc96cf17626d8da876b9b463dc", + "css": "align-right", + "code": 59399, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M312.5 164.1V85.9C312.5 68.7 326.5 54.7 343.8 54.7H843.8C861 54.7 875 68.7 875 85.9V164.1C875 181.3 861 195.3 843.8 195.3H343.8C326.5 195.3 312.5 181.3 312.5 164.1ZM31.3 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H31.3C14 304.7 0 318.7 0 335.9V414.1C0 431.3 14 445.3 31.3 445.3ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM343.8 695.3H843.8C861 695.3 875 681.3 875 664.1V585.9C875 568.7 861 554.7 843.8 554.7H343.8C326.5 554.7 312.5 568.7 312.5 585.9V664.1C312.5 681.3 326.5 695.3 343.8 695.3Z", + "width": 875 + }, + "search": ["align-right"] + }, + { + "uid": "b2d03fd882d7c96479a3c6c1dbc1a889", + "css": "file-powerpoint", + "code": 59485, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M378.3 529.7C395.5 529.7 408.6 535 418 545.5 436.7 566.8 437.1 609.4 417.6 631.6 408 642.6 394.3 648.2 376.4 648.2H323.8V529.7H378.3ZM736.3 205.1L544.9 13.7C536.1 4.9 524.2 0 511.7 0H500V250H750V238.1C750 225.8 745.1 213.9 736.3 205.1ZM437.5 265.6V0H46.9C20.9 0 0 20.9 0 46.9V953.1C0 979.1 20.9 1000 46.9 1000H703.1C729.1 1000 750 979.1 750 953.1V312.5H484.4C458.6 312.5 437.5 291.4 437.5 265.6ZM541 588.3C541 764.6 367.6 739.8 324 739.8V851.6C324 864.5 313.5 875 300.6 875H240.4C227.5 875 217 864.5 217 851.6V461.3C217 448.4 227.5 437.9 240.4 437.9H398.6C485.5 437.9 541 502 541 588.3Z", + "width": 750 + }, + "search": ["file-powerpoint"] + }, + { + "uid": "c59ea6604f4c8a3bebd9cb24630f0e3b", + "css": "superscript", + "code": 59443, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M531.3 375H398.8C388.1 375 377.9 380.7 372.3 389.8L293.6 516.8C289.1 523.8 284.8 531.1 281.4 537.3 278.1 531.1 274.2 524 270.3 517L192.4 389.8C186.7 380.7 176.6 375 165.8 375H31.3C14.1 375 0 389.1 0 406.3V468.8C0 485.9 14.1 500 31.3 500H90L193.2 651 82.6 812.5H31.3C14.1 812.5 0 826.6 0 843.8V906.3C0 923.4 14.1 937.5 31.3 937.5H156.3C167 937.5 177.1 931.8 182.8 922.7L270.1 781.8C274.4 774.8 278.3 767.6 281.6 761.1 285.2 767.4 289.3 774.6 293.8 781.1L383 922.9C388.7 932 398.6 937.5 409.4 937.5H531.3C548.4 937.5 562.5 923.4 562.5 906.2V843.7C562.5 826.6 548.4 812.5 531.3 812.5H488.3L373.8 647.9 476.6 500H531.3C548.4 500 562.5 485.9 562.5 468.8V406.3C562.5 389.1 548.4 375 531.3 375ZM968.8 500H771.9C778.7 479.5 808.6 458.4 842.8 436.7 875.2 416 912.1 392.6 941 360.7 975.2 323.4 991.6 282.2 991.6 234.6 991.6 116.2 892.6 62.5 800.6 62.5 717.6 62.5 651.4 105.5 616.2 160.9 607 175.2 611.1 194.1 625.2 203.7L684.4 243.4C698 252.5 716.6 249.4 726.6 236.3 742.2 216 763.3 200.8 788.5 200.8 826.4 200.8 839.8 226 839.8 247.5 839.8 318.2 606.6 358.8 606.6 560 606.6 573 607.8 585.4 609.4 597.7 611.5 613.3 624.6 624.8 640.4 624.8H968.8C985.9 624.8 1000 610.7 1000 593.6V531.1C1000 514.1 985.9 500 968.8 500Z", + "width": 1000 + }, + "search": ["superscript"] + }, + { + "uid": "cb9e27f4e2c9fe6182e2351f9ad71c14", + "css": "subscript", + "code": 59444, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M531.3 62.5H398.8C388.1 62.5 377.9 68.2 372.3 77.3L293.6 204.3C289.1 211.3 284.8 218.6 281.4 224.8 278.1 218.6 274.2 211.5 270.3 204.5L192.4 77.3C186.7 68.2 176.6 62.5 165.8 62.5H31.3C14.1 62.5 0 76.6 0 93.8V156.3C0 173.4 14.1 187.5 31.3 187.5H90L193.2 338.5 82.6 500H31.3C14.1 500 0 514.1 0 531.3V593.8C0 610.9 14.1 625 31.3 625H156.3C167 625 177.1 619.3 182.8 610.2L270.1 469.3C274.4 462.3 278.3 455.1 281.6 448.6 285.2 454.9 289.3 462.1 293.8 468.6L383 610.4C388.7 619.5 398.6 625 409.4 625H531.3C548.4 625 562.5 610.9 562.5 593.8V531.3C562.5 514.1 548.4 500 531.3 500H488.3L373.8 335.4 476.6 187.5H531.3C548.4 187.5 562.5 173.4 562.5 156.3V93.8C562.5 76.6 548.4 62.5 531.3 62.5ZM968.8 812.5H771.9C778.7 792 808.6 770.9 842.8 749.2 875.2 728.5 912.1 705.1 941 673.2 975.2 635.9 991.6 594.7 991.6 547.1 991.6 428.7 892.6 375 800.6 375 717.6 375 651.4 418 616.2 473.4 607 487.7 611.1 506.6 625.2 516.2L684.4 555.9C698 565 716.6 561.9 726.6 548.8 742.2 528.5 763.3 513.3 788.5 513.3 826.4 513.3 839.8 538.5 839.8 560 839.8 630.7 606.6 671.3 606.6 872.5 606.6 885.5 607.8 897.9 609.4 910.2 611.5 925.8 624.6 937.3 640.4 937.3H968.8C985.9 937.3 1000 923.2 1000 906.1V843.6C1000 826.6 985.9 812.5 968.8 812.5Z", + "width": 1000 + }, + "search": ["subscript"] + }, + { + "uid": "2f9853bb94503f2e5149dddae69657f6", + "css": "gauge", + "code": 59446, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M562.5 62.5C251.8 62.5 0 314.3 0 625 0 728.1 27.8 824.7 76.3 907.8 87.2 926.6 108.1 937.5 129.9 937.5H995.1C1016.9 937.5 1037.8 926.6 1048.7 907.8 1097.2 824.7 1125 728.1 1125 625 1125 314.3 873.2 62.5 562.5 62.5ZM562.5 187.5C591.2 187.5 614.4 207.3 621.7 233.7 619.6 238.1 616.6 242 615 246.7L597 300.8C587 307.6 575.5 312.5 562.5 312.5 528 312.5 500 284.5 500 250S528 187.5 562.5 187.5ZM187.5 750C153 750 125 722 125 687.5S153 625 187.5 625 250 653 250 687.5 222 750 187.5 750ZM281.3 437.5C246.7 437.5 218.8 409.5 218.8 375S246.7 312.5 281.3 312.5 343.8 340.5 343.8 375 315.8 437.5 281.3 437.5ZM763.2 296.1L643.4 655.4C670.2 678.4 687.5 712 687.5 750 687.5 772.9 680.9 794 670.2 812.5H454.8C444.1 794 437.5 772.9 437.5 750 437.5 683.7 489.3 630 554.5 625.8L674.3 266.4C682.4 241.9 708.9 228.4 733.6 236.8 758.1 245 771.4 271.5 763.2 296.1ZM791.9 407.8L822.2 316.9C828.9 314.4 836.1 312.5 843.8 312.5 878.3 312.5 906.3 340.5 906.3 375S878.3 437.5 843.8 437.5C821.5 437.5 802.9 425.3 791.9 407.8ZM937.5 750C903 750 875 722 875 687.5S903 625 937.5 625 1000 653 1000 687.5 972 750 937.5 750Z", + "width": 1125 + }, + "search": ["tachometer-alt"] + }, + { + "uid": "9f61e6a7ba9b929596aba1e946386ca1", + "css": "exchange-alt", + "code": 59447, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M0 328.1V296.9C0 271 21 250 46.9 250H750V156.3C750 114.5 800.6 93.7 830 123.1L986.3 279.4C1004.6 297.7 1004.6 327.3 986.3 345.6L830 501.9C800.7 531.2 750 510.7 750 468.8V375H46.9C21 375 0 354 0 328.1ZM953.1 625H250V531.3C250 489.6 199.5 468.6 170 498.1L13.7 654.4C-4.6 672.7-4.6 702.3 13.7 720.6L170 876.9C199.3 906.2 250 885.6 250 843.8V750H953.1C979 750 1000 729 1000 703.1V671.9C1000 646 979 625 953.1 625Z", + "width": 1000 + }, + "search": ["exchange-alt"] + }, + { + "uid": "762c1dbaf1d25d6f7365934483e90285", + "css": "text-width", + "code": 59448, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M31.3 62.5H843.8C861 62.5 875 76.5 875 93.8V281.3C875 298.5 861 312.5 843.8 312.5H774.4C757.2 312.5 743.2 298.5 743.2 281.3V187.5H511.2V437.5H578.1C595.4 437.5 609.4 451.5 609.4 468.8V531.3C609.4 548.5 595.4 562.5 578.1 562.5H296.9C279.6 562.5 265.6 548.5 265.6 531.3V468.8C265.6 451.5 279.6 437.5 296.9 437.5H363.8V187.5H131.8V281.3C131.8 298.5 117.8 312.5 100.6 312.5H31.3C14 312.5 0 298.5 0 281.3V93.8C0 76.5 14 62.5 31.3 62.5ZM865.8 727.9L709.6 571.7C691.4 553.4 656.3 563.2 656.3 593.8V687.5H218.8V593.8C218.8 565.8 184.9 552.1 165.4 571.7L9.2 727.9C-3 740.1-3.1 759.9 9.2 772.1L165.4 928.3C183.6 946.6 218.8 936.8 218.8 906.3V812.5H656.3V906.2C656.3 934.2 690.1 947.9 709.6 928.3L865.8 772.1C878 759.9 878.1 740.1 865.8 727.9Z", + "width": 875 + }, + "search": ["text-width"] + }, + { + "uid": "db94b783531717f104b39b398db3d0f2", + "css": "sync-alt", + "code": 59392, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M724.1 260.3C663 203.1 583.8 171.8 499.7 171.9 348.4 172 217.8 275.7 181.8 419.6 179.1 430.1 169.8 437.5 159 437.5H47.1C32.4 437.5 21.3 424.2 24 409.8 66.3 185.4 263.3 15.6 500 15.6 629.8 15.6 747.6 66.7 834.6 149.8L904.4 80C933.9 50.5 984.4 71.4 984.4 113.2V375C984.4 400.9 963.4 421.9 937.5 421.9H675.7C633.9 421.9 613 371.4 642.5 341.9L724.1 260.3ZM62.5 578.1H324.3C366.1 578.1 387 628.6 357.5 658.1L275.9 739.7C337 796.9 416.2 828.2 500.3 828.1 651.5 828 782.2 724.3 818.2 580.4 820.9 569.9 830.2 562.5 841 562.5H952.9C967.6 562.5 978.7 575.8 976 590.2 933.7 814.6 736.7 984.4 500 984.4 370.2 984.4 252.4 933.3 165.4 850.2L95.6 920C66.1 949.5 15.6 928.6 15.6 886.8V625C15.6 599.1 36.6 578.1 62.5 578.1Z", + "width": 1000 + }, + "search": ["sync-alt"] + }, + { + "uid": "f0ec8e32814f630fb6234b84cb5d4672", + "css": "picture", + "code": 59450, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M906.3 875H93.8C42 875 0 833 0 781.3V218.8C0 167 42 125 93.8 125H906.3C958 125 1000 167 1000 218.8V781.3C1000 833 958 875 906.3 875ZM218.8 234.4C158.3 234.4 109.4 283.3 109.4 343.8S158.3 453.1 218.8 453.1 328.1 404.2 328.1 343.8 279.2 234.4 218.8 234.4ZM125 750H875V531.3L704.1 360.3C694.9 351.2 680.1 351.2 670.9 360.3L406.3 625 297.8 516.6C288.7 507.4 273.8 507.4 264.7 516.6L125 656.3V750Z", + "width": 1000 + }, + "search": ["image"] + }, + { + "uid": "5c54164453ce690dddffa89377692bff", + "css": "file-code", + "code": 59401, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M750 238.2V250H500V0H511.8C524.3 0 536.2 4.9 545 13.7L736.3 205A46.9 46.9 0 0 1 750 238.2ZM484.4 312.5C458.6 312.5 437.5 291.4 437.5 265.6V0H46.9C21 0 0 21 0 46.9V953.1C0 979 21 1000 46.9 1000H703.1C729 1000 750 979 750 953.1V312.5H484.4ZM240.6 782.2A10.5 10.5 0 0 1 225.7 782.7L99 663.9A10.5 10.5 0 0 1 99 648.6L225.7 529.8A10.5 10.5 0 0 1 240.6 530.3L278.9 571.1A10.5 10.5 0 0 1 278.2 586.2L198.5 656.3 278.2 726.3A10.5 10.5 0 0 1 278.9 741.4L240.6 782.2ZM340.8 880.8L287.2 865.3A10.6 10.6 0 0 1 280 852.2L400 438.9A10.6 10.6 0 0 1 413.1 431.7L466.7 447.2A10.5 10.5 0 0 1 473.9 460.3L353.9 873.6A10.5 10.5 0 0 1 340.8 880.8ZM654.9 663.9L528.2 782.7A10.5 10.5 0 0 1 513.3 782.2L475 741.4A10.5 10.5 0 0 1 475.8 726.3L555.4 656.3 475.8 586.2A10.5 10.5 0 0 1 475 571.1L513.3 530.3A10.5 10.5 0 0 1 528.2 529.8L654.9 648.6A10.5 10.5 0 0 1 654.9 663.9Z", + "width": 750 + }, + "search": ["file-code"] + }, + { + "uid": "d7271d490b71df4311e32cdacae8b331", + "css": "home", + "code": 59403, + "src": "fontawesome" + } + ] +} diff --git a/src/static/js/AttributeManager.js b/src/static/js/AttributeManager.js index f508af641..628bb30e5 100644 --- a/src/static/js/AttributeManager.js +++ b/src/static/js/AttributeManager.js @@ -1,22 +1,22 @@ -'use strict'; +"use strict"; -const AttributeMap = require('./AttributeMap'); -const Changeset = require('./Changeset'); -const ChangesetUtils = require('./ChangesetUtils'); -const attributes = require('./attributes'); -const _ = require('./underscore'); +const AttributeMap = require("./AttributeMap"); +const Changeset = require("./Changeset"); +const ChangesetUtils = require("./ChangesetUtils"); +const attributes = require("./attributes"); +const _ = require("./underscore"); -const lineMarkerAttribute = 'lmkr'; +const lineMarkerAttribute = "lmkr"; // Some of these attributes are kept for compatibility purposes. // Not sure if we need all of them -const DEFAULT_LINE_ATTRIBUTES = ['author', 'lmkr', 'insertorder', 'start']; +const DEFAULT_LINE_ATTRIBUTES = ["author", "lmkr", "insertorder", "start"]; // If one of these attributes are set to the first character of a // line it is considered as a line attribute marker i.e. attributes // set on this marker are applied to the whole line. // The list attribute is only maintained for compatibility reasons -const lineAttributes = [lineMarkerAttribute, 'list']; +const lineAttributes = [lineMarkerAttribute, "list"]; /* The Attribute manager builds changesets based on a document @@ -34,322 +34,398 @@ const lineAttributes = [lineMarkerAttribute, 'list']; */ const AttributeManager = function (rep, applyChangesetCallback) { - this.rep = rep; - this.applyChangesetCallback = applyChangesetCallback; - this.author = ''; + this.rep = rep; + this.applyChangesetCallback = applyChangesetCallback; + this.author = ""; - // If the first char in a line has one of the following attributes - // it will be considered as a line marker + // If the first char in a line has one of the following attributes + // it will be considered as a line marker }; AttributeManager.DEFAULT_LINE_ATTRIBUTES = DEFAULT_LINE_ATTRIBUTES; AttributeManager.lineAttributes = lineAttributes; AttributeManager.prototype = _(AttributeManager.prototype).extend({ + applyChangeset(changeset) { + if (!this.applyChangesetCallback) return changeset; - applyChangeset(changeset) { - if (!this.applyChangesetCallback) return changeset; + const cs = changeset.toString(); + if (!Changeset.isIdentity(cs)) { + this.applyChangesetCallback(cs); + } - const cs = changeset.toString(); - if (!Changeset.isIdentity(cs)) { - this.applyChangesetCallback(cs); - } + return changeset; + }, - return changeset; - }, - - /* + /* Sets attributes on a range @param start [row, col] tuple pointing to the start of the range @param end [row, col] tuple pointing to the end of the range @param attribs: an array of attributes */ - setAttributesOnRange(start, end, attribs) { - if (start[0] < 0) throw new RangeError('selection start line number is negative'); - if (start[1] < 0) throw new RangeError('selection start column number is negative'); - if (end[0] < 0) throw new RangeError('selection end line number is negative'); - if (end[1] < 0) throw new RangeError('selection end column number is negative'); - if (start[0] > end[0] || (start[0] === end[0] && start[1] > end[1])) { - throw new RangeError('selection ends before it starts'); - } + setAttributesOnRange(start, end, attribs) { + if (start[0] < 0) + throw new RangeError("selection start line number is negative"); + if (start[1] < 0) + throw new RangeError("selection start column number is negative"); + if (end[0] < 0) + throw new RangeError("selection end line number is negative"); + if (end[1] < 0) + throw new RangeError("selection end column number is negative"); + if (start[0] > end[0] || (start[0] === end[0] && start[1] > end[1])) { + throw new RangeError("selection ends before it starts"); + } - // instead of applying the attributes to the whole range at once, we need to apply them - // line by line, to be able to disregard the "*" used as line marker. For more details, - // see https://github.com/ether/etherpad-lite/issues/2772 - let allChangesets; - for (let row = start[0]; row <= end[0]; row++) { - const [startCol, endCol] = this._findRowRange(row, start, end); - const rowChangeset = this._setAttributesOnRangeByLine(row, startCol, endCol, attribs); + // instead of applying the attributes to the whole range at once, we need to apply them + // line by line, to be able to disregard the "*" used as line marker. For more details, + // see https://github.com/ether/etherpad-lite/issues/2772 + let allChangesets; + for (let row = start[0]; row <= end[0]; row++) { + const [startCol, endCol] = this._findRowRange(row, start, end); + const rowChangeset = this._setAttributesOnRangeByLine( + row, + startCol, + endCol, + attribs, + ); - // compose changesets of all rows into a single changeset - // as the range might not be continuous - // due to the presence of line markers on the rows - if (allChangesets) { - allChangesets = Changeset.compose( - allChangesets.toString(), rowChangeset.toString(), this.rep.apool); - } else { - allChangesets = rowChangeset; - } - } + // compose changesets of all rows into a single changeset + // as the range might not be continuous + // due to the presence of line markers on the rows + if (allChangesets) { + allChangesets = Changeset.compose( + allChangesets.toString(), + rowChangeset.toString(), + this.rep.apool, + ); + } else { + allChangesets = rowChangeset; + } + } - return this.applyChangeset(allChangesets); - }, + return this.applyChangeset(allChangesets); + }, - _findRowRange(row, start, end) { - if (row < start[0] || row > end[0]) throw new RangeError(`line ${row} not in selection`); - if (row >= this.rep.lines.length()) throw new RangeError(`selected line ${row} does not exist`); + _findRowRange(row, start, end) { + if (row < start[0] || row > end[0]) + throw new RangeError(`line ${row} not in selection`); + if (row >= this.rep.lines.length()) + throw new RangeError(`selected line ${row} does not exist`); - // Subtract 1 for the end-of-line '\n' (it is never selected). - const lineLength = - this.rep.lines.offsetOfIndex(row + 1) - this.rep.lines.offsetOfIndex(row) - 1; - const markerWidth = this.lineHasMarker(row) ? 1 : 0; - if (lineLength - markerWidth < 0) throw new Error(`line ${row} has negative length`); + // Subtract 1 for the end-of-line '\n' (it is never selected). + const lineLength = + this.rep.lines.offsetOfIndex(row + 1) - + this.rep.lines.offsetOfIndex(row) - + 1; + const markerWidth = this.lineHasMarker(row) ? 1 : 0; + if (lineLength - markerWidth < 0) + throw new Error(`line ${row} has negative length`); - if (start[1] < 0) throw new RangeError('selection starts at negative column'); - const startCol = Math.max(markerWidth, row === start[0] ? start[1] : 0); - if (startCol > lineLength) throw new RangeError('selection starts after line end'); + if (start[1] < 0) + throw new RangeError("selection starts at negative column"); + const startCol = Math.max(markerWidth, row === start[0] ? start[1] : 0); + if (startCol > lineLength) + throw new RangeError("selection starts after line end"); - if (end[1] < 0) throw new RangeError('selection ends at negative column'); - const endCol = Math.max(markerWidth, row === end[0] ? end[1] : lineLength); - if (endCol > lineLength) throw new RangeError('selection ends after line end'); - if (startCol > endCol) throw new RangeError('selection ends before it starts'); + if (end[1] < 0) throw new RangeError("selection ends at negative column"); + const endCol = Math.max(markerWidth, row === end[0] ? end[1] : lineLength); + if (endCol > lineLength) + throw new RangeError("selection ends after line end"); + if (startCol > endCol) + throw new RangeError("selection ends before it starts"); - return [startCol, endCol]; - }, + return [startCol, endCol]; + }, - /** - * Sets attributes on a range, by line - * @param row the row where range is - * @param startCol column where range starts - * @param endCol column where range ends (one past the last selected column) - * @param attribs an array of attributes - */ - _setAttributesOnRangeByLine(row, startCol, endCol, attribs) { - const builder = Changeset.builder(this.rep.lines.totalWidth()); - ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [row, startCol]); - ChangesetUtils.buildKeepRange( - this.rep, builder, [row, startCol], [row, endCol], attribs, this.rep.apool); - return builder; - }, + /** + * Sets attributes on a range, by line + * @param row the row where range is + * @param startCol column where range starts + * @param endCol column where range ends (one past the last selected column) + * @param attribs an array of attributes + */ + _setAttributesOnRangeByLine(row, startCol, endCol, attribs) { + const builder = Changeset.builder(this.rep.lines.totalWidth()); + ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [row, startCol]); + ChangesetUtils.buildKeepRange( + this.rep, + builder, + [row, startCol], + [row, endCol], + attribs, + this.rep.apool, + ); + return builder; + }, - /* + /* Returns if the line already has a line marker @param lineNum: the number of the line */ - lineHasMarker(lineNum) { - return lineAttributes.find( - (attribute) => this.getAttributeOnLine(lineNum, attribute) !== '') !== undefined; - }, + lineHasMarker(lineNum) { + return ( + lineAttributes.find( + (attribute) => this.getAttributeOnLine(lineNum, attribute) !== "", + ) !== undefined + ); + }, - /* + /* Gets a specified attribute on a line @param lineNum: the number of the line to set the attribute for @param attributeKey: the name of the attribute to get, e.g. list */ - getAttributeOnLine(lineNum, attributeName) { - // get `attributeName` attribute of first char of line - const aline = this.rep.alines[lineNum]; - if (!aline) return ''; - const [op] = Changeset.deserializeOps(aline); - if (op == null) return ''; - return AttributeMap.fromString(op.attribs, this.rep.apool).get(attributeName) || ''; - }, + getAttributeOnLine(lineNum, attributeName) { + // get `attributeName` attribute of first char of line + const aline = this.rep.alines[lineNum]; + if (!aline) return ""; + const [op] = Changeset.deserializeOps(aline); + if (op == null) return ""; + return ( + AttributeMap.fromString(op.attribs, this.rep.apool).get(attributeName) || + "" + ); + }, - /* + /* Gets all attributes on a line @param lineNum: the number of the line to get the attribute for */ - getAttributesOnLine(lineNum) { - // get attributes of first char of line - const aline = this.rep.alines[lineNum]; - if (!aline) return []; - const [op] = Changeset.deserializeOps(aline); - if (op == null) return []; - return [...attributes.attribsFromString(op.attribs, this.rep.apool)]; - }, + getAttributesOnLine(lineNum) { + // get attributes of first char of line + const aline = this.rep.alines[lineNum]; + if (!aline) return []; + const [op] = Changeset.deserializeOps(aline); + if (op == null) return []; + return [...attributes.attribsFromString(op.attribs, this.rep.apool)]; + }, - /* + /* Gets a given attribute on a selection @param attributeName @param prevChar returns true or false if an attribute is visible in range */ - getAttributeOnSelection(attributeName, prevChar) { - const rep = this.rep; - if (!(rep.selStart && rep.selEnd)) return; - // If we're looking for the caret attribute not the selection - // has the user already got a selection or is this purely a caret location? - const isNotSelection = (rep.selStart[0] === rep.selEnd[0] && rep.selEnd[1] === rep.selStart[1]); - if (isNotSelection) { - if (prevChar) { - // If it's not the start of the line - if (rep.selStart[1] !== 0) { - rep.selStart[1]--; - } - } - } + getAttributeOnSelection(attributeName, prevChar) { + const rep = this.rep; + if (!(rep.selStart && rep.selEnd)) return; + // If we're looking for the caret attribute not the selection + // has the user already got a selection or is this purely a caret location? + const isNotSelection = + rep.selStart[0] === rep.selEnd[0] && rep.selEnd[1] === rep.selStart[1]; + if (isNotSelection) { + if (prevChar) { + // If it's not the start of the line + if (rep.selStart[1] !== 0) { + rep.selStart[1]--; + } + } + } - const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString(); - const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`); - const hasIt = (attribs) => withItRegex.test(attribs); + const withIt = new AttributeMap(rep.apool) + .set(attributeName, "true") + .toString(); + const withItRegex = new RegExp(`${withIt.replace(/\*/g, "\\*")}(\\*|$)`); + const hasIt = (attribs) => withItRegex.test(attribs); - const rangeHasAttrib = (selStart, selEnd) => { - // if range is collapsed -> no attribs in range - if (selStart[1] === selEnd[1] && selStart[0] === selEnd[0]) return false; + const rangeHasAttrib = (selStart, selEnd) => { + // if range is collapsed -> no attribs in range + if (selStart[1] === selEnd[1] && selStart[0] === selEnd[0]) return false; - if (selStart[0] !== selEnd[0]) { // -> More than one line selected - // from selStart to the end of the first line - let hasAttrib = rangeHasAttrib( - selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]); + if (selStart[0] !== selEnd[0]) { + // -> More than one line selected + // from selStart to the end of the first line + let hasAttrib = rangeHasAttrib(selStart, [ + selStart[0], + rep.lines.atIndex(selStart[0]).text.length, + ]); - // for all lines in between - for (let n = selStart[0] + 1; n < selEnd[0]; n++) { - hasAttrib = hasAttrib && rangeHasAttrib([n, 0], [n, rep.lines.atIndex(n).text.length]); - } + // for all lines in between + for (let n = selStart[0] + 1; n < selEnd[0]; n++) { + hasAttrib = + hasAttrib && + rangeHasAttrib([n, 0], [n, rep.lines.atIndex(n).text.length]); + } - // for the last, potentially partial, line - hasAttrib = hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]); + // for the last, potentially partial, line + hasAttrib = + hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]); - return hasAttrib; - } + return hasAttrib; + } - // Logic tells us we now have a range on a single line + // Logic tells us we now have a range on a single line - const lineNum = selStart[0]; - const start = selStart[1]; - const end = selEnd[1]; - let hasAttrib = true; + const lineNum = selStart[0]; + const start = selStart[1]; + const end = selEnd[1]; + let hasAttrib = true; - let indexIntoLine = 0; - for (const op of Changeset.deserializeOps(rep.alines[lineNum])) { - const opStartInLine = indexIntoLine; - const opEndInLine = opStartInLine + op.chars; - if (!hasIt(op.attribs)) { - // does op overlap selection? - if (!(opEndInLine <= start || opStartInLine >= end)) { - // since it's overlapping but hasn't got the attrib -> range hasn't got it - hasAttrib = false; - break; - } - } - indexIntoLine = opEndInLine; - } + let indexIntoLine = 0; + for (const op of Changeset.deserializeOps(rep.alines[lineNum])) { + const opStartInLine = indexIntoLine; + const opEndInLine = opStartInLine + op.chars; + if (!hasIt(op.attribs)) { + // does op overlap selection? + if (!(opEndInLine <= start || opStartInLine >= end)) { + // since it's overlapping but hasn't got the attrib -> range hasn't got it + hasAttrib = false; + break; + } + } + indexIntoLine = opEndInLine; + } - return hasAttrib; - }; - return rangeHasAttrib(rep.selStart, rep.selEnd); - }, + return hasAttrib; + }; + return rangeHasAttrib(rep.selStart, rep.selEnd); + }, - /* + /* Gets all attributes at a position containing line number and column @param lineNumber starting with zero @param column starting with zero returns a list of attributes in the format [ ["key","value"], ["key","value"], ... ] */ - getAttributesOnPosition(lineNumber, column) { - // get all attributes of the line - const aline = this.rep.alines[lineNumber]; + getAttributesOnPosition(lineNumber, column) { + // get all attributes of the line + const aline = this.rep.alines[lineNumber]; - if (!aline) { - return []; - } + if (!aline) { + return []; + } - // we need to sum up how much characters each operations take until the wanted position - let currentPointer = 0; + // we need to sum up how much characters each operations take until the wanted position + let currentPointer = 0; - for (const currentOperation of Changeset.deserializeOps(aline)) { - currentPointer += currentOperation.chars; - if (currentPointer <= column) continue; - return [...attributes.attribsFromString(currentOperation.attribs, this.rep.apool)]; - } - return []; - }, + for (const currentOperation of Changeset.deserializeOps(aline)) { + currentPointer += currentOperation.chars; + if (currentPointer <= column) continue; + return [ + ...attributes.attribsFromString( + currentOperation.attribs, + this.rep.apool, + ), + ]; + } + return []; + }, - /* + /* Gets all attributes at caret position if the user selected a range, the start of the selection is taken returns a list of attributes in the format [ ["key","value"], ["key","value"], ... ] */ - getAttributesOnCaret() { - return this.getAttributesOnPosition(this.rep.selStart[0], this.rep.selStart[1]); - }, + getAttributesOnCaret() { + return this.getAttributesOnPosition( + this.rep.selStart[0], + this.rep.selStart[1], + ); + }, - /* + /* Sets a specified attribute on a line @param lineNum: the number of the line to set the attribute for @param attributeKey: the name of the attribute to set, e.g. list @param attributeValue: an optional parameter to pass to the attribute (e.g. indention level) */ - setAttributeOnLine(lineNum, attributeName, attributeValue) { - let loc = [0, 0]; - const builder = Changeset.builder(this.rep.lines.totalWidth()); - const hasMarker = this.lineHasMarker(lineNum); + setAttributeOnLine(lineNum, attributeName, attributeValue) { + let loc = [0, 0]; + const builder = Changeset.builder(this.rep.lines.totalWidth()); + const hasMarker = this.lineHasMarker(lineNum); - ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 0])); + ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 0])); - if (hasMarker) { - ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 1]), [ - [attributeName, attributeValue], - ], this.rep.apool); - } else { - // add a line marker - builder.insert('*', [ - ['author', this.author], - ['insertorder', 'first'], - [lineMarkerAttribute, '1'], - [attributeName, attributeValue], - ], this.rep.apool); - } + if (hasMarker) { + ChangesetUtils.buildKeepRange( + this.rep, + builder, + loc, + (loc = [lineNum, 1]), + [[attributeName, attributeValue]], + this.rep.apool, + ); + } else { + // add a line marker + builder.insert( + "*", + [ + ["author", this.author], + ["insertorder", "first"], + [lineMarkerAttribute, "1"], + [attributeName, attributeValue], + ], + this.rep.apool, + ); + } - return this.applyChangeset(builder); - }, + return this.applyChangeset(builder); + }, - /** - * Removes a specified attribute on a line - * @param lineNum the number of the affected line - * @param attributeName the name of the attribute to remove, e.g. list - * @param attributeValue if given only attributes with equal value will be removed - */ - removeAttributeOnLine(lineNum, attributeName, attributeValue) { - const builder = Changeset.builder(this.rep.lines.totalWidth()); - const hasMarker = this.lineHasMarker(lineNum); - let found = false; + /** + * Removes a specified attribute on a line + * @param lineNum the number of the affected line + * @param attributeName the name of the attribute to remove, e.g. list + * @param attributeValue if given only attributes with equal value will be removed + */ + removeAttributeOnLine(lineNum, attributeName, attributeValue) { + const builder = Changeset.builder(this.rep.lines.totalWidth()); + const hasMarker = this.lineHasMarker(lineNum); + let found = false; - const attribs = this.getAttributesOnLine(lineNum).map((attrib) => { - if (attrib[0] === attributeName && (!attributeValue || attrib[0] === attributeValue)) { - found = true; - return [attrib[0], '']; - } else if (attrib[0] === 'author') { - // update last author to make changes to line attributes on this line - return [attrib[0], this.author]; - } - return attrib; - }); + const attribs = this.getAttributesOnLine(lineNum).map((attrib) => { + if ( + attrib[0] === attributeName && + (!attributeValue || attrib[0] === attributeValue) + ) { + found = true; + return [attrib[0], ""]; + } else if (attrib[0] === "author") { + // update last author to make changes to line attributes on this line + return [attrib[0], this.author]; + } + return attrib; + }); - if (!found) { - return; - } + if (!found) { + return; + } - ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [lineNum, 0]); + ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [lineNum, 0]); - const countAttribsWithMarker = _.chain(attribs).filter((a) => !!a[1]) - .map((a) => a[0]).difference(DEFAULT_LINE_ATTRIBUTES).size().value(); + const countAttribsWithMarker = _.chain(attribs) + .filter((a) => !!a[1]) + .map((a) => a[0]) + .difference(DEFAULT_LINE_ATTRIBUTES) + .size() + .value(); - // if we have marker and any of attributes don't need to have marker. we need delete it - if (hasMarker && !countAttribsWithMarker) { - ChangesetUtils.buildRemoveRange(this.rep, builder, [lineNum, 0], [lineNum, 1]); - } else { - ChangesetUtils.buildKeepRange( - this.rep, builder, [lineNum, 0], [lineNum, 1], attribs, this.rep.apool); - } + // if we have marker and any of attributes don't need to have marker. we need delete it + if (hasMarker && !countAttribsWithMarker) { + ChangesetUtils.buildRemoveRange( + this.rep, + builder, + [lineNum, 0], + [lineNum, 1], + ); + } else { + ChangesetUtils.buildKeepRange( + this.rep, + builder, + [lineNum, 0], + [lineNum, 1], + attribs, + this.rep.apool, + ); + } - return this.applyChangeset(builder); - }, + return this.applyChangeset(builder); + }, - /* + /* Toggles a line attribute for the specified line number If a line attribute with the specified name exists with any value it will be removed Otherwise it will be set to the given value @@ -357,26 +433,26 @@ AttributeManager.prototype = _(AttributeManager.prototype).extend({ @param attributeKey: the name of the attribute to toggle, e.g. list @param attributeValue: the value to pass to the attribute (e.g. indention level) */ - toggleAttributeOnLine(lineNum, attributeName, attributeValue) { - return this.getAttributeOnLine(lineNum, attributeName) - ? this.removeAttributeOnLine(lineNum, attributeName) - : this.setAttributeOnLine(lineNum, attributeName, attributeValue); - }, + toggleAttributeOnLine(lineNum, attributeName, attributeValue) { + return this.getAttributeOnLine(lineNum, attributeName) + ? this.removeAttributeOnLine(lineNum, attributeName) + : this.setAttributeOnLine(lineNum, attributeName, attributeValue); + }, - hasAttributeOnSelectionOrCaretPosition(attributeName) { - const hasSelection = ( - (this.rep.selStart[0] !== this.rep.selEnd[0]) || (this.rep.selEnd[1] !== this.rep.selStart[1]) - ); - let hasAttrib; - if (hasSelection) { - hasAttrib = this.getAttributeOnSelection(attributeName); - } else { - const attributesOnCaretPosition = this.getAttributesOnCaret(); - const allAttribs = [].concat(...attributesOnCaretPosition); // flatten - hasAttrib = allAttribs.includes(attributeName); - } - return hasAttrib; - }, + hasAttributeOnSelectionOrCaretPosition(attributeName) { + const hasSelection = + this.rep.selStart[0] !== this.rep.selEnd[0] || + this.rep.selEnd[1] !== this.rep.selStart[1]; + let hasAttrib; + if (hasSelection) { + hasAttrib = this.getAttributeOnSelection(attributeName); + } else { + const attributesOnCaretPosition = this.getAttributesOnCaret(); + const allAttribs = [].concat(...attributesOnCaretPosition); // flatten + hasAttrib = allAttribs.includes(attributeName); + } + return hasAttrib; + }, }); module.exports = AttributeManager; diff --git a/src/static/js/AttributeMap.js b/src/static/js/AttributeMap.js index 55640eb8b..305996eb8 100644 --- a/src/static/js/AttributeMap.js +++ b/src/static/js/AttributeMap.js @@ -1,6 +1,6 @@ -'use strict'; +"use strict"; -const attributes = require('./attributes'); +const attributes = require("./attributes"); /** * A `[key, value]` pair of strings describing a text attribute. @@ -21,71 +21,74 @@ const attributes = require('./attributes'); * Convenience class to convert an Op's attribute string to/from a Map of key, value pairs. */ class AttributeMap extends Map { - /** - * Converts an attribute string into an AttributeMap. - * - * @param {AttributeString} str - The attribute string to convert into an AttributeMap. - * @param {AttributePool} pool - Attribute pool. - * @returns {AttributeMap} - */ - static fromString(str, pool) { - return new AttributeMap(pool).updateFromString(str); - } + /** + * Converts an attribute string into an AttributeMap. + * + * @param {AttributeString} str - The attribute string to convert into an AttributeMap. + * @param {AttributePool} pool - Attribute pool. + * @returns {AttributeMap} + */ + static fromString(str, pool) { + return new AttributeMap(pool).updateFromString(str); + } - /** - * @param {AttributePool} pool - Attribute pool. - */ - constructor(pool) { - super(); - /** @public */ - this.pool = pool; - } + /** + * @param {AttributePool} pool - Attribute pool. + */ + constructor(pool) { + super(); + /** @public */ + this.pool = pool; + } - /** - * @param {string} k - Attribute name. - * @param {string} v - Attribute value. - * @returns {AttributeMap} `this` (for chaining). - */ - set(k, v) { - k = k == null ? '' : String(k); - v = v == null ? '' : String(v); - this.pool.putAttrib([k, v]); - return super.set(k, v); - } + /** + * @param {string} k - Attribute name. + * @param {string} v - Attribute value. + * @returns {AttributeMap} `this` (for chaining). + */ + set(k, v) { + k = k == null ? "" : String(k); + v = v == null ? "" : String(v); + this.pool.putAttrib([k, v]); + return super.set(k, v); + } - toString() { - return attributes.attribsToString(attributes.sort([...this]), this.pool); - } + toString() { + return attributes.attribsToString(attributes.sort([...this]), this.pool); + } - /** - * @param {Iterable} entries - [key, value] pairs to insert into this map. - * @param {boolean} [emptyValueIsDelete] - If true and an entry's value is the empty string, the - * key is removed from this map (if present). - * @returns {AttributeMap} `this` (for chaining). - */ - update(entries, emptyValueIsDelete = false) { - for (let [k, v] of entries) { - k = k == null ? '' : String(k); - v = v == null ? '' : String(v); - if (!v && emptyValueIsDelete) { - this.delete(k); - } else { - this.set(k, v); - } - } - return this; - } + /** + * @param {Iterable} entries - [key, value] pairs to insert into this map. + * @param {boolean} [emptyValueIsDelete] - If true and an entry's value is the empty string, the + * key is removed from this map (if present). + * @returns {AttributeMap} `this` (for chaining). + */ + update(entries, emptyValueIsDelete = false) { + for (let [k, v] of entries) { + k = k == null ? "" : String(k); + v = v == null ? "" : String(v); + if (!v && emptyValueIsDelete) { + this.delete(k); + } else { + this.set(k, v); + } + } + return this; + } - /** - * @param {AttributeString} str - The attribute string identifying the attributes to insert into - * this map. - * @param {boolean} [emptyValueIsDelete] - If true and an entry's value is the empty string, the - * key is removed from this map (if present). - * @returns {AttributeMap} `this` (for chaining). - */ - updateFromString(str, emptyValueIsDelete = false) { - return this.update(attributes.attribsFromString(str, this.pool), emptyValueIsDelete); - } + /** + * @param {AttributeString} str - The attribute string identifying the attributes to insert into + * this map. + * @param {boolean} [emptyValueIsDelete] - If true and an entry's value is the empty string, the + * key is removed from this map (if present). + * @returns {AttributeMap} `this` (for chaining). + */ + updateFromString(str, emptyValueIsDelete = false) { + return this.update( + attributes.attribsFromString(str, this.pool), + emptyValueIsDelete, + ); + } } module.exports = AttributeMap; diff --git a/src/static/js/AttributePool.js b/src/static/js/AttributePool.js index ccdd2eb35..b6abdeb5a 100644 --- a/src/static/js/AttributePool.js +++ b/src/static/js/AttributePool.js @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * This code represents the Attribute Pool Object of the original Etherpad. * 90% of the code is still like in the original Etherpad @@ -55,185 +55,195 @@ * in the pad. */ class AttributePool { - constructor() { - /** - * Maps an attribute identifier to the attribute's `[key, value]` string pair. - * - * TODO: Rename to `_numToAttrib` once all users have been migrated to call `getAttrib` instead - * of accessing this directly. - * @private - * TODO: Convert to an array. - * @type {NumToAttrib} - */ - this.numToAttrib = {}; // e.g. {0: ['foo','bar']} + constructor() { + /** + * Maps an attribute identifier to the attribute's `[key, value]` string pair. + * + * TODO: Rename to `_numToAttrib` once all users have been migrated to call `getAttrib` instead + * of accessing this directly. + * @private + * TODO: Convert to an array. + * @type {NumToAttrib} + */ + this.numToAttrib = {}; // e.g. {0: ['foo','bar']} - /** - * Maps the string representation of an attribute (`String([key, value])`) to its non-negative - * identifier. - * - * TODO: Rename to `_attribToNum` once all users have been migrated to use `putAttrib` instead - * of accessing this directly. - * @private - * TODO: Convert to a `Map` object. - * @type {Object.} - */ - this.attribToNum = {}; // e.g. {'foo,bar': 0} + /** + * Maps the string representation of an attribute (`String([key, value])`) to its non-negative + * identifier. + * + * TODO: Rename to `_attribToNum` once all users have been migrated to use `putAttrib` instead + * of accessing this directly. + * @private + * TODO: Convert to a `Map` object. + * @type {Object.} + */ + this.attribToNum = {}; // e.g. {'foo,bar': 0} - /** - * The attribute ID to assign to the next new attribute. - * - * TODO: This property will not be necessary once `numToAttrib` is converted to an array (just - * push onto the array). - * - * @private - * @type {number} - */ - this.nextNum = 0; - } + /** + * The attribute ID to assign to the next new attribute. + * + * TODO: This property will not be necessary once `numToAttrib` is converted to an array (just + * push onto the array). + * + * @private + * @type {number} + */ + this.nextNum = 0; + } - /** - * @returns {AttributePool} A deep copy of this attribute pool. - */ - clone() { - const c = new AttributePool(); - for (const [n, a] of Object.entries(this.numToAttrib)) c.numToAttrib[n] = [a[0], a[1]]; - Object.assign(c.attribToNum, this.attribToNum); - c.nextNum = this.nextNum; - return c; - } + /** + * @returns {AttributePool} A deep copy of this attribute pool. + */ + clone() { + const c = new AttributePool(); + for (const [n, a] of Object.entries(this.numToAttrib)) + c.numToAttrib[n] = [a[0], a[1]]; + Object.assign(c.attribToNum, this.attribToNum); + c.nextNum = this.nextNum; + return c; + } - /** - * Add an attribute to the attribute set, or query for an existing attribute identifier. - * - * @param {Attribute} attrib - The attribute's `[key, value]` pair of strings. - * @param {boolean} [dontAddIfAbsent=false] - If true, do not insert the attribute into the pool - * if the attribute does not already exist in the pool. This can be used to test for - * membership in the pool without mutating the pool. - * @returns {number} The attribute's identifier, or -1 if the attribute is not in the pool. - */ - putAttrib(attrib, dontAddIfAbsent = false) { - const str = String(attrib); - if (str in this.attribToNum) { - return this.attribToNum[str]; - } - if (dontAddIfAbsent) { - return -1; - } - const num = this.nextNum++; - this.attribToNum[str] = num; - this.numToAttrib[num] = [String(attrib[0] || ''), String(attrib[1] || '')]; - return num; - } + /** + * Add an attribute to the attribute set, or query for an existing attribute identifier. + * + * @param {Attribute} attrib - The attribute's `[key, value]` pair of strings. + * @param {boolean} [dontAddIfAbsent=false] - If true, do not insert the attribute into the pool + * if the attribute does not already exist in the pool. This can be used to test for + * membership in the pool without mutating the pool. + * @returns {number} The attribute's identifier, or -1 if the attribute is not in the pool. + */ + putAttrib(attrib, dontAddIfAbsent = false) { + const str = String(attrib); + if (str in this.attribToNum) { + return this.attribToNum[str]; + } + if (dontAddIfAbsent) { + return -1; + } + const num = this.nextNum++; + this.attribToNum[str] = num; + this.numToAttrib[num] = [String(attrib[0] || ""), String(attrib[1] || "")]; + return num; + } - /** - * @param {number} num - The identifier of the attribute to fetch. - * @returns {Attribute} The attribute with the given identifier, or nullish if there is no such - * attribute. - */ - getAttrib(num) { - const pair = this.numToAttrib[num]; - if (!pair) { - return pair; - } - return [pair[0], pair[1]]; // return a mutable copy - } + /** + * @param {number} num - The identifier of the attribute to fetch. + * @returns {Attribute} The attribute with the given identifier, or nullish if there is no such + * attribute. + */ + getAttrib(num) { + const pair = this.numToAttrib[num]; + if (!pair) { + return pair; + } + return [pair[0], pair[1]]; // return a mutable copy + } - /** - * @param {number} num - The identifier of the attribute to fetch. - * @returns {string} Eqivalent to `getAttrib(num)[0]` if the attribute exists, otherwise the empty - * string. - */ - getAttribKey(num) { - const pair = this.numToAttrib[num]; - if (!pair) return ''; - return pair[0]; - } + /** + * @param {number} num - The identifier of the attribute to fetch. + * @returns {string} Eqivalent to `getAttrib(num)[0]` if the attribute exists, otherwise the empty + * string. + */ + getAttribKey(num) { + const pair = this.numToAttrib[num]; + if (!pair) return ""; + return pair[0]; + } - /** - * @param {number} num - The identifier of the attribute to fetch. - * @returns {string} Eqivalent to `getAttrib(num)[1]` if the attribute exists, otherwise the empty - * string. - */ - getAttribValue(num) { - const pair = this.numToAttrib[num]; - if (!pair) return ''; - return pair[1]; - } + /** + * @param {number} num - The identifier of the attribute to fetch. + * @returns {string} Eqivalent to `getAttrib(num)[1]` if the attribute exists, otherwise the empty + * string. + */ + getAttribValue(num) { + const pair = this.numToAttrib[num]; + if (!pair) return ""; + return pair[1]; + } - /** - * Executes a callback for each attribute in the pool. - * - * @param {Function} func - Callback to call with two arguments: key and value. Its return value - * is ignored. - */ - eachAttrib(func) { - for (const n of Object.keys(this.numToAttrib)) { - const pair = this.numToAttrib[n]; - func(pair[0], pair[1]); - } - } + /** + * Executes a callback for each attribute in the pool. + * + * @param {Function} func - Callback to call with two arguments: key and value. Its return value + * is ignored. + */ + eachAttrib(func) { + for (const n of Object.keys(this.numToAttrib)) { + const pair = this.numToAttrib[n]; + func(pair[0], pair[1]); + } + } - /** - * @returns {Jsonable} An object that can be passed to `fromJsonable` to reconstruct this - * attribute pool. The returned object can be converted to JSON. WARNING: The returned object - * has references to internal state (it is not a deep copy). Use the `clone()` method to copy - * a pool -- do NOT do `new AttributePool().fromJsonable(pool.toJsonable())` to copy because - * the resulting shared state will lead to pool corruption. - */ - toJsonable() { - return { - numToAttrib: this.numToAttrib, - nextNum: this.nextNum, - }; - } + /** + * @returns {Jsonable} An object that can be passed to `fromJsonable` to reconstruct this + * attribute pool. The returned object can be converted to JSON. WARNING: The returned object + * has references to internal state (it is not a deep copy). Use the `clone()` method to copy + * a pool -- do NOT do `new AttributePool().fromJsonable(pool.toJsonable())` to copy because + * the resulting shared state will lead to pool corruption. + */ + toJsonable() { + return { + numToAttrib: this.numToAttrib, + nextNum: this.nextNum, + }; + } - /** - * Replace the contents of this attribute pool with values from a previous call to `toJsonable`. - * - * @param {Jsonable} obj - Object returned by `toJsonable` containing the attributes and their - * identifiers. WARNING: This function takes ownership of the object (it does not make a deep - * copy). Use the `clone()` method to copy a pool -- do NOT do - * `new AttributePool().fromJsonable(pool.toJsonable())` to copy because the resulting shared - * state will lead to pool corruption. - */ - fromJsonable(obj) { - this.numToAttrib = obj.numToAttrib; - this.nextNum = obj.nextNum; - this.attribToNum = {}; - for (const n of Object.keys(this.numToAttrib)) { - this.attribToNum[String(this.numToAttrib[n])] = Number(n); - } - return this; - } + /** + * Replace the contents of this attribute pool with values from a previous call to `toJsonable`. + * + * @param {Jsonable} obj - Object returned by `toJsonable` containing the attributes and their + * identifiers. WARNING: This function takes ownership of the object (it does not make a deep + * copy). Use the `clone()` method to copy a pool -- do NOT do + * `new AttributePool().fromJsonable(pool.toJsonable())` to copy because the resulting shared + * state will lead to pool corruption. + */ + fromJsonable(obj) { + this.numToAttrib = obj.numToAttrib; + this.nextNum = obj.nextNum; + this.attribToNum = {}; + for (const n of Object.keys(this.numToAttrib)) { + this.attribToNum[String(this.numToAttrib[n])] = Number(n); + } + return this; + } - /** - * Asserts that the data in the pool is consistent. Throws if inconsistent. - */ - check() { - if (!Number.isInteger(this.nextNum)) throw new Error('nextNum property is not an integer'); - if (this.nextNum < 0) throw new Error('nextNum property is negative'); - for (const prop of ['numToAttrib', 'attribToNum']) { - const obj = this[prop]; - if (obj == null) throw new Error(`${prop} property is null`); - if (typeof obj !== 'object') throw new TypeError(`${prop} property is not an object`); - const keys = Object.keys(obj); - if (keys.length !== this.nextNum) { - throw new Error(`${prop} size mismatch (want ${this.nextNum}, got ${keys.length})`); - } - } - for (let i = 0; i < this.nextNum; ++i) { - const attr = this.numToAttrib[`${i}`]; - if (!Array.isArray(attr)) throw new TypeError(`attrib ${i} is not an array`); - if (attr.length !== 2) throw new Error(`attrib ${i} is not an array of length 2`); - const [k, v] = attr; - if (k == null) throw new TypeError(`attrib ${i} key is null`); - if (typeof k !== 'string') throw new TypeError(`attrib ${i} key is not a string`); - if (v == null) throw new TypeError(`attrib ${i} value is null`); - if (typeof v !== 'string') throw new TypeError(`attrib ${i} value is not a string`); - const attrStr = String(attr); - if (this.attribToNum[attrStr] !== i) throw new Error(`attribToNum for ${attrStr} !== ${i}`); - } - } + /** + * Asserts that the data in the pool is consistent. Throws if inconsistent. + */ + check() { + if (!Number.isInteger(this.nextNum)) + throw new Error("nextNum property is not an integer"); + if (this.nextNum < 0) throw new Error("nextNum property is negative"); + for (const prop of ["numToAttrib", "attribToNum"]) { + const obj = this[prop]; + if (obj == null) throw new Error(`${prop} property is null`); + if (typeof obj !== "object") + throw new TypeError(`${prop} property is not an object`); + const keys = Object.keys(obj); + if (keys.length !== this.nextNum) { + throw new Error( + `${prop} size mismatch (want ${this.nextNum}, got ${keys.length})`, + ); + } + } + for (let i = 0; i < this.nextNum; ++i) { + const attr = this.numToAttrib[`${i}`]; + if (!Array.isArray(attr)) + throw new TypeError(`attrib ${i} is not an array`); + if (attr.length !== 2) + throw new Error(`attrib ${i} is not an array of length 2`); + const [k, v] = attr; + if (k == null) throw new TypeError(`attrib ${i} key is null`); + if (typeof k !== "string") + throw new TypeError(`attrib ${i} key is not a string`); + if (v == null) throw new TypeError(`attrib ${i} value is null`); + if (typeof v !== "string") + throw new TypeError(`attrib ${i} value is not a string`); + const attrStr = String(attr); + if (this.attribToNum[attrStr] !== i) + throw new Error(`attribToNum for ${attrStr} !== ${i}`); + } + } } module.exports = AttributePool; diff --git a/src/static/js/Changeset.js b/src/static/js/Changeset.js index 53b3f2c8f..c5a44b859 100644 --- a/src/static/js/Changeset.js +++ b/src/static/js/Changeset.js @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /* * Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd) @@ -22,10 +22,10 @@ * https://github.com/ether/pad/blob/master/infrastructure/ace/www/easysync2.js */ -const AttributeMap = require('./AttributeMap'); -const AttributePool = require('./AttributePool'); -const attributes = require('./attributes'); -const {padutils} = require('./pad_utils'); +const AttributeMap = require("./AttributeMap"); +const AttributePool = require("./AttributePool"); +const attributes = require("./attributes"); +const { padutils } = require("./pad_utils"); /** * A `[key, value]` pair of strings describing a text attribute. @@ -48,9 +48,9 @@ const {padutils} = require('./pad_utils'); * @param {string} msg - Just some message */ const error = (msg) => { - const e = new Error(msg); - e.easysync = true; - throw e; + const e = new Error(msg); + e.easysync = true; + throw e; }; /** @@ -62,7 +62,7 @@ const error = (msg) => { * @type {(b: boolean, msg: string) => asserts b} */ const assert = (b, msg) => { - if (!b) error(`Failed assertion: ${msg}`); + if (!b) error(`Failed assertion: ${msg}`); }; /** @@ -85,70 +85,71 @@ exports.numToString = (num) => num.toString(36).toLowerCase(); * An operation to apply to a shared document. */ class Op { - /** - * @param {(''|'='|'+'|'-')} [opcode=''] - Initial value of the `opcode` property. - */ - constructor(opcode = '') { - /** - * The operation's operator: - * - '=': Keep the next `chars` characters (containing `lines` newlines) from the base - * document. - * - '-': Remove the next `chars` characters (containing `lines` newlines) from the base - * document. - * - '+': Insert `chars` characters (containing `lines` newlines) at the current position in - * the document. The inserted characters come from the changeset's character bank. - * - '' (empty string): Invalid operator used in some contexts to signifiy the lack of an - * operation. - * - * @type {(''|'='|'+'|'-')} - * @public - */ - this.opcode = opcode; + /** + * @param {(''|'='|'+'|'-')} [opcode=''] - Initial value of the `opcode` property. + */ + constructor(opcode = "") { + /** + * The operation's operator: + * - '=': Keep the next `chars` characters (containing `lines` newlines) from the base + * document. + * - '-': Remove the next `chars` characters (containing `lines` newlines) from the base + * document. + * - '+': Insert `chars` characters (containing `lines` newlines) at the current position in + * the document. The inserted characters come from the changeset's character bank. + * - '' (empty string): Invalid operator used in some contexts to signifiy the lack of an + * operation. + * + * @type {(''|'='|'+'|'-')} + * @public + */ + this.opcode = opcode; - /** - * The number of characters to keep, insert, or delete. - * - * @type {number} - * @public - */ - this.chars = 0; + /** + * The number of characters to keep, insert, or delete. + * + * @type {number} + * @public + */ + this.chars = 0; - /** - * The number of characters among the `chars` characters that are newlines. If non-zero, the - * last character must be a newline. - * - * @type {number} - * @public - */ - this.lines = 0; + /** + * The number of characters among the `chars` characters that are newlines. If non-zero, the + * last character must be a newline. + * + * @type {number} + * @public + */ + this.lines = 0; - /** - * Identifiers of attributes to apply to the text, represented as a repeated (zero or more) - * sequence of asterisk followed by a non-negative base-36 (lower-case) integer. For example, - * '*2*1o' indicates that attributes 2 and 60 apply to the text affected by the operation. The - * identifiers come from the document's attribute pool. - * - * For keep ('=') operations, the attributes are merged with the base text's existing - * attributes: - * - A keep op attribute with a non-empty value replaces an existing base text attribute that - * has the same key. - * - A keep op attribute with an empty value is interpreted as an instruction to remove an - * existing base text attribute that has the same key, if one exists. - * - * This is the empty string for remove ('-') operations. - * - * @type {string} - * @public - */ - this.attribs = ''; - } + /** + * Identifiers of attributes to apply to the text, represented as a repeated (zero or more) + * sequence of asterisk followed by a non-negative base-36 (lower-case) integer. For example, + * '*2*1o' indicates that attributes 2 and 60 apply to the text affected by the operation. The + * identifiers come from the document's attribute pool. + * + * For keep ('=') operations, the attributes are merged with the base text's existing + * attributes: + * - A keep op attribute with a non-empty value replaces an existing base text attribute that + * has the same key. + * - A keep op attribute with an empty value is interpreted as an instruction to remove an + * existing base text attribute that has the same key, if one exists. + * + * This is the empty string for remove ('-') operations. + * + * @type {string} + * @public + */ + this.attribs = ""; + } - toString() { - if (!this.opcode) throw new TypeError('null op'); - if (typeof this.attribs !== 'string') throw new TypeError('attribs must be a string'); - const l = this.lines ? `|${exports.numToString(this.lines)}` : ''; - return this.attribs + l + this.opcode + exports.numToString(this.chars); - } + toString() { + if (!this.opcode) throw new TypeError("null op"); + if (typeof this.attribs !== "string") + throw new TypeError("attribs must be a string"); + const l = this.lines ? `|${exports.numToString(this.lines)}` : ""; + return this.attribs + l + this.opcode + exports.numToString(this.chars); + } } exports.Op = Op; @@ -188,18 +189,19 @@ exports.newLen = (cs) => exports.unpack(cs).newLen; * @returns {Generator} */ exports.deserializeOps = function* (ops) { - // TODO: Migrate to String.prototype.matchAll() once there is enough browser support. - const regex = /((?:\*[0-9a-z]+)*)(?:\|([0-9a-z]+))?([-+=])([0-9a-z]+)|(.)/g; - let match; - while ((match = regex.exec(ops)) != null) { - if (match[5] === '$') return; // Start of the insert operation character bank. - if (match[5] != null) error(`invalid operation: ${ops.slice(regex.lastIndex - 1)}`); - const op = new Op(match[3]); - op.lines = exports.parseNum(match[2] || '0'); - op.chars = exports.parseNum(match[4]); - op.attribs = match[1]; - yield op; - } + // TODO: Migrate to String.prototype.matchAll() once there is enough browser support. + const regex = /((?:\*[0-9a-z]+)*)(?:\|([0-9a-z]+))?([-+=])([0-9a-z]+)|(.)/g; + let match; + while ((match = regex.exec(ops)) != null) { + if (match[5] === "$") return; // Start of the insert operation character bank. + if (match[5] != null) + error(`invalid operation: ${ops.slice(regex.lastIndex - 1)}`); + const op = new Op(match[3]); + op.lines = exports.parseNum(match[2] || "0"); + op.chars = exports.parseNum(match[4]); + op.attribs = match[1]; + yield op; + } }; /** @@ -210,39 +212,39 @@ exports.deserializeOps = function* (ops) { * @deprecated Use `deserializeOps` instead. */ class OpIter { - /** - * @param {string} ops - String encoding the change operations to iterate over. - */ - constructor(ops) { - this._gen = exports.deserializeOps(ops); - this._next = this._gen.next(); - } + /** + * @param {string} ops - String encoding the change operations to iterate over. + */ + constructor(ops) { + this._gen = exports.deserializeOps(ops); + this._next = this._gen.next(); + } - /** - * @returns {boolean} Whether there are any remaining operations. - */ - hasNext() { - return !this._next.done; - } + /** + * @returns {boolean} Whether there are any remaining operations. + */ + hasNext() { + return !this._next.done; + } - /** - * Returns the next operation object and advances the iterator. - * - * Note: This does NOT implement the ECMAScript iterator protocol. - * - * @param {Op} [opOut] - Deprecated. Operation object to recycle for the return value. - * @returns {Op} The next operation, or an operation with a falsy `opcode` property if there are - * no more operations. - */ - next(opOut = new Op()) { - if (this.hasNext()) { - copyOp(this._next.value, opOut); - this._next = this._gen.next(); - } else { - clearOp(opOut); - } - return opOut; - } + /** + * Returns the next operation object and advances the iterator. + * + * Note: This does NOT implement the ECMAScript iterator protocol. + * + * @param {Op} [opOut] - Deprecated. Operation object to recycle for the return value. + * @returns {Op} The next operation, or an operation with a falsy `opcode` property if there are + * no more operations. + */ + next(opOut = new Op()) { + if (this.hasNext()) { + copyOp(this._next.value, opOut); + this._next = this._gen.next(); + } else { + clearOp(opOut); + } + return opOut; + } } /** @@ -253,9 +255,10 @@ class OpIter { * @returns {OpIter} Operator iterator object. */ exports.opIterator = (opsStr) => { - padutils.warnDeprecated( - 'Changeset.opIterator() is deprecated; use Changeset.deserializeOps() instead'); - return new OpIter(opsStr); + padutils.warnDeprecated( + "Changeset.opIterator() is deprecated; use Changeset.deserializeOps() instead", + ); + return new OpIter(opsStr); }; /** @@ -264,10 +267,10 @@ exports.opIterator = (opsStr) => { * @param {Op} op - object to clear */ const clearOp = (op) => { - op.opcode = ''; - op.chars = 0; - op.lines = 0; - op.attribs = ''; + op.opcode = ""; + op.chars = 0; + op.lines = 0; + op.attribs = ""; }; /** @@ -278,8 +281,10 @@ const clearOp = (op) => { * @returns {Op} */ exports.newOp = (optOpcode) => { - padutils.warnDeprecated('Changeset.newOp() is deprecated; use the Changeset.Op class instead'); - return new Op(optOpcode); + padutils.warnDeprecated( + "Changeset.newOp() is deprecated; use the Changeset.Op class instead", + ); + return new Op(optOpcode); }; /** @@ -325,24 +330,26 @@ const copyOp = (op1, op2 = new Op()) => Object.assign(op2, op1); * @yields {Op} One or two ops (depending on the presense of newlines) that cover the given text. * @returns {Generator} */ -const opsFromText = function* (opcode, text, attribs = '', pool = null) { - const op = new Op(opcode); - op.attribs = typeof attribs === 'string' - ? attribs : new AttributeMap(pool).update(attribs || [], opcode === '+').toString(); - const lastNewlinePos = text.lastIndexOf('\n'); - if (lastNewlinePos < 0) { - op.chars = text.length; - op.lines = 0; - yield op; - } else { - op.chars = lastNewlinePos + 1; - op.lines = text.match(/\n/g).length; - yield op; - const op2 = copyOp(op); - op2.chars = text.length - (lastNewlinePos + 1); - op2.lines = 0; - yield op2; - } +const opsFromText = function* (opcode, text, attribs = "", pool = null) { + const op = new Op(opcode); + op.attribs = + typeof attribs === "string" + ? attribs + : new AttributeMap(pool).update(attribs || [], opcode === "+").toString(); + const lastNewlinePos = text.lastIndexOf("\n"); + if (lastNewlinePos < 0) { + op.chars = text.length; + op.lines = 0; + yield op; + } else { + op.chars = lastNewlinePos + 1; + op.lines = text.match(/\n/g).length; + yield op; + const op2 = copyOp(op); + op2.chars = text.length - (lastNewlinePos + 1); + op2.lines = 0; + yield op2; + } }; /** @@ -371,245 +378,267 @@ const opsFromText = function* (opcode, text, attribs = '', pool = null) { * @returns {string} the checked Changeset */ exports.checkRep = (cs) => { - const unpacked = exports.unpack(cs); - const oldLen = unpacked.oldLen; - const newLen = unpacked.newLen; - const ops = unpacked.ops; - let charBank = unpacked.charBank; + const unpacked = exports.unpack(cs); + const oldLen = unpacked.oldLen; + const newLen = unpacked.newLen; + const ops = unpacked.ops; + let charBank = unpacked.charBank; - const assem = exports.smartOpAssembler(); - let oldPos = 0; - let calcNewLen = 0; - for (const o of exports.deserializeOps(ops)) { - switch (o.opcode) { - case '=': - oldPos += o.chars; - calcNewLen += o.chars; - break; - case '-': - oldPos += o.chars; - assert(oldPos <= oldLen, `${oldPos} > ${oldLen} in ${cs}`); - break; - case '+': - { - assert(charBank.length >= o.chars, 'Invalid changeset: not enough chars in charBank'); - const chars = charBank.slice(0, o.chars); - const nlines = (chars.match(/\n/g) || []).length; - assert(nlines === o.lines, - 'Invalid changeset: number of newlines in insert op does not match the charBank'); - assert(o.lines === 0 || chars.endsWith('\n'), - 'Invalid changeset: multiline insert op does not end with a newline'); - charBank = charBank.slice(o.chars); - calcNewLen += o.chars; - assert(calcNewLen <= newLen, `${calcNewLen} > ${newLen} in ${cs}`); - break; - } - default: - assert(false, `Invalid changeset: Unknown opcode: ${JSON.stringify(o.opcode)}`); - } - assem.append(o); - } - calcNewLen += oldLen - oldPos; - assert(calcNewLen === newLen, 'Invalid changeset: claimed length does not match actual length'); - assert(charBank === '', 'Invalid changeset: excess characters in the charBank'); - assem.endDocument(); - const normalized = exports.pack(oldLen, calcNewLen, assem.toString(), unpacked.charBank); - assert(normalized === cs, 'Invalid changeset: not in canonical form'); - return cs; + const assem = exports.smartOpAssembler(); + let oldPos = 0; + let calcNewLen = 0; + for (const o of exports.deserializeOps(ops)) { + switch (o.opcode) { + case "=": + oldPos += o.chars; + calcNewLen += o.chars; + break; + case "-": + oldPos += o.chars; + assert(oldPos <= oldLen, `${oldPos} > ${oldLen} in ${cs}`); + break; + case "+": { + assert( + charBank.length >= o.chars, + "Invalid changeset: not enough chars in charBank", + ); + const chars = charBank.slice(0, o.chars); + const nlines = (chars.match(/\n/g) || []).length; + assert( + nlines === o.lines, + "Invalid changeset: number of newlines in insert op does not match the charBank", + ); + assert( + o.lines === 0 || chars.endsWith("\n"), + "Invalid changeset: multiline insert op does not end with a newline", + ); + charBank = charBank.slice(o.chars); + calcNewLen += o.chars; + assert(calcNewLen <= newLen, `${calcNewLen} > ${newLen} in ${cs}`); + break; + } + default: + assert( + false, + `Invalid changeset: Unknown opcode: ${JSON.stringify(o.opcode)}`, + ); + } + assem.append(o); + } + calcNewLen += oldLen - oldPos; + assert( + calcNewLen === newLen, + "Invalid changeset: claimed length does not match actual length", + ); + assert( + charBank === "", + "Invalid changeset: excess characters in the charBank", + ); + assem.endDocument(); + const normalized = exports.pack( + oldLen, + calcNewLen, + assem.toString(), + unpacked.charBank, + ); + assert(normalized === cs, "Invalid changeset: not in canonical form"); + return cs; }; /** * @returns {SmartOpAssembler} */ exports.smartOpAssembler = () => { - const minusAssem = exports.mergingOpAssembler(); - const plusAssem = exports.mergingOpAssembler(); - const keepAssem = exports.mergingOpAssembler(); - const assem = exports.stringAssembler(); - let lastOpcode = ''; - let lengthChange = 0; + const minusAssem = exports.mergingOpAssembler(); + const plusAssem = exports.mergingOpAssembler(); + const keepAssem = exports.mergingOpAssembler(); + const assem = exports.stringAssembler(); + let lastOpcode = ""; + let lengthChange = 0; - const flushKeeps = () => { - assem.append(keepAssem.toString()); - keepAssem.clear(); - }; + const flushKeeps = () => { + assem.append(keepAssem.toString()); + keepAssem.clear(); + }; - const flushPlusMinus = () => { - assem.append(minusAssem.toString()); - minusAssem.clear(); - assem.append(plusAssem.toString()); - plusAssem.clear(); - }; + const flushPlusMinus = () => { + assem.append(minusAssem.toString()); + minusAssem.clear(); + assem.append(plusAssem.toString()); + plusAssem.clear(); + }; - const append = (op) => { - if (!op.opcode) return; - if (!op.chars) return; + const append = (op) => { + if (!op.opcode) return; + if (!op.chars) return; - if (op.opcode === '-') { - if (lastOpcode === '=') { - flushKeeps(); - } - minusAssem.append(op); - lengthChange -= op.chars; - } else if (op.opcode === '+') { - if (lastOpcode === '=') { - flushKeeps(); - } - plusAssem.append(op); - lengthChange += op.chars; - } else if (op.opcode === '=') { - if (lastOpcode !== '=') { - flushPlusMinus(); - } - keepAssem.append(op); - } - lastOpcode = op.opcode; - }; + if (op.opcode === "-") { + if (lastOpcode === "=") { + flushKeeps(); + } + minusAssem.append(op); + lengthChange -= op.chars; + } else if (op.opcode === "+") { + if (lastOpcode === "=") { + flushKeeps(); + } + plusAssem.append(op); + lengthChange += op.chars; + } else if (op.opcode === "=") { + if (lastOpcode !== "=") { + flushPlusMinus(); + } + keepAssem.append(op); + } + lastOpcode = op.opcode; + }; - /** - * Generates operations from the given text and attributes. - * - * @deprecated Use `opsFromText` instead. - * @param {('-'|'+'|'=')} opcode - The operator to use. - * @param {string} text - The text to remove/add/keep. - * @param {(string|Iterable)} attribs - The attributes to apply to the operations. - * @param {?AttributePool} pool - Attribute pool. Only required if `attribs` is an iterable of - * attribute key, value pairs. - */ - const appendOpWithText = (opcode, text, attribs, pool) => { - padutils.warnDeprecated('Changeset.smartOpAssembler().appendOpWithText() is deprecated; ' + - 'use opsFromText() instead.'); - for (const op of opsFromText(opcode, text, attribs, pool)) append(op); - }; + /** + * Generates operations from the given text and attributes. + * + * @deprecated Use `opsFromText` instead. + * @param {('-'|'+'|'=')} opcode - The operator to use. + * @param {string} text - The text to remove/add/keep. + * @param {(string|Iterable)} attribs - The attributes to apply to the operations. + * @param {?AttributePool} pool - Attribute pool. Only required if `attribs` is an iterable of + * attribute key, value pairs. + */ + const appendOpWithText = (opcode, text, attribs, pool) => { + padutils.warnDeprecated( + "Changeset.smartOpAssembler().appendOpWithText() is deprecated; " + + "use opsFromText() instead.", + ); + for (const op of opsFromText(opcode, text, attribs, pool)) append(op); + }; - const toString = () => { - flushPlusMinus(); - flushKeeps(); - return assem.toString(); - }; + const toString = () => { + flushPlusMinus(); + flushKeeps(); + return assem.toString(); + }; - const clear = () => { - minusAssem.clear(); - plusAssem.clear(); - keepAssem.clear(); - assem.clear(); - lengthChange = 0; - }; + const clear = () => { + minusAssem.clear(); + plusAssem.clear(); + keepAssem.clear(); + assem.clear(); + lengthChange = 0; + }; - const endDocument = () => { - keepAssem.endDocument(); - }; + const endDocument = () => { + keepAssem.endDocument(); + }; - const getLengthChange = () => lengthChange; + const getLengthChange = () => lengthChange; - return { - append, - toString, - clear, - endDocument, - appendOpWithText, - getLengthChange, - }; + return { + append, + toString, + clear, + endDocument, + appendOpWithText, + getLengthChange, + }; }; /** * @returns {MergingOpAssembler} */ exports.mergingOpAssembler = () => { - const assem = exports.opAssembler(); - const bufOp = new Op(); + const assem = exports.opAssembler(); + const bufOp = new Op(); - // If we get, for example, insertions [xxx\n,yyy], those don't merge, - // but if we get [xxx\n,yyy,zzz\n], that merges to [xxx\nyyyzzz\n]. - // This variable stores the length of yyy and any other newline-less - // ops immediately after it. - let bufOpAdditionalCharsAfterNewline = 0; + // If we get, for example, insertions [xxx\n,yyy], those don't merge, + // but if we get [xxx\n,yyy,zzz\n], that merges to [xxx\nyyyzzz\n]. + // This variable stores the length of yyy and any other newline-less + // ops immediately after it. + let bufOpAdditionalCharsAfterNewline = 0; - /** - * @param {boolean} [isEndDocument] - */ - const flush = (isEndDocument) => { - if (!bufOp.opcode) return; - if (isEndDocument && bufOp.opcode === '=' && !bufOp.attribs) { - // final merged keep, leave it implicit - } else { - assem.append(bufOp); - if (bufOpAdditionalCharsAfterNewline) { - bufOp.chars = bufOpAdditionalCharsAfterNewline; - bufOp.lines = 0; - assem.append(bufOp); - bufOpAdditionalCharsAfterNewline = 0; - } - } - bufOp.opcode = ''; - }; + /** + * @param {boolean} [isEndDocument] + */ + const flush = (isEndDocument) => { + if (!bufOp.opcode) return; + if (isEndDocument && bufOp.opcode === "=" && !bufOp.attribs) { + // final merged keep, leave it implicit + } else { + assem.append(bufOp); + if (bufOpAdditionalCharsAfterNewline) { + bufOp.chars = bufOpAdditionalCharsAfterNewline; + bufOp.lines = 0; + assem.append(bufOp); + bufOpAdditionalCharsAfterNewline = 0; + } + } + bufOp.opcode = ""; + }; - const append = (op) => { - if (op.chars <= 0) return; - if (bufOp.opcode === op.opcode && bufOp.attribs === op.attribs) { - if (op.lines > 0) { - // bufOp and additional chars are all mergeable into a multi-line op - bufOp.chars += bufOpAdditionalCharsAfterNewline + op.chars; - bufOp.lines += op.lines; - bufOpAdditionalCharsAfterNewline = 0; - } else if (bufOp.lines === 0) { - // both bufOp and op are in-line - bufOp.chars += op.chars; - } else { - // append in-line text to multi-line bufOp - bufOpAdditionalCharsAfterNewline += op.chars; - } - } else { - flush(); - copyOp(op, bufOp); - } - }; + const append = (op) => { + if (op.chars <= 0) return; + if (bufOp.opcode === op.opcode && bufOp.attribs === op.attribs) { + if (op.lines > 0) { + // bufOp and additional chars are all mergeable into a multi-line op + bufOp.chars += bufOpAdditionalCharsAfterNewline + op.chars; + bufOp.lines += op.lines; + bufOpAdditionalCharsAfterNewline = 0; + } else if (bufOp.lines === 0) { + // both bufOp and op are in-line + bufOp.chars += op.chars; + } else { + // append in-line text to multi-line bufOp + bufOpAdditionalCharsAfterNewline += op.chars; + } + } else { + flush(); + copyOp(op, bufOp); + } + }; - const endDocument = () => { - flush(true); - }; + const endDocument = () => { + flush(true); + }; - const toString = () => { - flush(); - return assem.toString(); - }; + const toString = () => { + flush(); + return assem.toString(); + }; - const clear = () => { - assem.clear(); - clearOp(bufOp); - }; - return { - append, - toString, - clear, - endDocument, - }; + const clear = () => { + assem.clear(); + clearOp(bufOp); + }; + return { + append, + toString, + clear, + endDocument, + }; }; /** * @returns {OpAssembler} */ exports.opAssembler = () => { - let serialized = ''; + let serialized = ""; - /** - * @param {Op} op - Operation to add. Ownership remains with the caller. - */ - const append = (op) => { - assert(op instanceof Op, 'argument must be an instance of Op'); - serialized += op.toString(); - }; + /** + * @param {Op} op - Operation to add. Ownership remains with the caller. + */ + const append = (op) => { + assert(op instanceof Op, "argument must be an instance of Op"); + serialized += op.toString(); + }; - const toString = () => serialized; + const toString = () => serialized; - const clear = () => { - serialized = ''; - }; - return { - append, - toString, - clear, - }; + const clear = () => { + serialized = ""; + }; + return { + append, + toString, + clear, + }; }; /** @@ -628,42 +657,42 @@ exports.opAssembler = () => { * @returns {StringIterator} */ exports.stringIterator = (str) => { - let curIndex = 0; - // newLines is the number of \n between curIndex and str.length - let newLines = str.split('\n').length - 1; - const getnewLines = () => newLines; + let curIndex = 0; + // newLines is the number of \n between curIndex and str.length + let newLines = str.split("\n").length - 1; + const getnewLines = () => newLines; - const assertRemaining = (n) => { - assert(n <= remaining(), `!(${n} <= ${remaining()})`); - }; + const assertRemaining = (n) => { + assert(n <= remaining(), `!(${n} <= ${remaining()})`); + }; - const take = (n) => { - assertRemaining(n); - const s = str.substr(curIndex, n); - newLines -= s.split('\n').length - 1; - curIndex += n; - return s; - }; + const take = (n) => { + assertRemaining(n); + const s = str.substr(curIndex, n); + newLines -= s.split("\n").length - 1; + curIndex += n; + return s; + }; - const peek = (n) => { - assertRemaining(n); - const s = str.substr(curIndex, n); - return s; - }; + const peek = (n) => { + assertRemaining(n); + const s = str.substr(curIndex, n); + return s; + }; - const skip = (n) => { - assertRemaining(n); - curIndex += n; - }; + const skip = (n) => { + assertRemaining(n); + curIndex += n; + }; - const remaining = () => str.length - curIndex; - return { - take, - skip, - remaining, - peek, - newlines: getnewLines, - }; + const remaining = () => str.length - curIndex; + return { + take, + skip, + remaining, + peek, + newlines: getnewLines, + }; }; /** @@ -678,13 +707,19 @@ exports.stringIterator = (str) => { * @returns {StringAssembler} */ exports.stringAssembler = () => ({ - _str: '', - clear() { this._str = ''; }, - /** - * @param {string} x - - */ - append(x) { this._str += String(x); }, - toString() { return this._str; }, + _str: "", + clear() { + this._str = ""; + }, + /** + * @param {string} x - + */ + append(x) { + this._str += String(x); + }, + toString() { + return this._str; + }, }); /** @@ -711,310 +746,320 @@ exports.stringAssembler = () => ({ * with no newlines. */ class TextLinesMutator { - /** - * @param {(string[]|StringArrayLike)} lines - Lines to mutate (in place). - */ - constructor(lines) { - this._lines = lines; - /** - * this._curSplice holds values that will be passed as arguments to this._lines.splice() to - * insert, delete, or change lines: - * - this._curSplice[0] is an index into the this._lines array. - * - this._curSplice[1] is the number of lines that will be removed from the this._lines array - * starting at the index. - * - The other elements represent mutated (changed by ops) lines or new lines (added by ops) - * to insert at the index. - * - * @type {[number, number?, ...string[]?]} - */ - this._curSplice = [0, 0]; - this._inSplice = false; - // position in lines after curSplice is applied: - this._curLine = 0; - this._curCol = 0; - // invariant: if (inSplice) then (curLine is in curSplice[0] + curSplice.length - {2,3}) && - // curLine >= curSplice[0] - // invariant: if (inSplice && (curLine >= curSplice[0] + curSplice.length - 2)) then - // curCol == 0 - } + /** + * @param {(string[]|StringArrayLike)} lines - Lines to mutate (in place). + */ + constructor(lines) { + this._lines = lines; + /** + * this._curSplice holds values that will be passed as arguments to this._lines.splice() to + * insert, delete, or change lines: + * - this._curSplice[0] is an index into the this._lines array. + * - this._curSplice[1] is the number of lines that will be removed from the this._lines array + * starting at the index. + * - The other elements represent mutated (changed by ops) lines or new lines (added by ops) + * to insert at the index. + * + * @type {[number, number?, ...string[]?]} + */ + this._curSplice = [0, 0]; + this._inSplice = false; + // position in lines after curSplice is applied: + this._curLine = 0; + this._curCol = 0; + // invariant: if (inSplice) then (curLine is in curSplice[0] + curSplice.length - {2,3}) && + // curLine >= curSplice[0] + // invariant: if (inSplice && (curLine >= curSplice[0] + curSplice.length - 2)) then + // curCol == 0 + } - /** - * Get a line from `lines` at given index. - * - * @param {number} idx - an index - * @returns {string} - */ - _linesGet(idx) { - if ('get' in this._lines) { - return this._lines.get(idx); - } else { - return this._lines[idx]; - } - } + /** + * Get a line from `lines` at given index. + * + * @param {number} idx - an index + * @returns {string} + */ + _linesGet(idx) { + if ("get" in this._lines) { + return this._lines.get(idx); + } else { + return this._lines[idx]; + } + } - /** - * Return a slice from `lines`. - * - * @param {number} start - the start index - * @param {number} end - the end index - * @returns {string[]} - */ - _linesSlice(start, end) { - // can be unimplemented if removeLines's return value not needed - if (this._lines.slice) { - return this._lines.slice(start, end); - } else { - return []; - } - } + /** + * Return a slice from `lines`. + * + * @param {number} start - the start index + * @param {number} end - the end index + * @returns {string[]} + */ + _linesSlice(start, end) { + // can be unimplemented if removeLines's return value not needed + if (this._lines.slice) { + return this._lines.slice(start, end); + } else { + return []; + } + } - /** - * Return the length of `lines`. - * - * @returns {number} - */ - _linesLength() { - if (typeof this._lines.length === 'number') { - return this._lines.length; - } else { - return this._lines.length(); - } - } + /** + * Return the length of `lines`. + * + * @returns {number} + */ + _linesLength() { + if (typeof this._lines.length === "number") { + return this._lines.length; + } else { + return this._lines.length(); + } + } - /** - * Starts a new splice. - */ - _enterSplice() { - this._curSplice[0] = this._curLine; - this._curSplice[1] = 0; - // TODO(doc) when is this the case? - // check all enterSplice calls and changes to curCol - if (this._curCol > 0) this._putCurLineInSplice(); - this._inSplice = true; - } + /** + * Starts a new splice. + */ + _enterSplice() { + this._curSplice[0] = this._curLine; + this._curSplice[1] = 0; + // TODO(doc) when is this the case? + // check all enterSplice calls and changes to curCol + if (this._curCol > 0) this._putCurLineInSplice(); + this._inSplice = true; + } - /** - * Changes the lines array according to the values in curSplice and resets curSplice. Called via - * close or TODO(doc). - */ - _leaveSplice() { - this._lines.splice(...this._curSplice); - this._curSplice.length = 2; - this._curSplice[0] = this._curSplice[1] = 0; - this._inSplice = false; - } + /** + * Changes the lines array according to the values in curSplice and resets curSplice. Called via + * close or TODO(doc). + */ + _leaveSplice() { + this._lines.splice(...this._curSplice); + this._curSplice.length = 2; + this._curSplice[0] = this._curSplice[1] = 0; + this._inSplice = false; + } - /** - * Indicates if curLine is already in the splice. This is necessary because the last element in - * curSplice is curLine when this line is currently worked on (e.g. when skipping or inserting). - * - * @returns {boolean} true if curLine is in splice - */ - _isCurLineInSplice() { - // The value of `this._curSplice[1]` does not matter when determining the return value because - // `this._curLine` refers to the line number *after* the splice is applied (so after those lines - // are deleted). - return this._curLine - this._curSplice[0] < this._curSplice.length - 2; - } + /** + * Indicates if curLine is already in the splice. This is necessary because the last element in + * curSplice is curLine when this line is currently worked on (e.g. when skipping or inserting). + * + * @returns {boolean} true if curLine is in splice + */ + _isCurLineInSplice() { + // The value of `this._curSplice[1]` does not matter when determining the return value because + // `this._curLine` refers to the line number *after* the splice is applied (so after those lines + // are deleted). + return this._curLine - this._curSplice[0] < this._curSplice.length - 2; + } - /** - * Incorporates current line into the splice and marks its old position to be deleted. - * - * @returns {number} the index of the added line in curSplice - */ - _putCurLineInSplice() { - if (!this._isCurLineInSplice()) { - this._curSplice.push(this._linesGet(this._curSplice[0] + this._curSplice[1])); - this._curSplice[1]++; - } - // TODO should be the same as this._curSplice.length - 1 - return 2 + this._curLine - this._curSplice[0]; - } + /** + * Incorporates current line into the splice and marks its old position to be deleted. + * + * @returns {number} the index of the added line in curSplice + */ + _putCurLineInSplice() { + if (!this._isCurLineInSplice()) { + this._curSplice.push( + this._linesGet(this._curSplice[0] + this._curSplice[1]), + ); + this._curSplice[1]++; + } + // TODO should be the same as this._curSplice.length - 1 + return 2 + this._curLine - this._curSplice[0]; + } - /** - * It will skip some newlines by putting them into the splice. - * - * @param {number} L - - * @param {boolean} includeInSplice - Indicates that attributes are present. - */ - skipLines(L, includeInSplice) { - if (!L) return; - if (includeInSplice) { - if (!this._inSplice) this._enterSplice(); - // TODO(doc) should this count the number of characters that are skipped to check? - for (let i = 0; i < L; i++) { - this._curCol = 0; - this._putCurLineInSplice(); - this._curLine++; - } - } else { - if (this._inSplice) { - if (L > 1) { - // TODO(doc) figure out why single lines are incorporated into splice instead of ignored - this._leaveSplice(); - } else { - this._putCurLineInSplice(); - } - } - this._curLine += L; - this._curCol = 0; - } - // tests case foo in remove(), which isn't otherwise covered in current impl - } + /** + * It will skip some newlines by putting them into the splice. + * + * @param {number} L - + * @param {boolean} includeInSplice - Indicates that attributes are present. + */ + skipLines(L, includeInSplice) { + if (!L) return; + if (includeInSplice) { + if (!this._inSplice) this._enterSplice(); + // TODO(doc) should this count the number of characters that are skipped to check? + for (let i = 0; i < L; i++) { + this._curCol = 0; + this._putCurLineInSplice(); + this._curLine++; + } + } else { + if (this._inSplice) { + if (L > 1) { + // TODO(doc) figure out why single lines are incorporated into splice instead of ignored + this._leaveSplice(); + } else { + this._putCurLineInSplice(); + } + } + this._curLine += L; + this._curCol = 0; + } + // tests case foo in remove(), which isn't otherwise covered in current impl + } - /** - * Skip some characters. Can contain newlines. - * - * @param {number} N - number of characters to skip - * @param {number} L - number of newlines to skip - * @param {boolean} includeInSplice - indicates if attributes are present - */ - skip(N, L, includeInSplice) { - if (!N) return; - if (L) { - this.skipLines(L, includeInSplice); - } else { - if (includeInSplice && !this._inSplice) this._enterSplice(); - if (this._inSplice) { - // although the line is put into splice curLine is not increased, because - // only some chars are skipped, not the whole line - this._putCurLineInSplice(); - } - this._curCol += N; - } - } + /** + * Skip some characters. Can contain newlines. + * + * @param {number} N - number of characters to skip + * @param {number} L - number of newlines to skip + * @param {boolean} includeInSplice - indicates if attributes are present + */ + skip(N, L, includeInSplice) { + if (!N) return; + if (L) { + this.skipLines(L, includeInSplice); + } else { + if (includeInSplice && !this._inSplice) this._enterSplice(); + if (this._inSplice) { + // although the line is put into splice curLine is not increased, because + // only some chars are skipped, not the whole line + this._putCurLineInSplice(); + } + this._curCol += N; + } + } - /** - * Remove whole lines from lines array. - * - * @param {number} L - number of lines to remove - * @returns {string} - */ - removeLines(L) { - if (!L) return ''; - if (!this._inSplice) this._enterSplice(); + /** + * Remove whole lines from lines array. + * + * @param {number} L - number of lines to remove + * @returns {string} + */ + removeLines(L) { + if (!L) return ""; + if (!this._inSplice) this._enterSplice(); - /** - * Gets a string of joined lines after the end of the splice. - * - * @param {number} k - number of lines - * @returns {string} joined lines - */ - const nextKLinesText = (k) => { - const m = this._curSplice[0] + this._curSplice[1]; - return this._linesSlice(m, m + k).join(''); - }; + /** + * Gets a string of joined lines after the end of the splice. + * + * @param {number} k - number of lines + * @returns {string} joined lines + */ + const nextKLinesText = (k) => { + const m = this._curSplice[0] + this._curSplice[1]; + return this._linesSlice(m, m + k).join(""); + }; - let removed = ''; - if (this._isCurLineInSplice()) { - if (this._curCol === 0) { - removed = this._curSplice[this._curSplice.length - 1]; - this._curSplice.length--; - removed += nextKLinesText(L - 1); - this._curSplice[1] += L - 1; - } else { - removed = nextKLinesText(L - 1); - this._curSplice[1] += L - 1; - const sline = this._curSplice.length - 1; - removed = this._curSplice[sline].substring(this._curCol) + removed; - this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + - this._linesGet(this._curSplice[0] + this._curSplice[1]); - this._curSplice[1] += 1; - } - } else { - removed = nextKLinesText(L); - this._curSplice[1] += L; - } - return removed; - } + let removed = ""; + if (this._isCurLineInSplice()) { + if (this._curCol === 0) { + removed = this._curSplice[this._curSplice.length - 1]; + this._curSplice.length--; + removed += nextKLinesText(L - 1); + this._curSplice[1] += L - 1; + } else { + removed = nextKLinesText(L - 1); + this._curSplice[1] += L - 1; + const sline = this._curSplice.length - 1; + removed = this._curSplice[sline].substring(this._curCol) + removed; + this._curSplice[sline] = + this._curSplice[sline].substring(0, this._curCol) + + this._linesGet(this._curSplice[0] + this._curSplice[1]); + this._curSplice[1] += 1; + } + } else { + removed = nextKLinesText(L); + this._curSplice[1] += L; + } + return removed; + } - /** - * Remove text from lines array. - * - * @param {number} N - characters to delete - * @param {number} L - lines to delete - * @returns {string} - */ - remove(N, L) { - if (!N) return ''; - if (L) return this.removeLines(L); - if (!this._inSplice) this._enterSplice(); - // although the line is put into splice, curLine is not increased, because - // only some chars are removed not the whole line - const sline = this._putCurLineInSplice(); - const removed = this._curSplice[sline].substring(this._curCol, this._curCol + N); - this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + - this._curSplice[sline].substring(this._curCol + N); - return removed; - } + /** + * Remove text from lines array. + * + * @param {number} N - characters to delete + * @param {number} L - lines to delete + * @returns {string} + */ + remove(N, L) { + if (!N) return ""; + if (L) return this.removeLines(L); + if (!this._inSplice) this._enterSplice(); + // although the line is put into splice, curLine is not increased, because + // only some chars are removed not the whole line + const sline = this._putCurLineInSplice(); + const removed = this._curSplice[sline].substring( + this._curCol, + this._curCol + N, + ); + this._curSplice[sline] = + this._curSplice[sline].substring(0, this._curCol) + + this._curSplice[sline].substring(this._curCol + N); + return removed; + } - /** - * Inserts text into lines array. - * - * @param {string} text - the text to insert - * @param {number} L - number of newlines in text - */ - insert(text, L) { - if (!text) return; - if (!this._inSplice) this._enterSplice(); - if (L) { - const newLines = exports.splitTextLines(text); - if (this._isCurLineInSplice()) { - const sline = this._curSplice.length - 1; - /** @type {string} */ - const theLine = this._curSplice[sline]; - const lineCol = this._curCol; - // Insert the chars up to `curCol` and the first new line. - this._curSplice[sline] = theLine.substring(0, lineCol) + newLines[0]; - this._curLine++; - newLines.splice(0, 1); - // insert the remaining new lines - this._curSplice.push(...newLines); - this._curLine += newLines.length; - // insert the remaining chars from the "old" line (e.g. the line we were in - // when we started to insert new lines) - this._curSplice.push(theLine.substring(lineCol)); - this._curCol = 0; // TODO(doc) why is this not set to the length of last line? - } else { - this._curSplice.push(...newLines); - this._curLine += newLines.length; - } - } else { - // There are no additional lines. Although the line is put into splice, curLine is not - // increased because there may be more chars in the line (newline is not reached). - const sline = this._putCurLineInSplice(); - if (!this._curSplice[sline]) { - const err = new Error( - 'curSplice[sline] not populated, actual curSplice contents is ' + - `${JSON.stringify(this._curSplice)}. Possibly related to ` + - 'https://github.com/ether/etherpad-lite/issues/2802'); - console.error(err.stack || err.toString()); - } - this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + text + - this._curSplice[sline].substring(this._curCol); - this._curCol += text.length; - } - } + /** + * Inserts text into lines array. + * + * @param {string} text - the text to insert + * @param {number} L - number of newlines in text + */ + insert(text, L) { + if (!text) return; + if (!this._inSplice) this._enterSplice(); + if (L) { + const newLines = exports.splitTextLines(text); + if (this._isCurLineInSplice()) { + const sline = this._curSplice.length - 1; + /** @type {string} */ + const theLine = this._curSplice[sline]; + const lineCol = this._curCol; + // Insert the chars up to `curCol` and the first new line. + this._curSplice[sline] = theLine.substring(0, lineCol) + newLines[0]; + this._curLine++; + newLines.splice(0, 1); + // insert the remaining new lines + this._curSplice.push(...newLines); + this._curLine += newLines.length; + // insert the remaining chars from the "old" line (e.g. the line we were in + // when we started to insert new lines) + this._curSplice.push(theLine.substring(lineCol)); + this._curCol = 0; // TODO(doc) why is this not set to the length of last line? + } else { + this._curSplice.push(...newLines); + this._curLine += newLines.length; + } + } else { + // There are no additional lines. Although the line is put into splice, curLine is not + // increased because there may be more chars in the line (newline is not reached). + const sline = this._putCurLineInSplice(); + if (!this._curSplice[sline]) { + const err = new Error( + "curSplice[sline] not populated, actual curSplice contents is " + + `${JSON.stringify(this._curSplice)}. Possibly related to ` + + "https://github.com/ether/etherpad-lite/issues/2802", + ); + console.error(err.stack || err.toString()); + } + this._curSplice[sline] = + this._curSplice[sline].substring(0, this._curCol) + + text + + this._curSplice[sline].substring(this._curCol); + this._curCol += text.length; + } + } - /** - * Checks if curLine (the line we are in when curSplice is applied) is the last line in `lines`. - * - * @returns {boolean} indicates if there are lines left - */ - hasMore() { - let docLines = this._linesLength(); - if (this._inSplice) { - docLines += this._curSplice.length - 2 - this._curSplice[1]; - } - return this._curLine < docLines; - } + /** + * Checks if curLine (the line we are in when curSplice is applied) is the last line in `lines`. + * + * @returns {boolean} indicates if there are lines left + */ + hasMore() { + let docLines = this._linesLength(); + if (this._inSplice) { + docLines += this._curSplice.length - 2 - this._curSplice[1]; + } + return this._curLine < docLines; + } - /** - * Closes the splice - */ - close() { - if (this._inSplice) this._leaveSplice(); - } + /** + * Closes the splice + */ + close() { + if (this._inSplice) this._leaveSplice(); + } } /** @@ -1039,22 +1084,22 @@ class TextLinesMutator { * @returns {string} the integrated changeset */ const applyZip = (in1, in2, func) => { - const ops1 = exports.deserializeOps(in1); - const ops2 = exports.deserializeOps(in2); - let next1 = ops1.next(); - let next2 = ops2.next(); - const assem = exports.smartOpAssembler(); - while (!next1.done || !next2.done) { - if (!next1.done && !next1.value.opcode) next1 = ops1.next(); - if (!next2.done && !next2.value.opcode) next2 = ops2.next(); - if (next1.value == null) next1.value = new Op(); - if (next2.value == null) next2.value = new Op(); - if (!next1.value.opcode && !next2.value.opcode) break; - const opOut = func(next1.value, next2.value); - if (opOut && opOut.opcode) assem.append(opOut); - } - assem.endDocument(); - return assem.toString(); + const ops1 = exports.deserializeOps(in1); + const ops2 = exports.deserializeOps(in2); + let next1 = ops1.next(); + let next2 = ops2.next(); + const assem = exports.smartOpAssembler(); + while (!next1.done || !next2.done) { + if (!next1.done && !next1.value.opcode) next1 = ops1.next(); + if (!next2.done && !next2.value.opcode) next2 = ops2.next(); + if (next1.value == null) next1.value = new Op(); + if (next2.value == null) next2.value = new Op(); + if (!next1.value.opcode && !next2.value.opcode) break; + const opOut = func(next1.value, next2.value); + if (opOut && opOut.opcode) assem.append(opOut); + } + assem.endDocument(); + return assem.toString(); }; /** @@ -1064,22 +1109,22 @@ const applyZip = (in1, in2, func) => { * @returns {Changeset} */ exports.unpack = (cs) => { - const headerRegex = /Z:([0-9a-z]+)([><])([0-9a-z]+)|/; - const headerMatch = headerRegex.exec(cs); - if ((!headerMatch) || (!headerMatch[0])) error(`Not a changeset: ${cs}`); - const oldLen = exports.parseNum(headerMatch[1]); - const changeSign = (headerMatch[2] === '>') ? 1 : -1; - const changeMag = exports.parseNum(headerMatch[3]); - const newLen = oldLen + changeSign * changeMag; - const opsStart = headerMatch[0].length; - let opsEnd = cs.indexOf('$'); - if (opsEnd < 0) opsEnd = cs.length; - return { - oldLen, - newLen, - ops: cs.substring(opsStart, opsEnd), - charBank: cs.substring(opsEnd + 1), - }; + const headerRegex = /Z:([0-9a-z]+)([><])([0-9a-z]+)|/; + const headerMatch = headerRegex.exec(cs); + if (!headerMatch || !headerMatch[0]) error(`Not a changeset: ${cs}`); + const oldLen = exports.parseNum(headerMatch[1]); + const changeSign = headerMatch[2] === ">" ? 1 : -1; + const changeMag = exports.parseNum(headerMatch[3]); + const newLen = oldLen + changeSign * changeMag; + const opsStart = headerMatch[0].length; + let opsEnd = cs.indexOf("$"); + if (opsEnd < 0) opsEnd = cs.length; + return { + oldLen, + newLen, + ops: cs.substring(opsStart, opsEnd), + charBank: cs.substring(opsEnd + 1), + }; }; /** @@ -1092,12 +1137,14 @@ exports.unpack = (cs) => { * @returns {string} The encoded changeset. */ exports.pack = (oldLen, newLen, opsStr, bank) => { - const lenDiff = newLen - oldLen; - const lenDiffStr = (lenDiff >= 0 ? `>${exports.numToString(lenDiff)}` - : `<${exports.numToString(-lenDiff)}`); - const a = []; - a.push('Z:', exports.numToString(oldLen), lenDiffStr, opsStr, '$', bank); - return a.join(''); + const lenDiff = newLen - oldLen; + const lenDiffStr = + lenDiff >= 0 + ? `>${exports.numToString(lenDiff)}` + : `<${exports.numToString(-lenDiff)}`; + const a = []; + a.push("Z:", exports.numToString(oldLen), lenDiffStr, opsStr, "$", bank); + return a.join(""); }; /** @@ -1108,41 +1155,50 @@ exports.pack = (oldLen, newLen, opsStr, bank) => { * @returns {string} */ exports.applyToText = (cs, str) => { - const unpacked = exports.unpack(cs); - assert(str.length === unpacked.oldLen, `mismatched apply: ${str.length} / ${unpacked.oldLen}`); - const bankIter = exports.stringIterator(unpacked.charBank); - const strIter = exports.stringIterator(str); - const assem = exports.stringAssembler(); - for (const op of exports.deserializeOps(unpacked.ops)) { - switch (op.opcode) { - case '+': - // op is + and op.lines 0: no newlines must be in op.chars - // op is + and op.lines >0: op.chars must include op.lines newlines - if (op.lines !== bankIter.peek(op.chars).split('\n').length - 1) { - throw new Error(`newline count is wrong in op +; cs:${cs} and text:${str}`); - } - assem.append(bankIter.take(op.chars)); - break; - case '-': - // op is - and op.lines 0: no newlines must be in the deleted string - // op is - and op.lines >0: op.lines newlines must be in the deleted string - if (op.lines !== strIter.peek(op.chars).split('\n').length - 1) { - throw new Error(`newline count is wrong in op -; cs:${cs} and text:${str}`); - } - strIter.skip(op.chars); - break; - case '=': - // op is = and op.lines 0: no newlines must be in the copied string - // op is = and op.lines >0: op.lines newlines must be in the copied string - if (op.lines !== strIter.peek(op.chars).split('\n').length - 1) { - throw new Error(`newline count is wrong in op =; cs:${cs} and text:${str}`); - } - assem.append(strIter.take(op.chars)); - break; - } - } - assem.append(strIter.take(strIter.remaining())); - return assem.toString(); + const unpacked = exports.unpack(cs); + assert( + str.length === unpacked.oldLen, + `mismatched apply: ${str.length} / ${unpacked.oldLen}`, + ); + const bankIter = exports.stringIterator(unpacked.charBank); + const strIter = exports.stringIterator(str); + const assem = exports.stringAssembler(); + for (const op of exports.deserializeOps(unpacked.ops)) { + switch (op.opcode) { + case "+": + // op is + and op.lines 0: no newlines must be in op.chars + // op is + and op.lines >0: op.chars must include op.lines newlines + if (op.lines !== bankIter.peek(op.chars).split("\n").length - 1) { + throw new Error( + `newline count is wrong in op +; cs:${cs} and text:${str}`, + ); + } + assem.append(bankIter.take(op.chars)); + break; + case "-": + // op is - and op.lines 0: no newlines must be in the deleted string + // op is - and op.lines >0: op.lines newlines must be in the deleted string + if (op.lines !== strIter.peek(op.chars).split("\n").length - 1) { + throw new Error( + `newline count is wrong in op -; cs:${cs} and text:${str}`, + ); + } + strIter.skip(op.chars); + break; + case "=": + // op is = and op.lines 0: no newlines must be in the copied string + // op is = and op.lines >0: op.lines newlines must be in the copied string + if (op.lines !== strIter.peek(op.chars).split("\n").length - 1) { + throw new Error( + `newline count is wrong in op =; cs:${cs} and text:${str}`, + ); + } + assem.append(strIter.take(op.chars)); + break; + } + } + assem.append(strIter.take(strIter.remaining())); + return assem.toString(); }; /** @@ -1152,23 +1208,23 @@ exports.applyToText = (cs, str) => { * @param {string[]} lines - The lines to which the changeset needs to be applied */ exports.mutateTextLines = (cs, lines) => { - const unpacked = exports.unpack(cs); - const bankIter = exports.stringIterator(unpacked.charBank); - const mut = new TextLinesMutator(lines); - for (const op of exports.deserializeOps(unpacked.ops)) { - switch (op.opcode) { - case '+': - mut.insert(bankIter.take(op.chars), op.lines); - break; - case '-': - mut.remove(op.chars, op.lines); - break; - case '=': - mut.skip(op.chars, op.lines, (!!op.attribs)); - break; - } - } - mut.close(); + const unpacked = exports.unpack(cs); + const bankIter = exports.stringIterator(unpacked.charBank); + const mut = new TextLinesMutator(lines); + for (const op of exports.deserializeOps(unpacked.ops)) { + switch (op.opcode) { + case "+": + mut.insert(bankIter.take(op.chars), op.lines); + break; + case "-": + mut.remove(op.chars, op.lines); + break; + case "=": + mut.skip(op.chars, op.lines, !!op.attribs); + break; + } + } + mut.close(); }; /** @@ -1181,28 +1237,30 @@ exports.mutateTextLines = (cs, lines) => { * @returns {string} */ exports.composeAttributes = (att1, att2, resultIsMutation, pool) => { - // att1 and att2 are strings like "*3*f*1c", asMutation is a boolean. - // Sometimes attribute (key,value) pairs are treated as attribute presence - // information, while other times they are treated as operations that - // mutate a set of attributes, and this affects whether an empty value - // is a deletion or a change. - // Examples, of the form (att1Items, att2Items, resultIsMutation) -> result - // ([], [(bold, )], true) -> [(bold, )] - // ([], [(bold, )], false) -> [] - // ([], [(bold, true)], true) -> [(bold, true)] - // ([], [(bold, true)], false) -> [(bold, true)] - // ([(bold, true)], [(bold, )], true) -> [(bold, )] - // ([(bold, true)], [(bold, )], false) -> [] - // pool can be null if att2 has no attributes. - if ((!att1) && resultIsMutation) { - // In the case of a mutation (i.e. composing two exportss), - // an att2 composed with an empy att1 is just att2. If att1 - // is part of an attribution string, then att2 may remove - // attributes that are already gone, so don't do this optimization. - return att2; - } - if (!att2) return att1; - return AttributeMap.fromString(att1, pool).updateFromString(att2, !resultIsMutation).toString(); + // att1 and att2 are strings like "*3*f*1c", asMutation is a boolean. + // Sometimes attribute (key,value) pairs are treated as attribute presence + // information, while other times they are treated as operations that + // mutate a set of attributes, and this affects whether an empty value + // is a deletion or a change. + // Examples, of the form (att1Items, att2Items, resultIsMutation) -> result + // ([], [(bold, )], true) -> [(bold, )] + // ([], [(bold, )], false) -> [] + // ([], [(bold, true)], true) -> [(bold, true)] + // ([], [(bold, true)], false) -> [(bold, true)] + // ([(bold, true)], [(bold, )], true) -> [(bold, )] + // ([(bold, true)], [(bold, )], false) -> [] + // pool can be null if att2 has no attributes. + if (!att1 && resultIsMutation) { + // In the case of a mutation (i.e. composing two exportss), + // an att2 composed with an empy att1 is just att2. If att1 + // is part of an attribution string, then att2 may remove + // attributes that are already gone, so don't do this optimization. + return att2; + } + if (!att2) return att1; + return AttributeMap.fromString(att1, pool) + .updateFromString(att2, !resultIsMutation) + .toString(); }; /** @@ -1215,56 +1273,76 @@ exports.composeAttributes = (att1, att2, resultIsMutation, pool) => { * @returns {Op} The result of applying `csOp` to `attOp`. */ const slicerZipperFunc = (attOp, csOp, pool) => { - const opOut = new Op(); - if (!attOp.opcode) { - copyOp(csOp, opOut); - csOp.opcode = ''; - } else if (!csOp.opcode) { - copyOp(attOp, opOut); - attOp.opcode = ''; - } else if (attOp.opcode === '-') { - copyOp(attOp, opOut); - attOp.opcode = ''; - } else if (csOp.opcode === '+') { - copyOp(csOp, opOut); - csOp.opcode = ''; - } else { - for (const op of [attOp, csOp]) { - assert(op.chars >= op.lines, `op has more newlines than chars: ${op.toString()}`); - } - assert( - attOp.chars < csOp.chars ? attOp.lines <= csOp.lines - : attOp.chars > csOp.chars ? attOp.lines >= csOp.lines - : attOp.lines === csOp.lines, - 'line count mismatch when composing changesets A*B; ' + - `opA: ${attOp.toString()} opB: ${csOp.toString()}`); - assert(['+', '='].includes(attOp.opcode), `unexpected opcode in op: ${attOp.toString()}`); - assert(['-', '='].includes(csOp.opcode), `unexpected opcode in op: ${csOp.toString()}`); - opOut.opcode = { - '+': { - '-': '', // The '-' cancels out (some of) the '+', leaving any remainder for the next call. - '=': '+', - }, - '=': { - '-': '-', - '=': '=', - }, - }[attOp.opcode][csOp.opcode]; - const [fullyConsumedOp, partiallyConsumedOp] = [attOp, csOp].sort((a, b) => a.chars - b.chars); - opOut.chars = fullyConsumedOp.chars; - opOut.lines = fullyConsumedOp.lines; - opOut.attribs = csOp.opcode === '-' - // csOp is a remove op and remove ops normally never have any attributes, so this should - // normally be the empty string. However, padDiff.js adds attributes to remove ops and needs - // them preserved so they are copied here. - ? csOp.attribs - : exports.composeAttributes(attOp.attribs, csOp.attribs, attOp.opcode === '=', pool); - partiallyConsumedOp.chars -= fullyConsumedOp.chars; - partiallyConsumedOp.lines -= fullyConsumedOp.lines; - if (!partiallyConsumedOp.chars) partiallyConsumedOp.opcode = ''; - fullyConsumedOp.opcode = ''; - } - return opOut; + const opOut = new Op(); + if (!attOp.opcode) { + copyOp(csOp, opOut); + csOp.opcode = ""; + } else if (!csOp.opcode) { + copyOp(attOp, opOut); + attOp.opcode = ""; + } else if (attOp.opcode === "-") { + copyOp(attOp, opOut); + attOp.opcode = ""; + } else if (csOp.opcode === "+") { + copyOp(csOp, opOut); + csOp.opcode = ""; + } else { + for (const op of [attOp, csOp]) { + assert( + op.chars >= op.lines, + `op has more newlines than chars: ${op.toString()}`, + ); + } + assert( + attOp.chars < csOp.chars + ? attOp.lines <= csOp.lines + : attOp.chars > csOp.chars + ? attOp.lines >= csOp.lines + : attOp.lines === csOp.lines, + "line count mismatch when composing changesets A*B; " + + `opA: ${attOp.toString()} opB: ${csOp.toString()}`, + ); + assert( + ["+", "="].includes(attOp.opcode), + `unexpected opcode in op: ${attOp.toString()}`, + ); + assert( + ["-", "="].includes(csOp.opcode), + `unexpected opcode in op: ${csOp.toString()}`, + ); + opOut.opcode = { + "+": { + "-": "", // The '-' cancels out (some of) the '+', leaving any remainder for the next call. + "=": "+", + }, + "=": { + "-": "-", + "=": "=", + }, + }[attOp.opcode][csOp.opcode]; + const [fullyConsumedOp, partiallyConsumedOp] = [attOp, csOp].sort( + (a, b) => a.chars - b.chars, + ); + opOut.chars = fullyConsumedOp.chars; + opOut.lines = fullyConsumedOp.lines; + opOut.attribs = + csOp.opcode === "-" + ? // csOp is a remove op and remove ops normally never have any attributes, so this should + // normally be the empty string. However, padDiff.js adds attributes to remove ops and needs + // them preserved so they are copied here. + csOp.attribs + : exports.composeAttributes( + attOp.attribs, + csOp.attribs, + attOp.opcode === "=", + pool, + ); + partiallyConsumedOp.chars -= fullyConsumedOp.chars; + partiallyConsumedOp.lines -= fullyConsumedOp.lines; + if (!partiallyConsumedOp.chars) partiallyConsumedOp.opcode = ""; + fullyConsumedOp.opcode = ""; + } + return opOut; }; /** @@ -1276,8 +1354,10 @@ const slicerZipperFunc = (attOp, csOp, pool) => { * @returns {string} */ exports.applyToAttribution = (cs, astr, pool) => { - const unpacked = exports.unpack(cs); - return applyZip(astr, unpacked.ops, (op1, op2) => slicerZipperFunc(op1, op2, pool)); + const unpacked = exports.unpack(cs); + return applyZip(astr, unpacked.ops, (op1, op2) => + slicerZipperFunc(op1, op2, pool), + ); }; /** @@ -1288,107 +1368,117 @@ exports.applyToAttribution = (cs, astr, pool) => { * @param {AttributePool} pool - Attribute pool. */ exports.mutateAttributionLines = (cs, lines, pool) => { - const unpacked = exports.unpack(cs); - const csOps = exports.deserializeOps(unpacked.ops); - let csOpsNext = csOps.next(); - const csBank = unpacked.charBank; - let csBankIndex = 0; - // treat the attribution lines as text lines, mutating a line at a time - const mut = new TextLinesMutator(lines); + const unpacked = exports.unpack(cs); + const csOps = exports.deserializeOps(unpacked.ops); + let csOpsNext = csOps.next(); + const csBank = unpacked.charBank; + let csBankIndex = 0; + // treat the attribution lines as text lines, mutating a line at a time + const mut = new TextLinesMutator(lines); - /** - * The Ops in the current line from `lines`. - * - * @type {?Generator} - */ - let lineOps = null; - let lineOpsNext = null; + /** + * The Ops in the current line from `lines`. + * + * @type {?Generator} + */ + let lineOps = null; + let lineOpsNext = null; - const lineOpsHasNext = () => lineOpsNext && !lineOpsNext.done; - /** - * Returns false if we are on the last attribute line in `lines` and there is no additional op in - * that line. - * - * @returns {boolean} True if there are more ops to go through. - */ - const isNextMutOp = () => lineOpsHasNext() || mut.hasMore(); + const lineOpsHasNext = () => lineOpsNext && !lineOpsNext.done; + /** + * Returns false if we are on the last attribute line in `lines` and there is no additional op in + * that line. + * + * @returns {boolean} True if there are more ops to go through. + */ + const isNextMutOp = () => lineOpsHasNext() || mut.hasMore(); - /** - * @returns {Op} The next Op from `lineIter`. If there are no more Ops, `lineIter` is reset to - * iterate over the next line, which is consumed from `mut`. If there are no more lines, - * returns a null Op. - */ - const nextMutOp = () => { - if (!lineOpsHasNext() && mut.hasMore()) { - // There are more attribute lines in `lines` to do AND either we just started so `lineIter` is - // still null or there are no more ops in current `lineIter`. - const line = mut.removeLines(1); - lineOps = exports.deserializeOps(line); - lineOpsNext = lineOps.next(); - } - if (!lineOpsHasNext()) return new Op(); // No more ops and no more lines. - const op = lineOpsNext.value; - lineOpsNext = lineOps.next(); - return op; - }; - let lineAssem = null; + /** + * @returns {Op} The next Op from `lineIter`. If there are no more Ops, `lineIter` is reset to + * iterate over the next line, which is consumed from `mut`. If there are no more lines, + * returns a null Op. + */ + const nextMutOp = () => { + if (!lineOpsHasNext() && mut.hasMore()) { + // There are more attribute lines in `lines` to do AND either we just started so `lineIter` is + // still null or there are no more ops in current `lineIter`. + const line = mut.removeLines(1); + lineOps = exports.deserializeOps(line); + lineOpsNext = lineOps.next(); + } + if (!lineOpsHasNext()) return new Op(); // No more ops and no more lines. + const op = lineOpsNext.value; + lineOpsNext = lineOps.next(); + return op; + }; + let lineAssem = null; - /** - * Appends an op to `lineAssem`. In case `lineAssem` includes one single newline, adds it to the - * `lines` mutator. - */ - const outputMutOp = (op) => { - if (!lineAssem) { - lineAssem = exports.mergingOpAssembler(); - } - lineAssem.append(op); - if (op.lines <= 0) return; - assert(op.lines === 1, `Can't have op.lines of ${op.lines} in attribution lines`); - // ship it to the mut - mut.insert(lineAssem.toString(), 1); - lineAssem = null; - }; + /** + * Appends an op to `lineAssem`. In case `lineAssem` includes one single newline, adds it to the + * `lines` mutator. + */ + const outputMutOp = (op) => { + if (!lineAssem) { + lineAssem = exports.mergingOpAssembler(); + } + lineAssem.append(op); + if (op.lines <= 0) return; + assert( + op.lines === 1, + `Can't have op.lines of ${op.lines} in attribution lines`, + ); + // ship it to the mut + mut.insert(lineAssem.toString(), 1); + lineAssem = null; + }; - let csOp = new Op(); - let attOp = new Op(); - while (csOp.opcode || !csOpsNext.done || attOp.opcode || isNextMutOp()) { - if (!csOp.opcode && !csOpsNext.done) { - // coOp done, but more ops in cs. - csOp = csOpsNext.value; - csOpsNext = csOps.next(); - } - if (!csOp.opcode && !attOp.opcode && !lineAssem && !lineOpsHasNext()) { - break; // done - } else if (csOp.opcode === '=' && csOp.lines > 0 && !csOp.attribs && !attOp.opcode && - !lineAssem && !lineOpsHasNext()) { - // Skip multiple lines without attributes; this is what makes small changes not order of the - // document size. - mut.skipLines(csOp.lines); - csOp.opcode = ''; - } else if (csOp.opcode === '+') { - const opOut = copyOp(csOp); - if (csOp.lines > 1) { - // Copy the first line from `csOp` to `opOut`. - const firstLineLen = csBank.indexOf('\n', csBankIndex) + 1 - csBankIndex; - csOp.chars -= firstLineLen; - csOp.lines--; - opOut.lines = 1; - opOut.chars = firstLineLen; - } else { - // Either one or no newlines in '+' `csOp`, copy to `opOut` and reset `csOp`. - csOp.opcode = ''; - } - outputMutOp(opOut); - csBankIndex += opOut.chars; - } else { - if (!attOp.opcode && isNextMutOp()) attOp = nextMutOp(); - const opOut = slicerZipperFunc(attOp, csOp, pool); - if (opOut.opcode) outputMutOp(opOut); - } - } + let csOp = new Op(); + let attOp = new Op(); + while (csOp.opcode || !csOpsNext.done || attOp.opcode || isNextMutOp()) { + if (!csOp.opcode && !csOpsNext.done) { + // coOp done, but more ops in cs. + csOp = csOpsNext.value; + csOpsNext = csOps.next(); + } + if (!csOp.opcode && !attOp.opcode && !lineAssem && !lineOpsHasNext()) { + break; // done + } else if ( + csOp.opcode === "=" && + csOp.lines > 0 && + !csOp.attribs && + !attOp.opcode && + !lineAssem && + !lineOpsHasNext() + ) { + // Skip multiple lines without attributes; this is what makes small changes not order of the + // document size. + mut.skipLines(csOp.lines); + csOp.opcode = ""; + } else if (csOp.opcode === "+") { + const opOut = copyOp(csOp); + if (csOp.lines > 1) { + // Copy the first line from `csOp` to `opOut`. + const firstLineLen = + csBank.indexOf("\n", csBankIndex) + 1 - csBankIndex; + csOp.chars -= firstLineLen; + csOp.lines--; + opOut.lines = 1; + opOut.chars = firstLineLen; + } else { + // Either one or no newlines in '+' `csOp`, copy to `opOut` and reset `csOp`. + csOp.opcode = ""; + } + outputMutOp(opOut); + csBankIndex += opOut.chars; + } else { + if (!attOp.opcode && isNextMutOp()) attOp = nextMutOp(); + const opOut = slicerZipperFunc(attOp, csOp, pool); + if (opOut.opcode) outputMutOp(opOut); + } + } - assert(!lineAssem, `line assembler not finished:${cs}`); - mut.close(); + assert(!lineAssem, `line assembler not finished:${cs}`); + mut.close(); }; /** @@ -1398,47 +1488,47 @@ exports.mutateAttributionLines = (cs, lines, pool) => { * @returns {string} joined Attribution lines */ exports.joinAttributionLines = (theAlines) => { - const assem = exports.mergingOpAssembler(); - for (const aline of theAlines) { - for (const op of exports.deserializeOps(aline)) assem.append(op); - } - return assem.toString(); + const assem = exports.mergingOpAssembler(); + for (const aline of theAlines) { + for (const op of exports.deserializeOps(aline)) assem.append(op); + } + return assem.toString(); }; exports.splitAttributionLines = (attrOps, text) => { - const assem = exports.mergingOpAssembler(); - const lines = []; - let pos = 0; + const assem = exports.mergingOpAssembler(); + const lines = []; + let pos = 0; - const appendOp = (op) => { - assem.append(op); - if (op.lines > 0) { - lines.push(assem.toString()); - assem.clear(); - } - pos += op.chars; - }; + const appendOp = (op) => { + assem.append(op); + if (op.lines > 0) { + lines.push(assem.toString()); + assem.clear(); + } + pos += op.chars; + }; - for (const op of exports.deserializeOps(attrOps)) { - let numChars = op.chars; - let numLines = op.lines; - while (numLines > 1) { - const newlineEnd = text.indexOf('\n', pos) + 1; - assert(newlineEnd > 0, 'newlineEnd <= 0 in splitAttributionLines'); - op.chars = newlineEnd - pos; - op.lines = 1; - appendOp(op); - numChars -= op.chars; - numLines -= op.lines; - } - if (numLines === 1) { - op.chars = numChars; - op.lines = 1; - } - appendOp(op); - } + for (const op of exports.deserializeOps(attrOps)) { + let numChars = op.chars; + let numLines = op.lines; + while (numLines > 1) { + const newlineEnd = text.indexOf("\n", pos) + 1; + assert(newlineEnd > 0, "newlineEnd <= 0 in splitAttributionLines"); + op.chars = newlineEnd - pos; + op.lines = 1; + appendOp(op); + numChars -= op.chars; + numLines -= op.lines; + } + if (numLines === 1) { + op.chars = numChars; + op.lines = 1; + } + appendOp(op); + } - return lines; + return lines; }; /** @@ -1458,34 +1548,34 @@ exports.splitTextLines = (text) => text.match(/[^\n]*(?:\n|[^\n]$)/g); * @returns {string} */ exports.compose = (cs1, cs2, pool) => { - const unpacked1 = exports.unpack(cs1); - const unpacked2 = exports.unpack(cs2); - const len1 = unpacked1.oldLen; - const len2 = unpacked1.newLen; - assert(len2 === unpacked2.oldLen, 'mismatched composition of two changesets'); - const len3 = unpacked2.newLen; - const bankIter1 = exports.stringIterator(unpacked1.charBank); - const bankIter2 = exports.stringIterator(unpacked2.charBank); - const bankAssem = exports.stringAssembler(); + const unpacked1 = exports.unpack(cs1); + const unpacked2 = exports.unpack(cs2); + const len1 = unpacked1.oldLen; + const len2 = unpacked1.newLen; + assert(len2 === unpacked2.oldLen, "mismatched composition of two changesets"); + const len3 = unpacked2.newLen; + const bankIter1 = exports.stringIterator(unpacked1.charBank); + const bankIter2 = exports.stringIterator(unpacked2.charBank); + const bankAssem = exports.stringAssembler(); - const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => { - const op1code = op1.opcode; - const op2code = op2.opcode; - if (op1code === '+' && op2code === '-') { - bankIter1.skip(Math.min(op1.chars, op2.chars)); - } - const opOut = slicerZipperFunc(op1, op2, pool); - if (opOut.opcode === '+') { - if (op2code === '+') { - bankAssem.append(bankIter2.take(opOut.chars)); - } else { - bankAssem.append(bankIter1.take(opOut.chars)); - } - } - return opOut; - }); + const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => { + const op1code = op1.opcode; + const op2code = op2.opcode; + if (op1code === "+" && op2code === "-") { + bankIter1.skip(Math.min(op1.chars, op2.chars)); + } + const opOut = slicerZipperFunc(op1, op2, pool); + if (opOut.opcode === "+") { + if (op2code === "+") { + bankAssem.append(bankIter2.take(opOut.chars)); + } else { + bankAssem.append(bankIter1.take(opOut.chars)); + } + } + return opOut; + }); - return exports.pack(len1, len3, newOps, bankAssem.toString()); + return exports.pack(len1, len3, newOps, bankAssem.toString()); }; /** @@ -1497,12 +1587,12 @@ exports.compose = (cs1, cs2, pool) => { * @returns {Function} */ exports.attributeTester = (attribPair, pool) => { - const never = (attribs) => false; - if (!pool) return never; - const attribNum = pool.putAttrib(attribPair, true); - if (attribNum < 0) return never; - const re = new RegExp(`\\*${exports.numToString(attribNum)}(?!\\w)`); - return (attribs) => re.test(attribs); + const never = (attribs) => false; + if (!pool) return never; + const attribNum = pool.putAttrib(attribPair, true); + if (attribNum < 0) return never; + const re = new RegExp(`\\*${exports.numToString(attribNum)}(?!\\w)`); + return (attribs) => re.test(attribs); }; /** @@ -1511,7 +1601,7 @@ exports.attributeTester = (attribPair, pool) => { * @param {number} N - length of the identity changeset * @returns {string} */ -exports.identity = (N) => exports.pack(N, N, '', ''); +exports.identity = (N) => exports.pack(N, N, "", ""); /** * Creates a Changeset which works on oldFullText and removes text from spliceStart to @@ -1527,20 +1617,29 @@ exports.identity = (N) => exports.pack(N, N, '', ''); * @returns {string} */ exports.makeSplice = (orig, start, ndel, ins, attribs, pool) => { - if (start < 0) throw new RangeError(`start index must be non-negative (is ${start})`); - if (ndel < 0) throw new RangeError(`characters to delete must be non-negative (is ${ndel})`); - if (start > orig.length) start = orig.length; - if (ndel > orig.length - start) ndel = orig.length - start; - const deleted = orig.substring(start, start + ndel); - const assem = exports.smartOpAssembler(); - const ops = (function* () { - yield* opsFromText('=', orig.substring(0, start)); - yield* opsFromText('-', deleted); - yield* opsFromText('+', ins, attribs, pool); - })(); - for (const op of ops) assem.append(op); - assem.endDocument(); - return exports.pack(orig.length, orig.length + ins.length - ndel, assem.toString(), ins); + if (start < 0) + throw new RangeError(`start index must be non-negative (is ${start})`); + if (ndel < 0) + throw new RangeError( + `characters to delete must be non-negative (is ${ndel})`, + ); + if (start > orig.length) start = orig.length; + if (ndel > orig.length - start) ndel = orig.length - start; + const deleted = orig.substring(start, start + ndel); + const assem = exports.smartOpAssembler(); + const ops = (function* () { + yield* opsFromText("=", orig.substring(0, start)); + yield* opsFromText("-", deleted); + yield* opsFromText("+", ins, attribs, pool); + })(); + for (const op of ops) assem.append(op); + assem.endDocument(); + return exports.pack( + orig.length, + orig.length + ins.length - ndel, + assem.toString(), + ins, + ); }; /** @@ -1551,32 +1650,32 @@ exports.makeSplice = (orig, start, ndel, ins, attribs, pool) => { * @returns {[number, number, string][]} */ const toSplices = (cs) => { - const unpacked = exports.unpack(cs); - /** @type {[number, number, string][]} */ - const splices = []; + const unpacked = exports.unpack(cs); + /** @type {[number, number, string][]} */ + const splices = []; - let oldPos = 0; - const charIter = exports.stringIterator(unpacked.charBank); - let inSplice = false; - for (const op of exports.deserializeOps(unpacked.ops)) { - if (op.opcode === '=') { - oldPos += op.chars; - inSplice = false; - } else { - if (!inSplice) { - splices.push([oldPos, oldPos, '']); - inSplice = true; - } - if (op.opcode === '-') { - oldPos += op.chars; - splices[splices.length - 1][1] += op.chars; - } else if (op.opcode === '+') { - splices[splices.length - 1][2] += charIter.take(op.chars); - } - } - } + let oldPos = 0; + const charIter = exports.stringIterator(unpacked.charBank); + let inSplice = false; + for (const op of exports.deserializeOps(unpacked.ops)) { + if (op.opcode === "=") { + oldPos += op.chars; + inSplice = false; + } else { + if (!inSplice) { + splices.push([oldPos, oldPos, ""]); + inSplice = true; + } + if (op.opcode === "-") { + oldPos += op.chars; + splices[splices.length - 1][1] += op.chars; + } else if (op.opcode === "+") { + splices[splices.length - 1][2] += charIter.take(op.chars); + } + } + } - return splices; + return splices; }; /** @@ -1587,45 +1686,45 @@ const toSplices = (cs) => { * @returns {[number, number]} */ exports.characterRangeFollow = (cs, startChar, endChar, insertionsAfter) => { - let newStartChar = startChar; - let newEndChar = endChar; - let lengthChangeSoFar = 0; - for (const splice of toSplices(cs)) { - const spliceStart = splice[0] + lengthChangeSoFar; - const spliceEnd = splice[1] + lengthChangeSoFar; - const newTextLength = splice[2].length; - const thisLengthChange = newTextLength - (spliceEnd - spliceStart); + let newStartChar = startChar; + let newEndChar = endChar; + let lengthChangeSoFar = 0; + for (const splice of toSplices(cs)) { + const spliceStart = splice[0] + lengthChangeSoFar; + const spliceEnd = splice[1] + lengthChangeSoFar; + const newTextLength = splice[2].length; + const thisLengthChange = newTextLength - (spliceEnd - spliceStart); - if (spliceStart <= newStartChar && spliceEnd >= newEndChar) { - // splice fully replaces/deletes range - // (also case that handles insertion at a collapsed selection) - if (insertionsAfter) { - newStartChar = newEndChar = spliceStart; - } else { - newStartChar = newEndChar = spliceStart + newTextLength; - } - } else if (spliceEnd <= newStartChar) { - // splice is before range - newStartChar += thisLengthChange; - newEndChar += thisLengthChange; - } else if (spliceStart >= newEndChar) { - // splice is after range - } else if (spliceStart >= newStartChar && spliceEnd <= newEndChar) { - // splice is inside range - newEndChar += thisLengthChange; - } else if (spliceEnd < newEndChar) { - // splice overlaps beginning of range - newStartChar = spliceStart + newTextLength; - newEndChar += thisLengthChange; - } else { - // splice overlaps end of range - newEndChar = spliceStart; - } + if (spliceStart <= newStartChar && spliceEnd >= newEndChar) { + // splice fully replaces/deletes range + // (also case that handles insertion at a collapsed selection) + if (insertionsAfter) { + newStartChar = newEndChar = spliceStart; + } else { + newStartChar = newEndChar = spliceStart + newTextLength; + } + } else if (spliceEnd <= newStartChar) { + // splice is before range + newStartChar += thisLengthChange; + newEndChar += thisLengthChange; + } else if (spliceStart >= newEndChar) { + // splice is after range + } else if (spliceStart >= newStartChar && spliceEnd <= newEndChar) { + // splice is inside range + newEndChar += thisLengthChange; + } else if (spliceEnd < newEndChar) { + // splice overlaps beginning of range + newStartChar = spliceStart + newTextLength; + newEndChar += thisLengthChange; + } else { + // splice overlaps end of range + newEndChar = spliceStart; + } - lengthChangeSoFar += thisLengthChange; - } + lengthChangeSoFar += thisLengthChange; + } - return [newStartChar, newEndChar]; + return [newStartChar, newEndChar]; }; /** @@ -1637,23 +1736,25 @@ exports.characterRangeFollow = (cs, startChar, endChar, insertionsAfter) => { * @returns {string} the new Changeset */ exports.moveOpsToNewPool = (cs, oldPool, newPool) => { - // works on exports or attribution string - let dollarPos = cs.indexOf('$'); - if (dollarPos < 0) { - dollarPos = cs.length; - } - const upToDollar = cs.substring(0, dollarPos); - const fromDollar = cs.substring(dollarPos); - // order of attribs stays the same - return upToDollar.replace(/\*([0-9a-z]+)/g, (_, a) => { - const oldNum = exports.parseNum(a); - const pair = oldPool.getAttrib(oldNum); - // The attribute might not be in the old pool if the user is viewing the current revision in the - // timeslider and text is deleted. See: https://github.com/ether/etherpad-lite/issues/3932 - if (!pair) return ''; - const newNum = newPool.putAttrib(pair); - return `*${exports.numToString(newNum)}`; - }) + fromDollar; + // works on exports or attribution string + let dollarPos = cs.indexOf("$"); + if (dollarPos < 0) { + dollarPos = cs.length; + } + const upToDollar = cs.substring(0, dollarPos); + const fromDollar = cs.substring(dollarPos); + // order of attribs stays the same + return ( + upToDollar.replace(/\*([0-9a-z]+)/g, (_, a) => { + const oldNum = exports.parseNum(a); + const pair = oldPool.getAttrib(oldNum); + // The attribute might not be in the old pool if the user is viewing the current revision in the + // timeslider and text is deleted. See: https://github.com/ether/etherpad-lite/issues/3932 + if (!pair) return ""; + const newNum = newPool.putAttrib(pair); + return `*${exports.numToString(newNum)}`; + }) + fromDollar + ); }; /** @@ -1663,9 +1764,9 @@ exports.moveOpsToNewPool = (cs, oldPool, newPool) => { * @returns {string} */ exports.makeAttribution = (text) => { - const assem = exports.smartOpAssembler(); - for (const op of opsFromText('+', text)) assem.append(op); - return assem.toString(); + const assem = exports.smartOpAssembler(); + for (const op of opsFromText("+", text)) assem.append(op); + return assem.toString(); }; /** @@ -1677,20 +1778,21 @@ exports.makeAttribution = (text) => { * @param {Function} func - function to call */ exports.eachAttribNumber = (cs, func) => { - padutils.warnDeprecated( - 'Changeset.eachAttribNumber() is deprecated; use attributes.decodeAttribString() instead'); - let dollarPos = cs.indexOf('$'); - if (dollarPos < 0) { - dollarPos = cs.length; - } - const upToDollar = cs.substring(0, dollarPos); + padutils.warnDeprecated( + "Changeset.eachAttribNumber() is deprecated; use attributes.decodeAttribString() instead", + ); + let dollarPos = cs.indexOf("$"); + if (dollarPos < 0) { + dollarPos = cs.length; + } + const upToDollar = cs.substring(0, dollarPos); - // WARNING: The following cannot be replaced with a call to `attributes.decodeAttribString()` - // because that function only works on attribute strings, not serialized operations or changesets. - upToDollar.replace(/\*([0-9a-z]+)/g, (_, a) => { - func(exports.parseNum(a)); - return ''; - }); + // WARNING: The following cannot be replaced with a call to `attributes.decodeAttribString()` + // because that function only works on attribute strings, not serialized operations or changesets. + upToDollar.replace(/\*([0-9a-z]+)/g, (_, a) => { + func(exports.parseNum(a)); + return ""; + }); }; /** @@ -1702,7 +1804,8 @@ exports.eachAttribNumber = (cs, func) => { * Changeset * @returns {string} */ -exports.filterAttribNumbers = (cs, filter) => exports.mapAttribNumbers(cs, filter); +exports.filterAttribNumbers = (cs, filter) => + exports.mapAttribNumbers(cs, filter); /** * Does exactly the same as exports.filterAttribNumbers. @@ -1712,24 +1815,24 @@ exports.filterAttribNumbers = (cs, filter) => exports.mapAttribNumbers(cs, filte * @returns {string} */ exports.mapAttribNumbers = (cs, func) => { - let dollarPos = cs.indexOf('$'); - if (dollarPos < 0) { - dollarPos = cs.length; - } - const upToDollar = cs.substring(0, dollarPos); + let dollarPos = cs.indexOf("$"); + if (dollarPos < 0) { + dollarPos = cs.length; + } + const upToDollar = cs.substring(0, dollarPos); - const newUpToDollar = upToDollar.replace(/\*([0-9a-z]+)/g, (s, a) => { - const n = func(exports.parseNum(a)); - if (n === true) { - return s; - } else if ((typeof n) === 'number') { - return `*${exports.numToString(n)}`; - } else { - return ''; - } - }); + const newUpToDollar = upToDollar.replace(/\*([0-9a-z]+)/g, (s, a) => { + const n = func(exports.parseNum(a)); + if (n === true) { + return s; + } else if (typeof n === "number") { + return `*${exports.numToString(n)}`; + } else { + return ""; + } + }); - return newUpToDollar + cs.substring(dollarPos); + return newUpToDollar + cs.substring(dollarPos); }; /** @@ -1750,8 +1853,8 @@ exports.mapAttribNumbers = (cs, func) => { * @returns {AText} */ exports.makeAText = (text, attribs) => ({ - text, - attribs: (attribs || exports.makeAttribution(text)), + text, + attribs: attribs || exports.makeAttribution(text), }); /** @@ -1763,8 +1866,8 @@ exports.makeAText = (text, attribs) => ({ * @returns {AText} */ exports.applyToAText = (cs, atext, pool) => ({ - text: exports.applyToText(cs, atext.text), - attribs: exports.applyToAttribution(cs, atext.attribs, pool), + text: exports.applyToText(cs, atext.text), + attribs: exports.applyToAttribution(cs, atext.attribs, pool), }); /** @@ -1774,11 +1877,11 @@ exports.applyToAText = (cs, atext, pool) => ({ * @returns {AText} */ exports.cloneAText = (atext) => { - if (!atext) error('atext is null'); - return { - text: atext.text, - attribs: atext.attribs, - }; + if (!atext) error("atext is null"); + return { + text: atext.text, + attribs: atext.attribs, + }; }; /** @@ -1788,8 +1891,8 @@ exports.cloneAText = (atext) => { * @param {AText} atext2 - */ exports.copyAText = (atext1, atext2) => { - atext2.text = atext1.text; - atext2.attribs = atext1.attribs; + atext2.text = atext1.text; + atext2.attribs = atext1.attribs; }; /** @@ -1800,27 +1903,28 @@ exports.copyAText = (atext1, atext2) => { * @returns {Generator} */ exports.opsFromAText = function* (atext) { - // intentionally skips last newline char of atext - let lastOp = null; - for (const op of exports.deserializeOps(atext.attribs)) { - if (lastOp != null) yield lastOp; - lastOp = op; - } - if (lastOp == null) return; - // exclude final newline - if (lastOp.lines <= 1) { - lastOp.lines = 0; - lastOp.chars--; - } else { - const nextToLastNewlineEnd = atext.text.lastIndexOf('\n', atext.text.length - 2) + 1; - const lastLineLength = atext.text.length - nextToLastNewlineEnd - 1; - lastOp.lines--; - lastOp.chars -= (lastLineLength + 1); - yield copyOp(lastOp); - lastOp.lines = 0; - lastOp.chars = lastLineLength; - } - if (lastOp.chars) yield lastOp; + // intentionally skips last newline char of atext + let lastOp = null; + for (const op of exports.deserializeOps(atext.attribs)) { + if (lastOp != null) yield lastOp; + lastOp = op; + } + if (lastOp == null) return; + // exclude final newline + if (lastOp.lines <= 1) { + lastOp.lines = 0; + lastOp.chars--; + } else { + const nextToLastNewlineEnd = + atext.text.lastIndexOf("\n", atext.text.length - 2) + 1; + const lastLineLength = atext.text.length - nextToLastNewlineEnd - 1; + lastOp.lines--; + lastOp.chars -= lastLineLength + 1; + yield copyOp(lastOp); + lastOp.lines = 0; + lastOp.chars = lastLineLength; + } + if (lastOp.chars) yield lastOp; }; /** @@ -1831,9 +1935,10 @@ exports.opsFromAText = function* (atext) { * @param assem - Assembler like SmartOpAssembler TODO add desc */ exports.appendATextToAssembler = (atext, assem) => { - padutils.warnDeprecated( - 'Changeset.appendATextToAssembler() is deprecated; use Changeset.opsFromAText() instead'); - for (const op of exports.opsFromAText(atext)) assem.append(op); + padutils.warnDeprecated( + "Changeset.appendATextToAssembler() is deprecated; use Changeset.opsFromAText() instead", + ); + for (const op of exports.opsFromAText(atext)) assem.append(op); }; /** @@ -1844,12 +1949,12 @@ exports.appendATextToAssembler = (atext, assem) => { * @returns {{translated: string, pool: AttributePool}} */ exports.prepareForWire = (cs, pool) => { - const newPool = new AttributePool(); - const newCs = exports.moveOpsToNewPool(cs, pool, newPool); - return { - translated: newCs, - pool: newPool, - }; + const newPool = new AttributePool(); + const newCs = exports.moveOpsToNewPool(cs, pool, newPool); + return { + translated: newCs, + pool: newPool, + }; }; /** @@ -1859,19 +1964,19 @@ exports.prepareForWire = (cs, pool) => { * @returns {boolean} */ exports.isIdentity = (cs) => { - const unpacked = exports.unpack(cs); - return unpacked.ops === '' && unpacked.oldLen === unpacked.newLen; + const unpacked = exports.unpack(cs); + return unpacked.ops === "" && unpacked.oldLen === unpacked.newLen; }; /** * @deprecated Use an AttributeMap instead. */ const attribsAttributeValue = (attribs, key, pool) => { - if (!attribs) return ''; - for (const [k, v] of attributes.attribsFromString(attribs, pool)) { - if (k === key) return v; - } - return ''; + if (!attribs) return ""; + for (const [k, v] of attributes.attribsFromString(attribs, pool)) { + if (k === key) return v; + } + return ""; }; /** @@ -1884,9 +1989,10 @@ const attribsAttributeValue = (attribs, key, pool) => { * @returns {string} */ exports.opAttributeValue = (op, key, pool) => { - padutils.warnDeprecated( - 'Changeset.opAttributeValue() is deprecated; use an AttributeMap instead'); - return attribsAttributeValue(op.attribs, key, pool); + padutils.warnDeprecated( + "Changeset.opAttributeValue() is deprecated; use an AttributeMap instead", + ); + return attribsAttributeValue(op.attribs, key, pool); }; /** @@ -1899,9 +2005,10 @@ exports.opAttributeValue = (op, key, pool) => { * @returns {string} */ exports.attribsAttributeValue = (attribs, key, pool) => { - padutils.warnDeprecated( - 'Changeset.attribsAttributeValue() is deprecated; use an AttributeMap instead'); - return attribsAttributeValue(attribs, key, pool); + padutils.warnDeprecated( + "Changeset.attribsAttributeValue() is deprecated; use an AttributeMap instead", + ); + return attribsAttributeValue(attribs, key, pool); }; /** @@ -1920,81 +2027,88 @@ exports.attribsAttributeValue = (attribs, key, pool) => { * @returns {Builder} */ exports.builder = (oldLen) => { - const assem = exports.smartOpAssembler(); - const o = new Op(); - const charBank = exports.stringAssembler(); + const assem = exports.smartOpAssembler(); + const o = new Op(); + const charBank = exports.stringAssembler(); - const self = { - /** - * @param {number} N - Number of characters to keep. - * @param {number} L - Number of newlines among the `N` characters. If positive, the last - * character must be a newline. - * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' - * (no pool needed in latter case). - * @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of - * attribute key, value pairs. - * @returns {Builder} this - */ - keep: (N, L, attribs, pool) => { - o.opcode = '='; - o.attribs = typeof attribs === 'string' - ? attribs : new AttributeMap(pool).update(attribs || []).toString(); - o.chars = N; - o.lines = (L || 0); - assem.append(o); - return self; - }, + const self = { + /** + * @param {number} N - Number of characters to keep. + * @param {number} L - Number of newlines among the `N` characters. If positive, the last + * character must be a newline. + * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' + * (no pool needed in latter case). + * @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of + * attribute key, value pairs. + * @returns {Builder} this + */ + keep: (N, L, attribs, pool) => { + o.opcode = "="; + o.attribs = + typeof attribs === "string" + ? attribs + : new AttributeMap(pool).update(attribs || []).toString(); + o.chars = N; + o.lines = L || 0; + assem.append(o); + return self; + }, - /** - * @param {string} text - Text to keep. - * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' - * (no pool needed in latter case). - * @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of - * attribute key, value pairs. - * @returns {Builder} this - */ - keepText: (text, attribs, pool) => { - for (const op of opsFromText('=', text, attribs, pool)) assem.append(op); - return self; - }, + /** + * @param {string} text - Text to keep. + * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' + * (no pool needed in latter case). + * @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of + * attribute key, value pairs. + * @returns {Builder} this + */ + keepText: (text, attribs, pool) => { + for (const op of opsFromText("=", text, attribs, pool)) assem.append(op); + return self; + }, - /** - * @param {string} text - Text to insert. - * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' - * (no pool needed in latter case). - * @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of - * attribute key, value pairs. - * @returns {Builder} this - */ - insert: (text, attribs, pool) => { - for (const op of opsFromText('+', text, attribs, pool)) assem.append(op); - charBank.append(text); - return self; - }, + /** + * @param {string} text - Text to insert. + * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' + * (no pool needed in latter case). + * @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of + * attribute key, value pairs. + * @returns {Builder} this + */ + insert: (text, attribs, pool) => { + for (const op of opsFromText("+", text, attribs, pool)) assem.append(op); + charBank.append(text); + return self; + }, - /** - * @param {number} N - Number of characters to remove. - * @param {number} L - Number of newlines among the `N` characters. If positive, the last - * character must be a newline. - * @returns {Builder} this - */ - remove: (N, L) => { - o.opcode = '-'; - o.attribs = ''; - o.chars = N; - o.lines = (L || 0); - assem.append(o); - return self; - }, + /** + * @param {number} N - Number of characters to remove. + * @param {number} L - Number of newlines among the `N` characters. If positive, the last + * character must be a newline. + * @returns {Builder} this + */ + remove: (N, L) => { + o.opcode = "-"; + o.attribs = ""; + o.chars = N; + o.lines = L || 0; + assem.append(o); + return self; + }, - toString: () => { - assem.endDocument(); - const newLen = oldLen + assem.getLengthChange(); - return exports.pack(oldLen, newLen, assem.toString(), charBank.toString()); - }, - }; + toString: () => { + assem.endDocument(); + const newLen = oldLen + assem.getLengthChange(); + return exports.pack( + oldLen, + newLen, + assem.toString(), + charBank.toString(), + ); + }, + }; - return self; + return self; }; /** @@ -2011,394 +2125,409 @@ exports.builder = (oldLen) => { * @returns {AttributeString} */ exports.makeAttribsString = (opcode, attribs, pool) => { - padutils.warnDeprecated( - 'Changeset.makeAttribsString() is deprecated; ' + - 'use AttributeMap.prototype.toString() or attributes.attribsToString() instead'); - if (!attribs || !['=', '+'].includes(opcode)) return ''; - if (typeof attribs === 'string') return attribs; - return new AttributeMap(pool).update(attribs, opcode === '+').toString(); + padutils.warnDeprecated( + "Changeset.makeAttribsString() is deprecated; " + + "use AttributeMap.prototype.toString() or attributes.attribsToString() instead", + ); + if (!attribs || !["=", "+"].includes(opcode)) return ""; + if (typeof attribs === "string") return attribs; + return new AttributeMap(pool).update(attribs, opcode === "+").toString(); }; /** * Like "substring" but on a single-line attribution string. */ exports.subattribution = (astr, start, optEnd) => { - const attOps = exports.deserializeOps(astr); - let attOpsNext = attOps.next(); - const assem = exports.smartOpAssembler(); - let attOp = new Op(); - const csOp = new Op(); + const attOps = exports.deserializeOps(astr); + let attOpsNext = attOps.next(); + const assem = exports.smartOpAssembler(); + let attOp = new Op(); + const csOp = new Op(); - const doCsOp = () => { - if (!csOp.chars) return; - while (csOp.opcode && (attOp.opcode || !attOpsNext.done)) { - if (!attOp.opcode) { - attOp = attOpsNext.value; - attOpsNext = attOps.next(); - } - if (csOp.opcode && attOp.opcode && csOp.chars >= attOp.chars && - attOp.lines > 0 && csOp.lines <= 0) { - csOp.lines++; - } - const opOut = slicerZipperFunc(attOp, csOp, null); - if (opOut.opcode) assem.append(opOut); - } - }; + const doCsOp = () => { + if (!csOp.chars) return; + while (csOp.opcode && (attOp.opcode || !attOpsNext.done)) { + if (!attOp.opcode) { + attOp = attOpsNext.value; + attOpsNext = attOps.next(); + } + if ( + csOp.opcode && + attOp.opcode && + csOp.chars >= attOp.chars && + attOp.lines > 0 && + csOp.lines <= 0 + ) { + csOp.lines++; + } + const opOut = slicerZipperFunc(attOp, csOp, null); + if (opOut.opcode) assem.append(opOut); + } + }; - csOp.opcode = '-'; - csOp.chars = start; + csOp.opcode = "-"; + csOp.chars = start; - doCsOp(); + doCsOp(); - if (optEnd === undefined) { - if (attOp.opcode) { - assem.append(attOp); - } - while (!attOpsNext.done) { - assem.append(attOpsNext.value); - attOpsNext = attOps.next(); - } - } else { - csOp.opcode = '='; - csOp.chars = optEnd - start; - doCsOp(); - } + if (optEnd === undefined) { + if (attOp.opcode) { + assem.append(attOp); + } + while (!attOpsNext.done) { + assem.append(attOpsNext.value); + attOpsNext = attOps.next(); + } + } else { + csOp.opcode = "="; + csOp.chars = optEnd - start; + doCsOp(); + } - return assem.toString(); + return assem.toString(); }; exports.inverse = (cs, lines, alines, pool) => { - // lines and alines are what the exports is meant to apply to. - // They may be arrays or objects with .get(i) and .length methods. - // They include final newlines on lines. + // lines and alines are what the exports is meant to apply to. + // They may be arrays or objects with .get(i) and .length methods. + // They include final newlines on lines. - const linesGet = (idx) => { - if (lines.get) { - return lines.get(idx); - } else { - return lines[idx]; - } - }; + const linesGet = (idx) => { + if (lines.get) { + return lines.get(idx); + } else { + return lines[idx]; + } + }; - /** - * @param {number} idx - - * @returns {string} - */ - const alinesGet = (idx) => { - if (alines.get) { - return alines.get(idx); - } else { - return alines[idx]; - } - }; + /** + * @param {number} idx - + * @returns {string} + */ + const alinesGet = (idx) => { + if (alines.get) { + return alines.get(idx); + } else { + return alines[idx]; + } + }; - let curLine = 0; - let curChar = 0; - let curLineOps = null; - let curLineOpsNext = null; - let curLineOpsLine; - let curLineNextOp = new Op('+'); + let curLine = 0; + let curChar = 0; + let curLineOps = null; + let curLineOpsNext = null; + let curLineOpsLine; + let curLineNextOp = new Op("+"); - const unpacked = exports.unpack(cs); - const builder = exports.builder(unpacked.newLen); + const unpacked = exports.unpack(cs); + const builder = exports.builder(unpacked.newLen); - const consumeAttribRuns = (numChars, func /* (len, attribs, endsLine)*/) => { - if (!curLineOps || curLineOpsLine !== curLine) { - curLineOps = exports.deserializeOps(alinesGet(curLine)); - curLineOpsNext = curLineOps.next(); - curLineOpsLine = curLine; - let indexIntoLine = 0; - while (!curLineOpsNext.done) { - curLineNextOp = curLineOpsNext.value; - curLineOpsNext = curLineOps.next(); - if (indexIntoLine + curLineNextOp.chars >= curChar) { - curLineNextOp.chars -= (curChar - indexIntoLine); - break; - } - indexIntoLine += curLineNextOp.chars; - } - } + const consumeAttribRuns = (numChars, func /* (len, attribs, endsLine)*/) => { + if (!curLineOps || curLineOpsLine !== curLine) { + curLineOps = exports.deserializeOps(alinesGet(curLine)); + curLineOpsNext = curLineOps.next(); + curLineOpsLine = curLine; + let indexIntoLine = 0; + while (!curLineOpsNext.done) { + curLineNextOp = curLineOpsNext.value; + curLineOpsNext = curLineOps.next(); + if (indexIntoLine + curLineNextOp.chars >= curChar) { + curLineNextOp.chars -= curChar - indexIntoLine; + break; + } + indexIntoLine += curLineNextOp.chars; + } + } - while (numChars > 0) { - if (!curLineNextOp.chars && curLineOpsNext.done) { - curLine++; - curChar = 0; - curLineOpsLine = curLine; - curLineNextOp.chars = 0; - curLineOps = exports.deserializeOps(alinesGet(curLine)); - curLineOpsNext = curLineOps.next(); - } - if (!curLineNextOp.chars) { - if (curLineOpsNext.done) { - curLineNextOp = new Op(); - } else { - curLineNextOp = curLineOpsNext.value; - curLineOpsNext = curLineOps.next(); - } - } - const charsToUse = Math.min(numChars, curLineNextOp.chars); - func(charsToUse, curLineNextOp.attribs, charsToUse === curLineNextOp.chars && - curLineNextOp.lines > 0); - numChars -= charsToUse; - curLineNextOp.chars -= charsToUse; - curChar += charsToUse; - } + while (numChars > 0) { + if (!curLineNextOp.chars && curLineOpsNext.done) { + curLine++; + curChar = 0; + curLineOpsLine = curLine; + curLineNextOp.chars = 0; + curLineOps = exports.deserializeOps(alinesGet(curLine)); + curLineOpsNext = curLineOps.next(); + } + if (!curLineNextOp.chars) { + if (curLineOpsNext.done) { + curLineNextOp = new Op(); + } else { + curLineNextOp = curLineOpsNext.value; + curLineOpsNext = curLineOps.next(); + } + } + const charsToUse = Math.min(numChars, curLineNextOp.chars); + func( + charsToUse, + curLineNextOp.attribs, + charsToUse === curLineNextOp.chars && curLineNextOp.lines > 0, + ); + numChars -= charsToUse; + curLineNextOp.chars -= charsToUse; + curChar += charsToUse; + } - if (!curLineNextOp.chars && curLineOpsNext.done) { - curLine++; - curChar = 0; - } - }; + if (!curLineNextOp.chars && curLineOpsNext.done) { + curLine++; + curChar = 0; + } + }; - const skip = (N, L) => { - if (L) { - curLine += L; - curChar = 0; - } else if (curLineOps && curLineOpsLine === curLine) { - consumeAttribRuns(N, () => {}); - } else { - curChar += N; - } - }; + const skip = (N, L) => { + if (L) { + curLine += L; + curChar = 0; + } else if (curLineOps && curLineOpsLine === curLine) { + consumeAttribRuns(N, () => {}); + } else { + curChar += N; + } + }; - const nextText = (numChars) => { - let len = 0; - const assem = exports.stringAssembler(); - const firstString = linesGet(curLine).substring(curChar); - len += firstString.length; - assem.append(firstString); + const nextText = (numChars) => { + let len = 0; + const assem = exports.stringAssembler(); + const firstString = linesGet(curLine).substring(curChar); + len += firstString.length; + assem.append(firstString); - let lineNum = curLine + 1; - while (len < numChars) { - const nextString = linesGet(lineNum); - len += nextString.length; - assem.append(nextString); - lineNum++; - } + let lineNum = curLine + 1; + while (len < numChars) { + const nextString = linesGet(lineNum); + len += nextString.length; + assem.append(nextString); + lineNum++; + } - return assem.toString().substring(0, numChars); - }; + return assem.toString().substring(0, numChars); + }; - const cachedStrFunc = (func) => { - const cache = {}; - return (s) => { - if (!cache[s]) { - cache[s] = func(s); - } - return cache[s]; - }; - }; + const cachedStrFunc = (func) => { + const cache = {}; + return (s) => { + if (!cache[s]) { + cache[s] = func(s); + } + return cache[s]; + }; + }; - for (const csOp of exports.deserializeOps(unpacked.ops)) { - if (csOp.opcode === '=') { - if (csOp.attribs) { - const attribs = AttributeMap.fromString(csOp.attribs, pool); - const undoBackToAttribs = cachedStrFunc((oldAttribsStr) => { - const oldAttribs = AttributeMap.fromString(oldAttribsStr, pool); - const backAttribs = new AttributeMap(pool); - for (const [key, value] of attribs) { - const oldValue = oldAttribs.get(key) || ''; - if (oldValue !== value) backAttribs.set(key, oldValue); - } - // TODO: backAttribs does not restore removed attributes (it is missing attributes that - // are in oldAttribs but not in attribs). I don't know if that is intentional. - return backAttribs.toString(); - }); - consumeAttribRuns(csOp.chars, (len, attribs, endsLine) => { - builder.keep(len, endsLine ? 1 : 0, undoBackToAttribs(attribs)); - }); - } else { - skip(csOp.chars, csOp.lines); - builder.keep(csOp.chars, csOp.lines); - } - } else if (csOp.opcode === '+') { - builder.remove(csOp.chars, csOp.lines); - } else if (csOp.opcode === '-') { - const textBank = nextText(csOp.chars); - let textBankIndex = 0; - consumeAttribRuns(csOp.chars, (len, attribs, endsLine) => { - builder.insert(textBank.substr(textBankIndex, len), attribs); - textBankIndex += len; - }); - } - } + for (const csOp of exports.deserializeOps(unpacked.ops)) { + if (csOp.opcode === "=") { + if (csOp.attribs) { + const attribs = AttributeMap.fromString(csOp.attribs, pool); + const undoBackToAttribs = cachedStrFunc((oldAttribsStr) => { + const oldAttribs = AttributeMap.fromString(oldAttribsStr, pool); + const backAttribs = new AttributeMap(pool); + for (const [key, value] of attribs) { + const oldValue = oldAttribs.get(key) || ""; + if (oldValue !== value) backAttribs.set(key, oldValue); + } + // TODO: backAttribs does not restore removed attributes (it is missing attributes that + // are in oldAttribs but not in attribs). I don't know if that is intentional. + return backAttribs.toString(); + }); + consumeAttribRuns(csOp.chars, (len, attribs, endsLine) => { + builder.keep(len, endsLine ? 1 : 0, undoBackToAttribs(attribs)); + }); + } else { + skip(csOp.chars, csOp.lines); + builder.keep(csOp.chars, csOp.lines); + } + } else if (csOp.opcode === "+") { + builder.remove(csOp.chars, csOp.lines); + } else if (csOp.opcode === "-") { + const textBank = nextText(csOp.chars); + let textBankIndex = 0; + consumeAttribRuns(csOp.chars, (len, attribs, endsLine) => { + builder.insert(textBank.substr(textBankIndex, len), attribs); + textBankIndex += len; + }); + } + } - return exports.checkRep(builder.toString()); + return exports.checkRep(builder.toString()); }; // %CLIENT FILE ENDS HERE% exports.follow = (cs1, cs2, reverseInsertOrder, pool) => { - const unpacked1 = exports.unpack(cs1); - const unpacked2 = exports.unpack(cs2); - const len1 = unpacked1.oldLen; - const len2 = unpacked2.oldLen; - assert(len1 === len2, 'mismatched follow - cannot transform cs1 on top of cs2'); - const chars1 = exports.stringIterator(unpacked1.charBank); - const chars2 = exports.stringIterator(unpacked2.charBank); + const unpacked1 = exports.unpack(cs1); + const unpacked2 = exports.unpack(cs2); + const len1 = unpacked1.oldLen; + const len2 = unpacked2.oldLen; + assert( + len1 === len2, + "mismatched follow - cannot transform cs1 on top of cs2", + ); + const chars1 = exports.stringIterator(unpacked1.charBank); + const chars2 = exports.stringIterator(unpacked2.charBank); - const oldLen = unpacked1.newLen; - let oldPos = 0; - let newLen = 0; + const oldLen = unpacked1.newLen; + let oldPos = 0; + let newLen = 0; - const hasInsertFirst = exports.attributeTester(['insertorder', 'first'], pool); + const hasInsertFirst = exports.attributeTester( + ["insertorder", "first"], + pool, + ); - const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => { - const opOut = new Op(); - if (op1.opcode === '+' || op2.opcode === '+') { - let whichToDo; - if (op2.opcode !== '+') { - whichToDo = 1; - } else if (op1.opcode !== '+') { - whichToDo = 2; - } else { - // both + - const firstChar1 = chars1.peek(1); - const firstChar2 = chars2.peek(1); - const insertFirst1 = hasInsertFirst(op1.attribs); - const insertFirst2 = hasInsertFirst(op2.attribs); - if (insertFirst1 && !insertFirst2) { - whichToDo = 1; - } else if (insertFirst2 && !insertFirst1) { - whichToDo = 2; - } else if (firstChar1 === '\n' && firstChar2 !== '\n') { - // insert string that doesn't start with a newline first so as not to break up lines - whichToDo = 2; - } else if (firstChar1 !== '\n' && firstChar2 === '\n') { - whichToDo = 1; - } else if (reverseInsertOrder) { - // break symmetry: - whichToDo = 2; - } else { - whichToDo = 1; - } - } - if (whichToDo === 1) { - chars1.skip(op1.chars); - opOut.opcode = '='; - opOut.lines = op1.lines; - opOut.chars = op1.chars; - opOut.attribs = ''; - op1.opcode = ''; - } else { - // whichToDo == 2 - chars2.skip(op2.chars); - copyOp(op2, opOut); - op2.opcode = ''; - } - } else if (op1.opcode === '-') { - if (!op2.opcode) { - op1.opcode = ''; - } else if (op1.chars <= op2.chars) { - op2.chars -= op1.chars; - op2.lines -= op1.lines; - op1.opcode = ''; - if (!op2.chars) { - op2.opcode = ''; - } - } else { - op1.chars -= op2.chars; - op1.lines -= op2.lines; - op2.opcode = ''; - } - } else if (op2.opcode === '-') { - copyOp(op2, opOut); - if (!op1.opcode) { - op2.opcode = ''; - } else if (op2.chars <= op1.chars) { - // delete part or all of a keep - op1.chars -= op2.chars; - op1.lines -= op2.lines; - op2.opcode = ''; - if (!op1.chars) { - op1.opcode = ''; - } - } else { - // delete all of a keep, and keep going - opOut.lines = op1.lines; - opOut.chars = op1.chars; - op2.lines -= op1.lines; - op2.chars -= op1.chars; - op1.opcode = ''; - } - } else if (!op1.opcode) { - copyOp(op2, opOut); - op2.opcode = ''; - } else if (!op2.opcode) { - // @NOTE: Critical bugfix for EPL issue #1625. We do not copy op1 here - // in order to prevent attributes from leaking into result changesets. - // copyOp(op1, opOut); - op1.opcode = ''; - } else { - // both keeps - opOut.opcode = '='; - opOut.attribs = followAttributes(op1.attribs, op2.attribs, pool); - if (op1.chars <= op2.chars) { - opOut.chars = op1.chars; - opOut.lines = op1.lines; - op2.chars -= op1.chars; - op2.lines -= op1.lines; - op1.opcode = ''; - if (!op2.chars) { - op2.opcode = ''; - } - } else { - opOut.chars = op2.chars; - opOut.lines = op2.lines; - op1.chars -= op2.chars; - op1.lines -= op2.lines; - op2.opcode = ''; - } - } - switch (opOut.opcode) { - case '=': - oldPos += opOut.chars; - newLen += opOut.chars; - break; - case '-': - oldPos += opOut.chars; - break; - case '+': - newLen += opOut.chars; - break; - } - return opOut; - }); - newLen += oldLen - oldPos; + const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => { + const opOut = new Op(); + if (op1.opcode === "+" || op2.opcode === "+") { + let whichToDo; + if (op2.opcode !== "+") { + whichToDo = 1; + } else if (op1.opcode !== "+") { + whichToDo = 2; + } else { + // both + + const firstChar1 = chars1.peek(1); + const firstChar2 = chars2.peek(1); + const insertFirst1 = hasInsertFirst(op1.attribs); + const insertFirst2 = hasInsertFirst(op2.attribs); + if (insertFirst1 && !insertFirst2) { + whichToDo = 1; + } else if (insertFirst2 && !insertFirst1) { + whichToDo = 2; + } else if (firstChar1 === "\n" && firstChar2 !== "\n") { + // insert string that doesn't start with a newline first so as not to break up lines + whichToDo = 2; + } else if (firstChar1 !== "\n" && firstChar2 === "\n") { + whichToDo = 1; + } else if (reverseInsertOrder) { + // break symmetry: + whichToDo = 2; + } else { + whichToDo = 1; + } + } + if (whichToDo === 1) { + chars1.skip(op1.chars); + opOut.opcode = "="; + opOut.lines = op1.lines; + opOut.chars = op1.chars; + opOut.attribs = ""; + op1.opcode = ""; + } else { + // whichToDo == 2 + chars2.skip(op2.chars); + copyOp(op2, opOut); + op2.opcode = ""; + } + } else if (op1.opcode === "-") { + if (!op2.opcode) { + op1.opcode = ""; + } else if (op1.chars <= op2.chars) { + op2.chars -= op1.chars; + op2.lines -= op1.lines; + op1.opcode = ""; + if (!op2.chars) { + op2.opcode = ""; + } + } else { + op1.chars -= op2.chars; + op1.lines -= op2.lines; + op2.opcode = ""; + } + } else if (op2.opcode === "-") { + copyOp(op2, opOut); + if (!op1.opcode) { + op2.opcode = ""; + } else if (op2.chars <= op1.chars) { + // delete part or all of a keep + op1.chars -= op2.chars; + op1.lines -= op2.lines; + op2.opcode = ""; + if (!op1.chars) { + op1.opcode = ""; + } + } else { + // delete all of a keep, and keep going + opOut.lines = op1.lines; + opOut.chars = op1.chars; + op2.lines -= op1.lines; + op2.chars -= op1.chars; + op1.opcode = ""; + } + } else if (!op1.opcode) { + copyOp(op2, opOut); + op2.opcode = ""; + } else if (!op2.opcode) { + // @NOTE: Critical bugfix for EPL issue #1625. We do not copy op1 here + // in order to prevent attributes from leaking into result changesets. + // copyOp(op1, opOut); + op1.opcode = ""; + } else { + // both keeps + opOut.opcode = "="; + opOut.attribs = followAttributes(op1.attribs, op2.attribs, pool); + if (op1.chars <= op2.chars) { + opOut.chars = op1.chars; + opOut.lines = op1.lines; + op2.chars -= op1.chars; + op2.lines -= op1.lines; + op1.opcode = ""; + if (!op2.chars) { + op2.opcode = ""; + } + } else { + opOut.chars = op2.chars; + opOut.lines = op2.lines; + op1.chars -= op2.chars; + op1.lines -= op2.lines; + op2.opcode = ""; + } + } + switch (opOut.opcode) { + case "=": + oldPos += opOut.chars; + newLen += opOut.chars; + break; + case "-": + oldPos += opOut.chars; + break; + case "+": + newLen += opOut.chars; + break; + } + return opOut; + }); + newLen += oldLen - oldPos; - return exports.pack(oldLen, newLen, newOps, unpacked2.charBank); + return exports.pack(oldLen, newLen, newOps, unpacked2.charBank); }; const followAttributes = (att1, att2, pool) => { - // The merge of two sets of attribute changes to the same text - // takes the lexically-earlier value if there are two values - // for the same key. Otherwise, all key/value changes from - // both attribute sets are taken. This operation is the "follow", - // so a set of changes is produced that can be applied to att1 - // to produce the merged set. - if ((!att2) || (!pool)) return ''; - if (!att1) return att2; - const atts = new Map(); - att2.replace(/\*([0-9a-z]+)/g, (_, a) => { - const [key, val] = pool.getAttrib(exports.parseNum(a)); - atts.set(key, val); - return ''; - }); - att1.replace(/\*([0-9a-z]+)/g, (_, a) => { - const [key, val] = pool.getAttrib(exports.parseNum(a)); - if (atts.has(key) && val <= atts.get(key)) atts.delete(key); - return ''; - }); - // we've only removed attributes, so they're already sorted - const buf = exports.stringAssembler(); - for (const att of atts) { - buf.append('*'); - buf.append(exports.numToString(pool.putAttrib(att))); - } - return buf.toString(); + // The merge of two sets of attribute changes to the same text + // takes the lexically-earlier value if there are two values + // for the same key. Otherwise, all key/value changes from + // both attribute sets are taken. This operation is the "follow", + // so a set of changes is produced that can be applied to att1 + // to produce the merged set. + if (!att2 || !pool) return ""; + if (!att1) return att2; + const atts = new Map(); + att2.replace(/\*([0-9a-z]+)/g, (_, a) => { + const [key, val] = pool.getAttrib(exports.parseNum(a)); + atts.set(key, val); + return ""; + }); + att1.replace(/\*([0-9a-z]+)/g, (_, a) => { + const [key, val] = pool.getAttrib(exports.parseNum(a)); + if (atts.has(key) && val <= atts.get(key)) atts.delete(key); + return ""; + }); + // we've only removed attributes, so they're already sorted + const buf = exports.stringAssembler(); + for (const att of atts) { + buf.append("*"); + buf.append(exports.numToString(pool.putAttrib(att))); + } + return buf.toString(); }; exports.exportedForTestingOnly = { - TextLinesMutator, - followAttributes, - toSplices, + TextLinesMutator, + followAttributes, + toSplices, }; diff --git a/src/static/js/ChangesetUtils.js b/src/static/js/ChangesetUtils.js index ef2be2ebe..b60128510 100644 --- a/src/static/js/ChangesetUtils.js +++ b/src/static/js/ChangesetUtils.js @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * This module contains several helper Functions to build Changesets @@ -21,32 +21,40 @@ * limitations under the License. */ exports.buildRemoveRange = (rep, builder, start, end) => { - const startLineOffset = rep.lines.offsetOfIndex(start[0]); - const endLineOffset = rep.lines.offsetOfIndex(end[0]); + const startLineOffset = rep.lines.offsetOfIndex(start[0]); + const endLineOffset = rep.lines.offsetOfIndex(end[0]); - if (end[0] > start[0]) { - builder.remove(endLineOffset - startLineOffset - start[1], end[0] - start[0]); - builder.remove(end[1]); - } else { - builder.remove(end[1] - start[1]); - } + if (end[0] > start[0]) { + builder.remove( + endLineOffset - startLineOffset - start[1], + end[0] - start[0], + ); + builder.remove(end[1]); + } else { + builder.remove(end[1] - start[1]); + } }; exports.buildKeepRange = (rep, builder, start, end, attribs, pool) => { - const startLineOffset = rep.lines.offsetOfIndex(start[0]); - const endLineOffset = rep.lines.offsetOfIndex(end[0]); + const startLineOffset = rep.lines.offsetOfIndex(start[0]); + const endLineOffset = rep.lines.offsetOfIndex(end[0]); - if (end[0] > start[0]) { - builder.keep(endLineOffset - startLineOffset - start[1], end[0] - start[0], attribs, pool); - builder.keep(end[1], 0, attribs, pool); - } else { - builder.keep(end[1] - start[1], 0, attribs, pool); - } + if (end[0] > start[0]) { + builder.keep( + endLineOffset - startLineOffset - start[1], + end[0] - start[0], + attribs, + pool, + ); + builder.keep(end[1], 0, attribs, pool); + } else { + builder.keep(end[1] - start[1], 0, attribs, pool); + } }; exports.buildKeepToStartOfRange = (rep, builder, start) => { - const startLineOffset = rep.lines.offsetOfIndex(start[0]); + const startLineOffset = rep.lines.offsetOfIndex(start[0]); - builder.keep(startLineOffset, start[0]); - builder.keep(start[1]); + builder.keep(startLineOffset, start[0]); + builder.keep(start[1]); }; diff --git a/src/static/js/ChatMessage.js b/src/static/js/ChatMessage.js index a627f88f9..e75c2d0b2 100644 --- a/src/static/js/ChatMessage.js +++ b/src/static/js/ChatMessage.js @@ -1,6 +1,8 @@ -'use strict'; +"use strict"; -const {padutils: {warnDeprecated}} = require('./pad_utils'); +const { + padutils: { warnDeprecated }, +} = require("./pad_utils"); /** * Represents a chat message stored in the database and transmitted among users. Plugins can extend @@ -9,90 +11,99 @@ const {padutils: {warnDeprecated}} = require('./pad_utils'); * Supports serialization to JSON. */ class ChatMessage { - static fromObject(obj) { - // The userId property was renamed to authorId, and userName was renamed to displayName. Accept - // the old names in case the db record was written by an older version of Etherpad. - obj = Object.assign({}, obj); // Don't mutate the caller's object. - if ('userId' in obj && !('authorId' in obj)) obj.authorId = obj.userId; - delete obj.userId; - if ('userName' in obj && !('displayName' in obj)) obj.displayName = obj.userName; - delete obj.userName; - return Object.assign(new ChatMessage(), obj); - } + static fromObject(obj) { + // The userId property was renamed to authorId, and userName was renamed to displayName. Accept + // the old names in case the db record was written by an older version of Etherpad. + obj = Object.assign({}, obj); // Don't mutate the caller's object. + if ("userId" in obj && !("authorId" in obj)) obj.authorId = obj.userId; + delete obj.userId; + if ("userName" in obj && !("displayName" in obj)) + obj.displayName = obj.userName; + delete obj.userName; + return Object.assign(new ChatMessage(), obj); + } - /** - * @param {?string} [text] - Initial value of the `text` property. - * @param {?string} [authorId] - Initial value of the `authorId` property. - * @param {?number} [time] - Initial value of the `time` property. - */ - constructor(text = null, authorId = null, time = null) { - /** - * The raw text of the user's chat message (before any rendering or processing). - * - * @type {?string} - */ - this.text = text; + /** + * @param {?string} [text] - Initial value of the `text` property. + * @param {?string} [authorId] - Initial value of the `authorId` property. + * @param {?number} [time] - Initial value of the `time` property. + */ + constructor(text = null, authorId = null, time = null) { + /** + * The raw text of the user's chat message (before any rendering or processing). + * + * @type {?string} + */ + this.text = text; - /** - * The user's author ID. - * - * @type {?string} - */ - this.authorId = authorId; + /** + * The user's author ID. + * + * @type {?string} + */ + this.authorId = authorId; - /** - * The message's timestamp, as milliseconds since epoch. - * - * @type {?number} - */ - this.time = time; + /** + * The message's timestamp, as milliseconds since epoch. + * + * @type {?number} + */ + this.time = time; - /** - * The user's display name. - * - * @type {?string} - */ - this.displayName = null; - } + /** + * The user's display name. + * + * @type {?string} + */ + this.displayName = null; + } - /** - * Alias of `authorId`, for compatibility with old plugins. - * - * @deprecated Use `authorId` instead. - * @type {string} - */ - get userId() { - warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead'); - return this.authorId; - } - set userId(val) { - warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead'); - this.authorId = val; - } + /** + * Alias of `authorId`, for compatibility with old plugins. + * + * @deprecated Use `authorId` instead. + * @type {string} + */ + get userId() { + warnDeprecated( + "ChatMessage.userId property is deprecated; use .authorId instead", + ); + return this.authorId; + } + set userId(val) { + warnDeprecated( + "ChatMessage.userId property is deprecated; use .authorId instead", + ); + this.authorId = val; + } - /** - * Alias of `displayName`, for compatibility with old plugins. - * - * @deprecated Use `displayName` instead. - * @type {string} - */ - get userName() { - warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead'); - return this.displayName; - } - set userName(val) { - warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead'); - this.displayName = val; - } + /** + * Alias of `displayName`, for compatibility with old plugins. + * + * @deprecated Use `displayName` instead. + * @type {string} + */ + get userName() { + warnDeprecated( + "ChatMessage.userName property is deprecated; use .displayName instead", + ); + return this.displayName; + } + set userName(val) { + warnDeprecated( + "ChatMessage.userName property is deprecated; use .displayName instead", + ); + this.displayName = val; + } - // TODO: Delete this method once users are unlikely to roll back to a version of Etherpad that - // doesn't support authorId and displayName. - toJSON() { - const {authorId, displayName, ...obj} = this; - obj.userId = authorId; - obj.userName = displayName; - return obj; - } + // TODO: Delete this method once users are unlikely to roll back to a version of Etherpad that + // doesn't support authorId and displayName. + toJSON() { + const { authorId, displayName, ...obj } = this; + obj.userId = authorId; + obj.userName = displayName; + return obj; + } } module.exports = ChatMessage; diff --git a/src/static/js/ace.js b/src/static/js/ace.js index b0a042570..c9822e728 100644 --- a/src/static/js/ace.js +++ b/src/static/js/ace.js @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * This code is mostly from the old Etherpad. Please help us to comment this code. * This helps other people to understand this code better and helps them to improve it. @@ -24,9 +24,9 @@ // requires: top // requires: undefined -const hooks = require('./pluginfw/hooks'); -const makeCSSManager = require('./cssmanager').makeCSSManager; -const pluginUtils = require('./pluginfw/shared'); +const hooks = require("./pluginfw/hooks"); +const makeCSSManager = require("./cssmanager").makeCSSManager; +const pluginUtils = require("./pluginfw/shared"); const debugLog = (...args) => {}; @@ -35,285 +35,325 @@ const debugLog = (...args) => {}; // errors out unless given an absolute URL for a JavaScript-created element. const absUrl = (url) => new URL(url, window.location.href).href; -const eventFired = async (obj, event, cleanups = [], predicate = () => true) => { - if (typeof cleanups === 'function') { - predicate = cleanups; - cleanups = []; - } - await new Promise((resolve, reject) => { - let cleanup; - const successCb = () => { - if (!predicate()) return; - debugLog(`Ace2Editor.init() ${event} event on`, obj); - cleanup(); - resolve(); - }; - const errorCb = () => { - const err = new Error(`Ace2Editor.init() error event while waiting for ${event} event`); - debugLog(`${err} on object`, obj); - cleanup(); - reject(err); - }; - cleanup = () => { - cleanup = () => {}; - obj.removeEventListener(event, successCb); - obj.removeEventListener('error', errorCb); - }; - cleanups.push(cleanup); - obj.addEventListener(event, successCb); - obj.addEventListener('error', errorCb); - }); +const eventFired = async ( + obj, + event, + cleanups = [], + predicate = () => true, +) => { + if (typeof cleanups === "function") { + predicate = cleanups; + cleanups = []; + } + await new Promise((resolve, reject) => { + let cleanup; + const successCb = () => { + if (!predicate()) return; + debugLog(`Ace2Editor.init() ${event} event on`, obj); + cleanup(); + resolve(); + }; + const errorCb = () => { + const err = new Error( + `Ace2Editor.init() error event while waiting for ${event} event`, + ); + debugLog(`${err} on object`, obj); + cleanup(); + reject(err); + }; + cleanup = () => { + cleanup = () => {}; + obj.removeEventListener(event, successCb); + obj.removeEventListener("error", errorCb); + }; + cleanups.push(cleanup); + obj.addEventListener(event, successCb); + obj.addEventListener("error", errorCb); + }); }; // Resolves when the frame's document is ready to be mutated. Browsers seem to be quirky about // iframe ready events so this function throws the kitchen sink at the problem. Maybe one day we'll // find a concise general solution. const frameReady = async (frame) => { - // Can't do `const doc = frame.contentDocument;` because Firefox seems to asynchronously replace - // the document object after the frame is first created for some reason. ¯\_(ツ)_/¯ - const doc = () => frame.contentDocument; - const cleanups = []; - try { - await Promise.race([ - eventFired(frame, 'load', cleanups), - eventFired(frame.contentWindow, 'load', cleanups), - eventFired(doc(), 'load', cleanups), - eventFired(doc(), 'DOMContentLoaded', cleanups), - eventFired(doc(), 'readystatechange', cleanups, () => doc.readyState === 'complete'), - ]); - } finally { - for (const cleanup of cleanups) cleanup(); - } + // Can't do `const doc = frame.contentDocument;` because Firefox seems to asynchronously replace + // the document object after the frame is first created for some reason. ¯\_(ツ)_/¯ + const doc = () => frame.contentDocument; + const cleanups = []; + try { + await Promise.race([ + eventFired(frame, "load", cleanups), + eventFired(frame.contentWindow, "load", cleanups), + eventFired(doc(), "load", cleanups), + eventFired(doc(), "DOMContentLoaded", cleanups), + eventFired( + doc(), + "readystatechange", + cleanups, + () => doc.readyState === "complete", + ), + ]); + } finally { + for (const cleanup of cleanups) cleanup(); + } }; const Ace2Editor = function () { - let info = {editor: this}; - let loaded = false; + let info = { editor: this }; + let loaded = false; - let actionsPendingInit = []; + let actionsPendingInit = []; - const pendingInit = (func) => function (...args) { - const action = () => func.apply(this, args); - if (loaded) return action(); - actionsPendingInit.push(action); - }; + const pendingInit = (func) => + function (...args) { + const action = () => func.apply(this, args); + if (loaded) return action(); + actionsPendingInit.push(action); + }; - const doActionsPendingInit = () => { - for (const fn of actionsPendingInit) fn(); - actionsPendingInit = []; - }; + const doActionsPendingInit = () => { + for (const fn of actionsPendingInit) fn(); + actionsPendingInit = []; + }; - // The following functions (prefixed by 'ace_') are exposed by editor, but - // execution is delayed until init is complete - const aceFunctionsPendingInit = [ - 'importText', - 'importAText', - 'focus', - 'setEditable', - 'setOnKeyPress', - 'setOnKeyDown', - 'setNotifyDirty', - 'setProperty', - 'setBaseText', - 'setBaseAttributedText', - 'applyChangesToBase', - 'applyPreparedChangesetToBase', - 'setUserChangeNotificationCallback', - 'setAuthorInfo', - 'callWithAce', - 'execCommand', - 'replaceRange', - ]; + // The following functions (prefixed by 'ace_') are exposed by editor, but + // execution is delayed until init is complete + const aceFunctionsPendingInit = [ + "importText", + "importAText", + "focus", + "setEditable", + "setOnKeyPress", + "setOnKeyDown", + "setNotifyDirty", + "setProperty", + "setBaseText", + "setBaseAttributedText", + "applyChangesToBase", + "applyPreparedChangesetToBase", + "setUserChangeNotificationCallback", + "setAuthorInfo", + "callWithAce", + "execCommand", + "replaceRange", + ]; - for (const fnName of aceFunctionsPendingInit) { - // Note: info[`ace_${fnName}`] does not exist yet, so it can't be passed directly to - // pendingInit(). A simple wrapper is used to defer the info[`ace_${fnName}`] lookup until - // method invocation. - this[fnName] = pendingInit(function (...args) { - info[`ace_${fnName}`].apply(this, args); - }); - } + for (const fnName of aceFunctionsPendingInit) { + // Note: info[`ace_${fnName}`] does not exist yet, so it can't be passed directly to + // pendingInit(). A simple wrapper is used to defer the info[`ace_${fnName}`] lookup until + // method invocation. + this[fnName] = pendingInit(function (...args) { + info[`ace_${fnName}`].apply(this, args); + }); + } - this.exportText = () => loaded ? info.ace_exportText() : '(awaiting init)\n'; + this.exportText = () => + loaded ? info.ace_exportText() : "(awaiting init)\n"; - this.getInInternationalComposition = - () => loaded ? info.ace_getInInternationalComposition() : null; + this.getInInternationalComposition = () => + loaded ? info.ace_getInInternationalComposition() : null; - // prepareUserChangeset: - // Returns null if no new changes or ACE not ready. Otherwise, bundles up all user changes - // to the latest base text into a Changeset, which is returned (as a string if encodeAsString). - // If this method returns a truthy value, then applyPreparedChangesetToBase can be called at some - // later point to consider these changes part of the base, after which prepareUserChangeset must - // be called again before applyPreparedChangesetToBase. Multiple consecutive calls to - // prepareUserChangeset will return an updated changeset that takes into account the latest user - // changes, and modify the changeset to be applied by applyPreparedChangesetToBase accordingly. - this.prepareUserChangeset = () => loaded ? info.ace_prepareUserChangeset() : null; + // prepareUserChangeset: + // Returns null if no new changes or ACE not ready. Otherwise, bundles up all user changes + // to the latest base text into a Changeset, which is returned (as a string if encodeAsString). + // If this method returns a truthy value, then applyPreparedChangesetToBase can be called at some + // later point to consider these changes part of the base, after which prepareUserChangeset must + // be called again before applyPreparedChangesetToBase. Multiple consecutive calls to + // prepareUserChangeset will return an updated changeset that takes into account the latest user + // changes, and modify the changeset to be applied by applyPreparedChangesetToBase accordingly. + this.prepareUserChangeset = () => + loaded ? info.ace_prepareUserChangeset() : null; - const addStyleTagsFor = (doc, files) => { - for (const file of files) { - const link = doc.createElement('link'); - link.rel = 'stylesheet'; - link.type = 'text/css'; - link.href = absUrl(encodeURI(file)); - doc.head.appendChild(link); - } - }; + const addStyleTagsFor = (doc, files) => { + for (const file of files) { + const link = doc.createElement("link"); + link.rel = "stylesheet"; + link.type = "text/css"; + link.href = absUrl(encodeURI(file)); + doc.head.appendChild(link); + } + }; - this.destroy = pendingInit(() => { - info.ace_dispose(); - info.frame.parentNode.removeChild(info.frame); - info = null; // prevent IE 6 closure memory leaks - }); + this.destroy = pendingInit(() => { + info.ace_dispose(); + info.frame.parentNode.removeChild(info.frame); + info = null; // prevent IE 6 closure memory leaks + }); - this.init = async function (containerId, initialCode) { - debugLog('Ace2Editor.init()'); - this.importText(initialCode); + this.init = async function (containerId, initialCode) { + debugLog("Ace2Editor.init()"); + this.importText(initialCode); - const includedCSS = [ - `../static/css/iframe_editor.css?v=${clientVars.randomVersionString}`, - `../static/css/pad.css?v=${clientVars.randomVersionString}`, - ...hooks.callAll('aceEditorCSS').map( - // Allow urls to external CSS - http(s):// and //some/path.css - (p) => /\/\//.test(p) ? p : `../static/plugins/${p}`), - `../static/skins/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`, - ]; + const includedCSS = [ + `../static/css/iframe_editor.css?v=${clientVars.randomVersionString}`, + `../static/css/pad.css?v=${clientVars.randomVersionString}`, + ...hooks.callAll("aceEditorCSS").map( + // Allow urls to external CSS - http(s):// and //some/path.css + (p) => (/\/\//.test(p) ? p : `../static/plugins/${p}`), + ), + `../static/skins/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`, + ]; - const skinVariants = clientVars.skinVariants.split(' ').filter((x) => x !== ''); + const skinVariants = clientVars.skinVariants + .split(" ") + .filter((x) => x !== ""); - const outerFrame = document.createElement('iframe'); - outerFrame.name = 'ace_outer'; - outerFrame.frameBorder = 0; // for IE - outerFrame.title = 'Ether'; - // Some browsers do strange things unless the iframe has a src or srcdoc property: - // - Firefox replaces the frame's contentWindow.document object with a different object after - // the frame is created. This can be worked around by waiting for the window's load event - // before continuing. - // - Chrome never fires any events on the frame or document. Eventually the document's - // readyState becomes 'complete' even though it never fires a readystatechange event. - // - Safari behaves like Chrome. - // srcdoc is avoided because Firefox's Content Security Policy engine does not properly handle - // 'self' with nested srcdoc iframes: https://bugzilla.mozilla.org/show_bug.cgi?id=1721296 - outerFrame.src = '../static/empty.html'; - info.frame = outerFrame; - document.getElementById(containerId).appendChild(outerFrame); - const outerWindow = outerFrame.contentWindow; + const outerFrame = document.createElement("iframe"); + outerFrame.name = "ace_outer"; + outerFrame.frameBorder = 0; // for IE + outerFrame.title = "Ether"; + // Some browsers do strange things unless the iframe has a src or srcdoc property: + // - Firefox replaces the frame's contentWindow.document object with a different object after + // the frame is created. This can be worked around by waiting for the window's load event + // before continuing. + // - Chrome never fires any events on the frame or document. Eventually the document's + // readyState becomes 'complete' even though it never fires a readystatechange event. + // - Safari behaves like Chrome. + // srcdoc is avoided because Firefox's Content Security Policy engine does not properly handle + // 'self' with nested srcdoc iframes: https://bugzilla.mozilla.org/show_bug.cgi?id=1721296 + outerFrame.src = "../static/empty.html"; + info.frame = outerFrame; + document.getElementById(containerId).appendChild(outerFrame); + const outerWindow = outerFrame.contentWindow; - debugLog('Ace2Editor.init() waiting for outer frame'); - await frameReady(outerFrame); - debugLog('Ace2Editor.init() outer frame ready'); + debugLog("Ace2Editor.init() waiting for outer frame"); + await frameReady(outerFrame); + debugLog("Ace2Editor.init() outer frame ready"); - // Firefox might replace the outerWindow.document object after iframe creation so this variable - // is assigned after the Window's load event. - const outerDocument = outerWindow.document; + // Firefox might replace the outerWindow.document object after iframe creation so this variable + // is assigned after the Window's load event. + const outerDocument = outerWindow.document; - // tag - outerDocument.documentElement.classList.add('outer-editor', 'outerdoc', ...skinVariants); + // tag + outerDocument.documentElement.classList.add( + "outer-editor", + "outerdoc", + ...skinVariants, + ); - // tag - addStyleTagsFor(outerDocument, includedCSS); - const outerStyle = outerDocument.createElement('style'); - outerStyle.type = 'text/css'; - outerStyle.title = 'dynamicsyntax'; - outerDocument.head.appendChild(outerStyle); + // tag + addStyleTagsFor(outerDocument, includedCSS); + const outerStyle = outerDocument.createElement("style"); + outerStyle.type = "text/css"; + outerStyle.title = "dynamicsyntax"; + outerDocument.head.appendChild(outerStyle); - // tag - outerDocument.body.id = 'outerdocbody'; - outerDocument.body.classList.add('outerdocbody', ...pluginUtils.clientPluginNames()); - const sideDiv = outerDocument.createElement('div'); - sideDiv.id = 'sidediv'; - sideDiv.classList.add('sidediv'); - outerDocument.body.appendChild(sideDiv); - const sideDivInner = outerDocument.createElement('div'); - sideDivInner.id = 'sidedivinner'; - sideDivInner.classList.add('sidedivinner'); - sideDiv.appendChild(sideDivInner); - const lineMetricsDiv = outerDocument.createElement('div'); - lineMetricsDiv.id = 'linemetricsdiv'; - lineMetricsDiv.appendChild(outerDocument.createTextNode('x')); - outerDocument.body.appendChild(lineMetricsDiv); + // tag + outerDocument.body.id = "outerdocbody"; + outerDocument.body.classList.add( + "outerdocbody", + ...pluginUtils.clientPluginNames(), + ); + const sideDiv = outerDocument.createElement("div"); + sideDiv.id = "sidediv"; + sideDiv.classList.add("sidediv"); + outerDocument.body.appendChild(sideDiv); + const sideDivInner = outerDocument.createElement("div"); + sideDivInner.id = "sidedivinner"; + sideDivInner.classList.add("sidedivinner"); + sideDiv.appendChild(sideDivInner); + const lineMetricsDiv = outerDocument.createElement("div"); + lineMetricsDiv.id = "linemetricsdiv"; + lineMetricsDiv.appendChild(outerDocument.createTextNode("x")); + outerDocument.body.appendChild(lineMetricsDiv); - const innerFrame = outerDocument.createElement('iframe'); - innerFrame.name = 'ace_inner'; - innerFrame.title = 'pad'; - innerFrame.scrolling = 'no'; - innerFrame.frameBorder = 0; - innerFrame.allowTransparency = true; // for IE - // The iframe MUST have a src or srcdoc property to avoid browser quirks. See the comment above - // outerFrame.srcdoc. - innerFrame.src = 'empty.html'; - outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild); - const innerWindow = innerFrame.contentWindow; + const innerFrame = outerDocument.createElement("iframe"); + innerFrame.name = "ace_inner"; + innerFrame.title = "pad"; + innerFrame.scrolling = "no"; + innerFrame.frameBorder = 0; + innerFrame.allowTransparency = true; // for IE + // The iframe MUST have a src or srcdoc property to avoid browser quirks. See the comment above + // outerFrame.srcdoc. + innerFrame.src = "empty.html"; + outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild); + const innerWindow = innerFrame.contentWindow; - debugLog('Ace2Editor.init() waiting for inner frame'); - await frameReady(innerFrame); - debugLog('Ace2Editor.init() inner frame ready'); + debugLog("Ace2Editor.init() waiting for inner frame"); + await frameReady(innerFrame); + debugLog("Ace2Editor.init() inner frame ready"); - // Firefox might replace the innerWindow.document object after iframe creation so this variable - // is assigned after the Window's load event. - const innerDocument = innerWindow.document; + // Firefox might replace the innerWindow.document object after iframe creation so this variable + // is assigned after the Window's load event. + const innerDocument = innerWindow.document; - // tag - innerDocument.documentElement.classList.add('inner-editor', ...skinVariants); + // tag + innerDocument.documentElement.classList.add( + "inner-editor", + ...skinVariants, + ); - // tag - addStyleTagsFor(innerDocument, includedCSS); - const requireKernel = innerDocument.createElement('script'); - requireKernel.type = 'text/javascript'; - requireKernel.src = - absUrl(`../static/js/require-kernel.js?v=${clientVars.randomVersionString}`); - innerDocument.head.appendChild(requireKernel); - // Pre-fetch modules to improve load performance. - for (const module of ['ace2_inner', 'ace2_common']) { - const script = innerDocument.createElement('script'); - script.type = 'text/javascript'; - script.src = absUrl(`../javascripts/lib/ep_etherpad-lite/static/js/${module}.js` + - `?callback=require.define&v=${clientVars.randomVersionString}`); - innerDocument.head.appendChild(script); - } - const innerStyle = innerDocument.createElement('style'); - innerStyle.type = 'text/css'; - innerStyle.title = 'dynamicsyntax'; - innerDocument.head.appendChild(innerStyle); - const headLines = []; - hooks.callAll('aceInitInnerdocbodyHead', {iframeHTML: headLines}); - innerDocument.head.appendChild( - innerDocument.createRange().createContextualFragment(headLines.join('\n'))); + // tag + addStyleTagsFor(innerDocument, includedCSS); + const requireKernel = innerDocument.createElement("script"); + requireKernel.type = "text/javascript"; + requireKernel.src = absUrl( + `../static/js/require-kernel.js?v=${clientVars.randomVersionString}`, + ); + innerDocument.head.appendChild(requireKernel); + // Pre-fetch modules to improve load performance. + for (const module of ["ace2_inner", "ace2_common"]) { + const script = innerDocument.createElement("script"); + script.type = "text/javascript"; + script.src = absUrl( + `../javascripts/lib/ep_etherpad-lite/static/js/${module}.js` + + `?callback=require.define&v=${clientVars.randomVersionString}`, + ); + innerDocument.head.appendChild(script); + } + const innerStyle = innerDocument.createElement("style"); + innerStyle.type = "text/css"; + innerStyle.title = "dynamicsyntax"; + innerDocument.head.appendChild(innerStyle); + const headLines = []; + hooks.callAll("aceInitInnerdocbodyHead", { iframeHTML: headLines }); + innerDocument.head.appendChild( + innerDocument + .createRange() + .createContextualFragment(headLines.join("\n")), + ); - // tag - innerDocument.body.id = 'innerdocbody'; - innerDocument.body.classList.add('innerdocbody'); - innerDocument.body.setAttribute('spellcheck', 'false'); - innerDocument.body.appendChild(innerDocument.createTextNode('\u00A0')); //   + // tag + innerDocument.body.id = "innerdocbody"; + innerDocument.body.classList.add("innerdocbody"); + innerDocument.body.setAttribute("spellcheck", "false"); + innerDocument.body.appendChild(innerDocument.createTextNode("\u00A0")); //   - debugLog('Ace2Editor.init() waiting for require kernel load'); - await eventFired(requireKernel, 'load'); - debugLog('Ace2Editor.init() require kernel loaded'); - const require = innerWindow.require; - require.setRootURI(absUrl('../javascripts/src')); - require.setLibraryURI(absUrl('../javascripts/lib')); - require.setGlobalKeyPath('require'); + debugLog("Ace2Editor.init() waiting for require kernel load"); + await eventFired(requireKernel, "load"); + debugLog("Ace2Editor.init() require kernel loaded"); + const require = innerWindow.require; + require.setRootURI(absUrl("../javascripts/src")); + require.setLibraryURI(absUrl("../javascripts/lib")); + require.setGlobalKeyPath("require"); - // intentially moved before requiring client_plugins to save a 307 - innerWindow.Ace2Inner = require('ep_etherpad-lite/static/js/ace2_inner'); - innerWindow.plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins'); - innerWindow.plugins.adoptPluginsFromAncestorsOf(innerWindow); + // intentially moved before requiring client_plugins to save a 307 + innerWindow.Ace2Inner = require("ep_etherpad-lite/static/js/ace2_inner"); + innerWindow.plugins = require("ep_etherpad-lite/static/js/pluginfw/client_plugins"); + innerWindow.plugins.adoptPluginsFromAncestorsOf(innerWindow); - innerWindow.$ = innerWindow.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery; + innerWindow.$ = innerWindow.jQuery = + require("ep_etherpad-lite/static/js/rjquery").jQuery; - debugLog('Ace2Editor.init() waiting for plugins'); - await new Promise((resolve, reject) => innerWindow.plugins.ensure( - (err) => err != null ? reject(err) : resolve())); - debugLog('Ace2Editor.init() waiting for Ace2Inner.init()'); - await innerWindow.Ace2Inner.init(info, { - inner: makeCSSManager(innerStyle.sheet), - outer: makeCSSManager(outerStyle.sheet), - parent: makeCSSManager(document.querySelector('style[title="dynamicsyntax"]').sheet), - }); - debugLog('Ace2Editor.init() Ace2Inner.init() returned'); - loaded = true; - doActionsPendingInit(); - debugLog('Ace2Editor.init() done'); - }; + debugLog("Ace2Editor.init() waiting for plugins"); + await new Promise((resolve, reject) => + innerWindow.plugins.ensure((err) => + err != null ? reject(err) : resolve(), + ), + ); + debugLog("Ace2Editor.init() waiting for Ace2Inner.init()"); + await innerWindow.Ace2Inner.init(info, { + inner: makeCSSManager(innerStyle.sheet), + outer: makeCSSManager(outerStyle.sheet), + parent: makeCSSManager( + document.querySelector('style[title="dynamicsyntax"]').sheet, + ), + }); + debugLog("Ace2Editor.init() Ace2Inner.init() returned"); + loaded = true; + doActionsPendingInit(); + debugLog("Ace2Editor.init() done"); + }; }; exports.Ace2Editor = Ace2Editor; diff --git a/src/static/js/ace2_common.js b/src/static/js/ace2_common.js index c1dab5cfd..e7010eb75 100644 --- a/src/static/js/ace2_common.js +++ b/src/static/js/ace2_common.js @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * This code is mostly from the old Etherpad. Please help us to comment this code. @@ -22,40 +22,39 @@ * limitations under the License. */ -const isNodeText = (node) => (node.nodeType === 3); +const isNodeText = (node) => node.nodeType === 3; const getAssoc = (obj, name) => obj[`_magicdom_${name}`]; const setAssoc = (obj, name, value) => { - // note that in IE designMode, properties of a node can get - // copied to new nodes that are spawned during editing; also, - // properties representable in HTML text can survive copy-and-paste - obj[`_magicdom_${name}`] = value; + // note that in IE designMode, properties of a node can get + // copied to new nodes that are spawned during editing; also, + // properties representable in HTML text can survive copy-and-paste + obj[`_magicdom_${name}`] = value; }; // "func" is a function over 0..(numItems-1) that is monotonically // "increasing" with index (false, then true). Finds the boundary // between false and true, a number between 0 and numItems inclusive. - const binarySearch = (numItems, func) => { - if (numItems < 1) return 0; - if (func(0)) return 0; - if (!func(numItems - 1)) return numItems; - let low = 0; // func(low) is always false - let high = numItems - 1; // func(high) is always true - while ((high - low) > 1) { - const x = Math.floor((low + high) / 2); // x != low, x != high - if (func(x)) high = x; - else low = x; - } - return high; + if (numItems < 1) return 0; + if (func(0)) return 0; + if (!func(numItems - 1)) return numItems; + let low = 0; // func(low) is always false + let high = numItems - 1; // func(high) is always true + while (high - low > 1) { + const x = Math.floor((low + high) / 2); // x != low, x != high + if (func(x)) high = x; + else low = x; + } + return high; }; const binarySearchInfinite = (expectedLength, func) => { - let i = 0; - while (!func(i)) i += expectedLength; - return binarySearch(i, func); + let i = 0; + while (!func(i)) i += expectedLength; + return binarySearch(i, func); }; const noop = () => {}; diff --git a/src/static/js/ace2_inner.js b/src/static/js/ace2_inner.js index e64c8695d..143e44adb 100644 --- a/src/static/js/ace2_inner.js +++ b/src/static/js/ace2_inner.js @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * Copyright 2009 Google Inc. @@ -18,3510 +18,3979 @@ */ let documentAttributeManager; -const AttributeMap = require('./AttributeMap'); -const browser = require('./vendors/browser'); -const padutils = require('./pad_utils').padutils; -const Ace2Common = require('./ace2_common'); -const $ = require('./rjquery').$; +const AttributeMap = require("./AttributeMap"); +const browser = require("./vendors/browser"); +const padutils = require("./pad_utils").padutils; +const Ace2Common = require("./ace2_common"); +const $ = require("./rjquery").$; const isNodeText = Ace2Common.isNodeText; const getAssoc = Ace2Common.getAssoc; const setAssoc = Ace2Common.setAssoc; const noop = Ace2Common.noop; -const hooks = require('./pluginfw/hooks'); +const hooks = require("./pluginfw/hooks"); function Ace2Inner(editorInfo, cssManagers) { - const makeChangesetTracker = require('./changesettracker').makeChangesetTracker; - const colorutils = require('./colorutils').colorutils; - const makeContentCollector = require('./contentcollector').makeContentCollector; - const domline = require('./domline').domline; - const AttribPool = require('./AttributePool'); - const Changeset = require('./Changeset'); - const ChangesetUtils = require('./ChangesetUtils'); - const linestylefilter = require('./linestylefilter').linestylefilter; - const SkipList = require('./skiplist'); - const undoModule = require('./undomodule').undoModule; - const AttributeManager = require('./AttributeManager'); - const Scroll = require('./scroll'); - const DEBUG = false; - - const THE_TAB = ' '; // 4 - const MAX_LIST_LEVEL = 16; - - const FORMATTING_STYLES = ['bold', 'italic', 'underline', 'strikethrough']; - const SELECT_BUTTON_CLASS = 'selected'; - - let thisAuthor = ''; - - let disposed = false; - - const focus = () => { - window.focus(); - }; - - const outerWin = window.parent; - const outerDoc = outerWin.document; - const sideDiv = outerDoc.getElementById('sidediv'); - const lineMetricsDiv = outerDoc.getElementById('linemetricsdiv'); - const sideDivInner = outerDoc.getElementById('sidedivinner'); - const appendNewSideDivLine = () => { - const lineDiv = outerDoc.createElement('div'); - sideDivInner.appendChild(lineDiv); - const lineSpan = outerDoc.createElement('span'); - lineSpan.classList.add('line-number'); - lineSpan.appendChild(outerDoc.createTextNode(sideDivInner.children.length)); - lineDiv.appendChild(lineSpan); - }; - appendNewSideDivLine(); - - const scroll = Scroll.init(outerWin); - - let outsideKeyDown = noop; - let outsideKeyPress = (e) => true; - let outsideNotifyDirty = noop; - - /** - * Document representation. - */ - const rep = { - /** - * The contents of the document. Each entry in this skip list is an object representing a - * line (actually paragraph) of text. The line objects are created by createDomLineEntry(). - */ - lines: new SkipList(), - /** - * Start of the selection. Represented as an array of two non-negative numbers that point to the - * first character of the selection: [zeroBasedLineNumber, zeroBasedColumnNumber]. Notes: - * - There is an implicit newline character (not actually stored) at the end of every line. - * Because of this, a selection that starts at the end of a line (column number equals the - * number of characters in the line, not including the implicit newline) is not equivalent - * to a selection that starts at the beginning of the next line. The same goes for the - * selection end. - * - If there are N lines, [N, 0] is valid for the start of the selection. [N, 0] indicates - * that the selection starts just after the implicit newline at the end of the document's - * last line (if the document has any lines). The same goes for the end of the selection. - * - If a line starts with a line marker, a selection that starts at the beginning of the line - * may start either immediately before (column = 0) or immediately after (column = 1) the - * line marker, and the two are considered to be semantically equivalent. For safety, all - * code should be written to accept either but only produce selections that start after the - * line marker (the column number should be 1, not 0, when there is a line marker). The same - * goes for the end of the selection. - */ - selStart: null, - /** - * End of the selection. Represented as an array of two non-negative numbers that point to the - * character just after the end of the selection: [zeroBasedLineNumber, zeroBasedColumnNumber]. - * See the above notes for selStart. - */ - selEnd: null, - /** - * Whether the selection extends "backwards", so that the focus point (controlled with the arrow - * keys) is at the beginning. This is not supported in IE, though native IE selections have that - * behavior (which we try not to interfere with). Must be false if selection is collapsed! - */ - selFocusAtStart: false, - alltext: '', - alines: [], - apool: new AttribPool(), - }; - - // lines, alltext, alines, and DOM are set up in init() - if (undoModule.enabled) { - undoModule.apool = rep.apool; - } - - let isEditable = true; - let doesWrap = true; - let hasLineNumbers = true; - let isStyled = true; - - let console = (DEBUG && window.console); - - if (!window.console) { - const names = [ - 'log', - 'debug', - 'info', - 'warn', - 'error', - 'assert', - 'dir', - 'dirxml', - 'group', - 'groupEnd', - 'time', - 'timeEnd', - 'count', - 'trace', - 'profile', - 'profileEnd', - ]; - console = {}; - for (const name of names) console[name] = noop; - } - - const scheduler = parent; // hack for opera required - - const performDocumentReplaceRange = (start, end, newText) => { - if (start === undefined) start = rep.selStart; - if (end === undefined) end = rep.selEnd; - - // start[0]: <--- start[1] --->CCCCCCCCCCC\n - // CCCCCCCCCCCCCCCCCCCC\n - // CCCC\n - // end[0]: -------\n - const builder = Changeset.builder(rep.lines.totalWidth()); - ChangesetUtils.buildKeepToStartOfRange(rep, builder, start); - ChangesetUtils.buildRemoveRange(rep, builder, start, end); - builder.insert(newText, [ - ['author', thisAuthor], - ], rep.apool); - const cs = builder.toString(); - - performDocumentApplyChangeset(cs); - }; - - const changesetTracker = makeChangesetTracker(scheduler, rep.apool, { - withCallbacks: (operationName, f) => { - inCallStackIfNecessary(operationName, () => { - fastIncorp(1); - f( - { - setDocumentAttributedText: (atext) => { - setDocAText(atext); - }, - applyChangesetToDocument: (changeset, preferInsertionAfterCaret) => { - const oldEventType = currentCallStack.editEvent.eventType; - currentCallStack.startNewEvent('nonundoable'); - - performDocumentApplyChangeset(changeset, preferInsertionAfterCaret); - - currentCallStack.startNewEvent(oldEventType); - }, - }); - }); - }, - }); - - const authorInfos = {}; // presence of key determines if author is present in doc - const getAuthorInfos = () => authorInfos; - editorInfo.ace_getAuthorInfos = getAuthorInfos; - - const setAuthorStyle = (author, info) => { - const authorSelector = getAuthorColorClassSelector(getAuthorClassName(author)); - - const authorStyleSet = hooks.callAll('aceSetAuthorStyle', { - dynamicCSS: cssManagers.inner, - outerDynamicCSS: cssManagers.outer, - parentDynamicCSS: cssManagers.parent, - info, - author, - authorSelector, - }); - - // Prevent default behaviour if any hook says so - if (authorStyleSet.some((it) => it)) { - return; - } - - if (!info) { - cssManagers.inner.removeSelectorStyle(authorSelector); - cssManagers.parent.removeSelectorStyle(authorSelector); - } else if (info.bgcolor) { - let bgcolor = info.bgcolor; - if ((typeof info.fade) === 'number') { - bgcolor = fadeColor(bgcolor, info.fade); - } - const textColor = - colorutils.textColorFromBackgroundColor(bgcolor, parent.parent.clientVars.skinName); - const styles = [ - cssManagers.inner.selectorStyle(authorSelector), - cssManagers.parent.selectorStyle(authorSelector), - ]; - for (const style of styles) { - style.backgroundColor = bgcolor; - style.color = textColor; - style['padding-top'] = '3px'; - style['padding-bottom'] = '4px'; - } - } - }; - - const setAuthorInfo = (author, info) => { - if (!author) return; // author ID not set for some reason - if ((typeof author) !== 'string') { - // Potentially caused by: https://github.com/ether/etherpad-lite/issues/2802"); - throw new Error(`setAuthorInfo: author (${author}) is not a string`); - } - if (!info) { - delete authorInfos[author]; - } else { - authorInfos[author] = info; - } - setAuthorStyle(author, info); - }; - - const getAuthorClassName = (author) => `author-${author.replace(/[^a-y0-9]/g, (c) => { - if (c === '.') return '-'; - return `z${c.charCodeAt(0)}z`; - })}`; - - const className2Author = (className) => { - if (className.substring(0, 7) === 'author-') { - return className.substring(7).replace(/[a-y0-9]+|-|z.+?z/g, (cc) => { - if (cc === '-') { return '.'; } else if (cc.charAt(0) === 'z') { - return String.fromCharCode(Number(cc.slice(1, -1))); - } else { - return cc; - } - }); - } - return null; - }; - - const getAuthorColorClassSelector = (oneClassName) => `.authorColors .${oneClassName}`; - - const fadeColor = (colorCSS, fadeFrac) => { - let color = colorutils.css2triple(colorCSS); - color = colorutils.blend(color, [1, 1, 1], fadeFrac); - return colorutils.triple2css(color); - }; - - editorInfo.ace_getRep = () => rep; - - editorInfo.ace_getAuthor = () => thisAuthor; - - const _nonScrollableEditEvents = { - applyChangesToBase: 1, - }; - - for (const eventType of hooks.callAll('aceRegisterNonScrollableEditEvents')) { - _nonScrollableEditEvents[eventType] = 1; - } - - const isScrollableEditEvent = (eventType) => !_nonScrollableEditEvents[eventType]; - - let currentCallStack = null; - - const inCallStack = (type, action) => { - if (disposed) return; - - const newEditEvent = (eventType) => ({ - eventType, - backset: null, - }); - - const submitOldEvent = (evt) => { - if (rep.selStart && rep.selEnd) { - const selStartChar = rep.lines.offsetOfIndex(rep.selStart[0]) + rep.selStart[1]; - const selEndChar = rep.lines.offsetOfIndex(rep.selEnd[0]) + rep.selEnd[1]; - evt.selStart = selStartChar; - evt.selEnd = selEndChar; - evt.selFocusAtStart = rep.selFocusAtStart; - } - if (undoModule.enabled) { - let undoWorked = false; - try { - if (isPadLoading(evt.eventType)) { - undoModule.clearHistory(); - } else if (evt.eventType === 'nonundoable') { - if (evt.changeset) { - undoModule.reportExternalChange(evt.changeset); - } - } else { - undoModule.reportEvent(evt); - } - undoWorked = true; - } finally { - if (!undoWorked) { - undoModule.enabled = false; // for safety - } - } - } - }; - - const startNewEvent = (eventType, dontSubmitOld) => { - const oldEvent = currentCallStack.editEvent; - if (!dontSubmitOld) { - submitOldEvent(oldEvent); - } - currentCallStack.editEvent = newEditEvent(eventType); - return oldEvent; - }; - - currentCallStack = { - type, - docTextChanged: false, - selectionAffected: false, - userChangedSelection: false, - domClean: false, - isUserChange: false, - // is this a "user change" type of call-stack - repChanged: false, - editEvent: newEditEvent(type), - startNewEvent, - }; - let cleanExit = false; - let result; - try { - result = action(); - - hooks.callAll('aceEditEvent', { - callstack: currentCallStack, - editorInfo, - rep, - documentAttributeManager, - }); - - cleanExit = true; - } finally { - const cs = currentCallStack; - if (cleanExit) { - submitOldEvent(cs.editEvent); - if (cs.domClean && cs.type !== 'setup') { - if (cs.selectionAffected) { - updateBrowserSelectionFromRep(); - } - if ((cs.docTextChanged || cs.userChangedSelection) && isScrollableEditEvent(cs.type)) { - scrollSelectionIntoView(); - } - if (cs.docTextChanged && cs.type.indexOf('importText') < 0) { - outsideNotifyDirty(); - } - } - } else if (currentCallStack.type === 'idleWorkTimer') { - idleWorkTimer.atLeast(1000); - } - currentCallStack = null; - } - return result; - }; - editorInfo.ace_inCallStack = inCallStack; - - const inCallStackIfNecessary = (type, action) => { - if (!currentCallStack) { - inCallStack(type, action); - } else { - action(); - } - }; - editorInfo.ace_inCallStackIfNecessary = inCallStackIfNecessary; - - const dispose = () => { - disposed = true; - if (idleWorkTimer) idleWorkTimer.never(); - teardown(); - }; - - const setWraps = (newVal) => { - doesWrap = newVal; - document.body.classList.toggle('doesWrap', doesWrap); - scheduler.setTimeout(() => { - inCallStackIfNecessary('setWraps', () => { - fastIncorp(7); - recreateDOM(); - fixView(); - }); - }, 0); - }; - - const setStyled = (newVal) => { - const oldVal = isStyled; - isStyled = !!newVal; - - if (newVal !== oldVal) { - if (!newVal) { - // clear styles - inCallStackIfNecessary('setStyled', () => { - fastIncorp(12); - const clearStyles = []; - for (const k of Object.keys(STYLE_ATTRIBS)) { - clearStyles.push([k, '']); - } - performDocumentApplyAttributesToCharRange(0, rep.alltext.length, clearStyles); - }); - } - } - }; - - const setTextFace = (face) => { - document.body.style.fontFamily = face; - lineMetricsDiv.style.fontFamily = face; - }; - - const recreateDOM = () => { - // precond: normalized - recolorLinesInRange(0, rep.alltext.length); - }; - - const setEditable = (newVal) => { - isEditable = newVal; - document.body.contentEditable = isEditable ? 'true' : 'false'; - document.body.classList.toggle('static', !isEditable); - }; - - const enforceEditability = () => setEditable(isEditable); - - const importText = (text, undoable, dontProcess) => { - let lines; - if (dontProcess) { - if (text.charAt(text.length - 1) !== '\n') { - throw new Error('new raw text must end with newline'); - } - if (/[\r\t\xa0]/.exec(text)) { - throw new Error('new raw text must not contain CR, tab, or nbsp'); - } - lines = text.substring(0, text.length - 1).split('\n'); - } else { - lines = text.split('\n').map(textify); - } - let newText = '\n'; - if (lines.length > 0) { - newText = `${lines.join('\n')}\n`; - } - - inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => { - setDocText(newText); - }); - - if (dontProcess && rep.alltext !== text) { - throw new Error('mismatch error setting raw text in importText'); - } - }; - - const importAText = (atext, apoolJsonObj, undoable) => { - atext = Changeset.cloneAText(atext); - if (apoolJsonObj) { - const wireApool = (new AttribPool()).fromJsonable(apoolJsonObj); - atext.attribs = Changeset.moveOpsToNewPool(atext.attribs, wireApool, rep.apool); - } - inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => { - setDocAText(atext); - }); - }; - - const setDocAText = (atext) => { - if (atext.text === '') { - /* - * The server is fine with atext.text being an empty string, but the front - * end is not, and crashes. - * - * It is not clear if this is a problem in the server or in the client - * code, and this is a client-side hack fix. The underlying problem needs - * to be investigated. - * - * See for reference: - * - https://github.com/ether/etherpad-lite/issues/3861 - */ - atext.text = '\n'; - } - - fastIncorp(8); - - const oldLen = rep.lines.totalWidth(); - const numLines = rep.lines.length(); - const upToLastLine = rep.lines.offsetOfIndex(numLines - 1); - const lastLineLength = rep.lines.atIndex(numLines - 1).text.length; - const assem = Changeset.smartOpAssembler(); - const o = new Changeset.Op('-'); - o.chars = upToLastLine; - o.lines = numLines - 1; - assem.append(o); - o.chars = lastLineLength; - o.lines = 0; - assem.append(o); - for (const op of Changeset.opsFromAText(atext)) assem.append(op); - const newLen = oldLen + assem.getLengthChange(); - const changeset = Changeset.checkRep( - Changeset.pack(oldLen, newLen, assem.toString(), atext.text.slice(0, -1))); - performDocumentApplyChangeset(changeset); - - performSelectionChange( - [0, rep.lines.atIndex(0).lineMarker], [0, rep.lines.atIndex(0).lineMarker]); - - idleWorkTimer.atMost(100); - - if (rep.alltext !== atext.text) { - throw new Error('mismatch error setting raw text in setDocAText'); - } - }; - - const setDocText = (text) => { - setDocAText(Changeset.makeAText(text)); - }; - - const getDocText = () => { - const alltext = rep.alltext; - let len = alltext.length; - if (len > 0) len--; // final extra newline - return alltext.substring(0, len); - }; - - const exportText = () => { - if (currentCallStack && !currentCallStack.domClean) { - inCallStackIfNecessary('exportText', () => { - fastIncorp(2); - }); - } - return getDocText(); - }; - - const editorChangedSize = () => fixView(); - - const setOnKeyPress = (handler) => { - outsideKeyPress = handler; - }; - - const setOnKeyDown = (handler) => { - outsideKeyDown = handler; - }; - - const setNotifyDirty = (handler) => { - outsideNotifyDirty = handler; - }; - - const CMDS = { - clearauthorship: (prompt) => { - if ((!(rep.selStart && rep.selEnd)) || isCaret()) { - if (prompt) { - prompt(); - } else { - performDocumentApplyAttributesToCharRange(0, rep.alltext.length, [ - ['author', ''], - ]); - } - } else { - setAttributeOnSelection('author', ''); - } - }, - }; - - const execCommand = (cmd, ...args) => { - cmd = cmd.toLowerCase(); - if (CMDS[cmd]) { - inCallStackIfNecessary(cmd, () => { - fastIncorp(9); - CMDS[cmd](...args); - }); - } - }; - - const replaceRange = (start, end, text) => { - inCallStackIfNecessary('replaceRange', () => { - fastIncorp(9); - performDocumentReplaceRange(start, end, text); - }); - }; - - editorInfo.ace_callWithAce = (fn, callStack, normalize) => { - let wrapper = () => fn(editorInfo); - - if (normalize !== undefined) { - const wrapper1 = wrapper; - wrapper = () => { - editorInfo.ace_fastIncorp(9); - wrapper1(); - }; - } - - if (callStack !== undefined) { - return editorInfo.ace_inCallStack(callStack, wrapper); - } else { - return wrapper(); - } - }; - - /** - * This methed exposes a setter for some ace properties - * @param key the name of the parameter - * @param value the value to set to - */ - editorInfo.ace_setProperty = (key, value) => { - // These properties are exposed - const setters = { - wraps: setWraps, - showsauthorcolors: (val) => document.body.classList.toggle('authorColors', !!val), - showsuserselections: (val) => document.body.classList.toggle('userSelections', !!val), - showslinenumbers: (value) => { - hasLineNumbers = !!value; - sideDiv.parentNode.classList.toggle('line-numbers-hidden', !hasLineNumbers); - fixView(); - }, - userauthor: (value) => { - thisAuthor = String(value); - documentAttributeManager.author = thisAuthor; - }, - styled: setStyled, - textface: setTextFace, - rtlistrue: (value) => { - document.body.classList.toggle('rtl', value); - document.body.classList.toggle('ltr', !value); - document.documentElement.dir = value ? 'rtl' : 'ltr'; - }, - }; - - const setter = setters[key.toLowerCase()]; - - // check if setter is present - if (setter !== undefined) { - setter(value); - } - }; - - editorInfo.ace_setBaseText = (txt) => { - changesetTracker.setBaseText(txt); - }; - editorInfo.ace_setBaseAttributedText = (atxt, apoolJsonObj) => { - changesetTracker.setBaseAttributedText(atxt, apoolJsonObj); - }; - editorInfo.ace_applyChangesToBase = (c, optAuthor, apoolJsonObj) => { - changesetTracker.applyChangesToBase(c, optAuthor, apoolJsonObj); - }; - editorInfo.ace_prepareUserChangeset = () => changesetTracker.prepareUserChangeset(); - editorInfo.ace_applyPreparedChangesetToBase = () => { - changesetTracker.applyPreparedChangesetToBase(); - }; - editorInfo.ace_setUserChangeNotificationCallback = (f) => { - changesetTracker.setUserChangeNotificationCallback(f); - }; - editorInfo.ace_setAuthorInfo = (author, info) => { - setAuthorInfo(author, info); - }; - - editorInfo.ace_getDocument = () => document; - - const now = () => Date.now(); - - const newTimeLimit = (ms) => { - const startTime = now(); - let exceededAlready = false; - let printedTrace = false; - const isTimeUp = () => { - if (exceededAlready) { - if ((!printedTrace)) { - printedTrace = true; - } - return true; - } - const elapsed = now() - startTime; - if (elapsed > ms) { - exceededAlready = true; - return true; - } else { - return false; - } - }; - - isTimeUp.elapsed = () => now() - startTime; - return isTimeUp; - }; - - - const makeIdleAction = (func) => { - let scheduledTimeout = null; - let scheduledTime = 0; - - const unschedule = () => { - if (scheduledTimeout) { - scheduler.clearTimeout(scheduledTimeout); - scheduledTimeout = null; - } - }; - - const reschedule = (time) => { - unschedule(); - scheduledTime = time; - let delay = time - now(); - if (delay < 0) delay = 0; - scheduledTimeout = scheduler.setTimeout(callback, delay); - }; - - const callback = () => { - scheduledTimeout = null; - // func may reschedule the action - func(); - }; - - return { - atMost: (ms) => { - const latestTime = now() + ms; - if ((!scheduledTimeout) || scheduledTime > latestTime) { - reschedule(latestTime); - } - }, - // atLeast(ms) will schedule the action if not scheduled yet. - // In other words, "infinity" is replaced by ms, even though - // it is technically larger. - atLeast: (ms) => { - const earliestTime = now() + ms; - if ((!scheduledTimeout) || scheduledTime < earliestTime) { - reschedule(earliestTime); - } - }, - never: () => { - unschedule(); - }, - }; - }; - - const fastIncorp = (n) => { - // normalize but don't do any lexing or anything - incorporateUserChanges(); - }; - editorInfo.ace_fastIncorp = fastIncorp; - - const idleWorkTimer = makeIdleAction(() => { - if (inInternationalComposition) { - // don't do idle input incorporation during international input composition - idleWorkTimer.atLeast(500); - return; - } - - inCallStackIfNecessary('idleWorkTimer', () => { - const isTimeUp = newTimeLimit(250); - - let finishedImportantWork = false; - let finishedWork = false; - - try { - incorporateUserChanges(); - - if (isTimeUp()) return; - - updateLineNumbers(); // update line numbers if any time left - if (isTimeUp()) return; - finishedImportantWork = true; - finishedWork = true; - } finally { - if (finishedWork) { - idleWorkTimer.atMost(1000); - } else if (finishedImportantWork) { - // if we've finished highlighting the view area, - // more highlighting could be counter-productive, - // e.g. if the user just opened a triple-quote and will soon close it. - idleWorkTimer.atMost(500); - } else { - let timeToWait = Math.round(isTimeUp.elapsed() / 2); - if (timeToWait < 100) timeToWait = 100; - idleWorkTimer.atMost(timeToWait); - } - } - }); - }); - - let _nextId = 1; - - const uniqueId = (n) => { - // not actually guaranteed to be unique, e.g. if user copy-pastes - // nodes with ids - const nid = n.id; - if (nid) return nid; - return (n.id = `magicdomid${_nextId++}`); - }; - - - const recolorLinesInRange = (startChar, endChar) => { - if (endChar <= startChar) return; - if (startChar < 0 || startChar >= rep.lines.totalWidth()) return; - let lineEntry = rep.lines.atOffset(startChar); // rounds down to line boundary - let lineStart = rep.lines.offsetOfEntry(lineEntry); - let lineIndex = rep.lines.indexOfEntry(lineEntry); - let selectionNeedsResetting = false; - let firstLine = null; - - // tokenFunc function; accesses current value of lineEntry and curDocChar, - // also mutates curDocChar - const tokenFunc = (tokenText, tokenClass) => { - lineEntry.domInfo.appendSpan(tokenText, tokenClass); - }; - - while (lineEntry && lineStart < endChar) { - const lineEnd = lineStart + lineEntry.width; - lineEntry.domInfo.clearSpans(); - getSpansForLine(lineEntry, tokenFunc, lineStart); - lineEntry.domInfo.finishUpdate(); - - markNodeClean(lineEntry.lineNode); - - if (rep.selStart && rep.selStart[0] === lineIndex || - rep.selEnd && rep.selEnd[0] === lineIndex) { - selectionNeedsResetting = true; - } - - if (firstLine == null) firstLine = lineIndex; - lineStart = lineEnd; - lineEntry = rep.lines.next(lineEntry); - lineIndex++; - } - if (selectionNeedsResetting) { - currentCallStack.selectionAffected = true; - } - }; - - // like getSpansForRange, but for a line, and the func takes (text,class) - // instead of (width,class); excludes the trailing '\n' from - // consideration by func - - - const getSpansForLine = (lineEntry, textAndClassFunc, lineEntryOffsetHint) => { - let lineEntryOffset = lineEntryOffsetHint; - if ((typeof lineEntryOffset) !== 'number') { - lineEntryOffset = rep.lines.offsetOfEntry(lineEntry); - } - const text = lineEntry.text; - if (text.length === 0) { - // allow getLineStyleFilter to set line-div styles - const func = linestylefilter.getLineStyleFilter( - 0, '', textAndClassFunc, rep.apool); - func('', ''); - } else { - let filteredFunc = linestylefilter.getFilterStack(text, textAndClassFunc, browser); - const lineNum = rep.lines.indexOfEntry(lineEntry); - const aline = rep.alines[lineNum]; - filteredFunc = linestylefilter.getLineStyleFilter( - text.length, aline, filteredFunc, rep.apool); - filteredFunc(text, ''); - } - }; - - let observedChanges; - - const clearObservedChanges = () => { - observedChanges = { - cleanNodesNearChanges: {}, - }; - }; - clearObservedChanges(); - - const getCleanNodeByKey = (key) => { - let n = document.getElementById(key); - // copying and pasting can lead to duplicate ids - while (n && isNodeDirty(n)) { - n.id = ''; - n = document.getElementById(key); - } - return n; - }; - - const observeChangesAroundNode = (node) => { - // Around this top-level DOM node, look for changes to the document - // (from how it looks in our representation) and record them in a way - // that can be used to "normalize" the document (apply the changes to our - // representation, and put the DOM in a canonical form). - let cleanNode; - let hasAdjacentDirtyness; - if (!isNodeDirty(node)) { - cleanNode = node; - const prevSib = cleanNode.previousSibling; - const nextSib = cleanNode.nextSibling; - hasAdjacentDirtyness = ((prevSib && isNodeDirty(prevSib)) || - (nextSib && isNodeDirty(nextSib))); - } else { - // node is dirty, look for clean node above - let upNode = node.previousSibling; - while (upNode && isNodeDirty(upNode)) { - upNode = upNode.previousSibling; - } - if (upNode) { - cleanNode = upNode; - } else { - let downNode = node.nextSibling; - while (downNode && isNodeDirty(downNode)) { - downNode = downNode.nextSibling; - } - if (downNode) { - cleanNode = downNode; - } - } - if (!cleanNode) { - // Couldn't find any adjacent clean nodes! - // Since top and bottom of doc is dirty, the dirty area will be detected. - return; - } - hasAdjacentDirtyness = true; - } - - if (hasAdjacentDirtyness) { - // previous or next line is dirty - observedChanges.cleanNodesNearChanges[`$${uniqueId(cleanNode)}`] = true; - } else { - // next and prev lines are clean (if they exist) - const lineKey = uniqueId(cleanNode); - const prevSib = cleanNode.previousSibling; - const nextSib = cleanNode.nextSibling; - const actualPrevKey = ((prevSib && uniqueId(prevSib)) || null); - const actualNextKey = ((nextSib && uniqueId(nextSib)) || null); - const repPrevEntry = rep.lines.prev(rep.lines.atKey(lineKey)); - const repNextEntry = rep.lines.next(rep.lines.atKey(lineKey)); - const repPrevKey = ((repPrevEntry && repPrevEntry.key) || null); - const repNextKey = ((repNextEntry && repNextEntry.key) || null); - if (actualPrevKey !== repPrevKey || actualNextKey !== repNextKey) { - observedChanges.cleanNodesNearChanges[`$${uniqueId(cleanNode)}`] = true; - } - } - }; - - const observeChangesAroundSelection = () => { - if (currentCallStack.observedSelection) return; - currentCallStack.observedSelection = true; - - const selection = getSelection(); - - if (selection) { - const node1 = topLevel(selection.startPoint.node); - const node2 = topLevel(selection.endPoint.node); - if (node1) observeChangesAroundNode(node1); - if (node2 && node1 !== node2) { - observeChangesAroundNode(node2); - } - } - }; - - const observeSuspiciousNodes = () => { - // inspired by Firefox bug #473255, where pasting formatted text - // causes the cursor to jump away, making the new HTML never found. - if (document.body.getElementsByTagName) { - const elts = document.body.getElementsByTagName('style'); - for (const elt of elts) { - const n = topLevel(elt); - if (n && n.parentNode === document.body) { - observeChangesAroundNode(n); - } - } - } - }; - - const incorporateUserChanges = () => { - if (currentCallStack.domClean) return false; - - currentCallStack.isUserChange = true; - - if (DEBUG && window.DONT_INCORP || window.DEBUG_DONT_INCORP) return false; - - // returns true if dom changes were made - if (!document.body.firstChild) { - document.body.innerHTML = '
      '; - } - - observeChangesAroundSelection(); - observeSuspiciousNodes(); - let dirtyRanges = getDirtyRanges(); - let dirtyRangesCheckOut = true; - let j = 0; - let a, b; - let scrollToTheLeftNeeded = false; - - while (j < dirtyRanges.length) { - a = dirtyRanges[j][0]; - b = dirtyRanges[j][1]; - if (!((a === 0 || getCleanNodeByKey(rep.lines.atIndex(a - 1).key)) && - (b === rep.lines.length() || getCleanNodeByKey(rep.lines.atIndex(b).key)))) { - dirtyRangesCheckOut = false; - break; - } - j++; - } - if (!dirtyRangesCheckOut) { - for (const bodyNode of document.body.childNodes) { - if ((bodyNode.tagName) && ((!bodyNode.id) || (!rep.lines.containsKey(bodyNode.id)))) { - observeChangesAroundNode(bodyNode); - } - } - dirtyRanges = getDirtyRanges(); - } - - clearObservedChanges(); - - const selection = getSelection(); - - let selStart, selEnd; // each one, if truthy, has [line,char] needed to set selection - let i = 0; - const splicesToDo = []; - let netNumLinesChangeSoFar = 0; - const toDeleteAtEnd = []; - const domInsertsNeeded = []; // each entry is [nodeToInsertAfter, [info1, info2, ...]] - while (i < dirtyRanges.length) { - const range = dirtyRanges[i]; - a = range[0]; - b = range[1]; - let firstDirtyNode = (((a === 0) && document.body.firstChild) || - getCleanNodeByKey(rep.lines.atIndex(a - 1).key).nextSibling); - firstDirtyNode = (firstDirtyNode && isNodeDirty(firstDirtyNode) && firstDirtyNode); - - let lastDirtyNode = (((b === rep.lines.length()) && document.body.lastChild) || - getCleanNodeByKey(rep.lines.atIndex(b).key).previousSibling); - - lastDirtyNode = (lastDirtyNode && isNodeDirty(lastDirtyNode) && lastDirtyNode); - if (firstDirtyNode && lastDirtyNode) { - const cc = makeContentCollector(isStyled, browser, rep.apool, className2Author); - cc.notifySelection(selection); - const dirtyNodes = []; - for (let n = firstDirtyNode; n && - !(n.previousSibling && n.previousSibling === lastDirtyNode); - n = n.nextSibling) { - cc.collectContent(n); - dirtyNodes.push(n); - } - cc.notifyNextNode(lastDirtyNode.nextSibling); - let lines = cc.getLines(); - if ((lines.length <= 1 || lines[lines.length - 1] !== '') && lastDirtyNode.nextSibling) { - // dirty region doesn't currently end a line, even taking the following node - // (or lack of node) into account, so include the following clean node. - // It could be SPAN or a DIV; basically this is any case where the contentCollector - // decides it isn't done. - // Note that this clean node might need to be there for the next dirty range. - b++; - const cleanLine = lastDirtyNode.nextSibling; - cc.collectContent(cleanLine); - toDeleteAtEnd.push(cleanLine); - cc.notifyNextNode(cleanLine.nextSibling); - } - - const ccData = cc.finish(); - const ss = ccData.selStart; - const se = ccData.selEnd; - lines = ccData.lines; - const lineAttribs = ccData.lineAttribs; - const linesWrapped = ccData.linesWrapped; - - if (linesWrapped > 0) { - // Chrome decides in its infinite wisdom that it's okay to put the browser's visisble - // window in the middle of the span. An outcome of this is that the first chars of the - // string are no longer visible to the user.. Yay chrome.. Move the browser's visible area - // to the left hand side of the span. Firefox isn't quite so bad, but it's still pretty - // quirky. - scrollToTheLeftNeeded = true; - } - - if (ss[0] >= 0) selStart = [ss[0] + a + netNumLinesChangeSoFar, ss[1]]; - if (se[0] >= 0) selEnd = [se[0] + a + netNumLinesChangeSoFar, se[1]]; - - const entries = []; - const nodeToAddAfter = lastDirtyNode; - const lineNodeInfos = []; - for (const lineString of lines) { - const newEntry = createDomLineEntry(lineString); - entries.push(newEntry); - lineNodeInfos.push(newEntry.domInfo); - } - domInsertsNeeded.push([nodeToAddAfter, lineNodeInfos]); - for (const n of dirtyNodes) toDeleteAtEnd.push(n); - const spliceHints = {}; - if (selStart) spliceHints.selStart = selStart; - if (selEnd) spliceHints.selEnd = selEnd; - splicesToDo.push([a + netNumLinesChangeSoFar, b - a, entries, lineAttribs, spliceHints]); - netNumLinesChangeSoFar += (lines.length - (b - a)); - } else if (b > a) { - splicesToDo.push([a + netNumLinesChangeSoFar, b - a, [], []]); - } - i++; - } - - const domChanges = (splicesToDo.length > 0); - - for (const splice of splicesToDo) doIncorpLineSplice(...splice); - for (const ins of domInsertsNeeded) insertDomLines(...ins); - for (const n of toDeleteAtEnd) n.remove(); - - // needed to stop chrome from breaking the ui when long strings without spaces are pasted - if (scrollToTheLeftNeeded) { - $('#innerdocbody').scrollLeft(0); - } - - // if the nodes that define the selection weren't encountered during - // content collection, figure out where those nodes are now. - if (selection && !selStart) { - const selStartFromHook = hooks.callAll('aceStartLineAndCharForPoint', { - callstack: currentCallStack, - editorInfo, - rep, - root: document.body, - point: selection.startPoint, - documentAttributeManager, - }); - selStart = (selStartFromHook == null || selStartFromHook.length === 0) - ? getLineAndCharForPoint(selection.startPoint) : selStartFromHook; - } - if (selection && !selEnd) { - const selEndFromHook = hooks.callAll('aceEndLineAndCharForPoint', { - callstack: currentCallStack, - editorInfo, - rep, - root: document.body, - point: selection.endPoint, - documentAttributeManager, - }); - selEnd = (selEndFromHook == null || - selEndFromHook.length === 0) - ? getLineAndCharForPoint(selection.endPoint) : selEndFromHook; - } - - // selection from content collection can, in various ways, extend past final - // BR in firefox DOM, so cap the line - const numLines = rep.lines.length(); - if (selStart && selStart[0] >= numLines) { - selStart[0] = numLines - 1; - selStart[1] = rep.lines.atIndex(selStart[0]).text.length; - } - if (selEnd && selEnd[0] >= numLines) { - selEnd[0] = numLines - 1; - selEnd[1] = rep.lines.atIndex(selEnd[0]).text.length; - } - - // update rep if we have a new selection - // NOTE: IE loses the selection when you click stuff in e.g. the - // editbar, so removing the selection when it's lost is not a good - // idea. - if (selection) repSelectionChange(selStart, selEnd, selection && selection.focusAtStart); - // update browser selection - if (selection && (domChanges || isCaret())) { - // if no DOM changes (not this case), want to treat range selection delicately, - // e.g. in IE not lose which end of the selection is the focus/anchor; - // on the other hand, we may have just noticed a press of PageUp/PageDown - currentCallStack.selectionAffected = true; - } - - currentCallStack.domClean = true; - - fixView(); - - return domChanges; - }; - - const STYLE_ATTRIBS = { - bold: true, - italic: true, - underline: true, - strikethrough: true, - list: true, - }; - - const isStyleAttribute = (aname) => !!STYLE_ATTRIBS[aname]; - - const isDefaultLineAttribute = - (aname) => AttributeManager.DEFAULT_LINE_ATTRIBUTES.indexOf(aname) !== -1; - - const insertDomLines = (nodeToAddAfter, infoStructs) => { - let lastEntry; - let lineStartOffset; - for (const info of infoStructs) { - const node = info.node; - const key = uniqueId(node); - let entry; - if (lastEntry) { - // optimization to avoid recalculation - const next = rep.lines.next(lastEntry); - if (next && next.key === key) { - entry = next; - lineStartOffset += lastEntry.width; - } - } - if (!entry) { - entry = rep.lines.atKey(key); - lineStartOffset = rep.lines.offsetOfKey(key); - } - lastEntry = entry; - getSpansForLine(entry, (tokenText, tokenClass) => { - info.appendSpan(tokenText, tokenClass); - }, lineStartOffset); - info.prepareForAdd(); - entry.lineMarker = info.lineMarker; - if (!nodeToAddAfter) { - document.body.insertBefore(node, document.body.firstChild); - } else { - document.body.insertBefore(node, nodeToAddAfter.nextSibling); - } - nodeToAddAfter = node; - info.notifyAdded(); - markNodeClean(node); - } - }; - - const isCaret = () => (rep.selStart && rep.selEnd && - rep.selStart[0] === rep.selEnd[0] && rep.selStart[1] === rep.selEnd[1]); - editorInfo.ace_isCaret = isCaret; - - // prereq: isCaret() - const caretLine = () => rep.selStart[0]; - - editorInfo.ace_caretLine = caretLine; - - const caretColumn = () => rep.selStart[1]; - - editorInfo.ace_caretColumn = caretColumn; - - const caretDocChar = () => rep.lines.offsetOfIndex(caretLine()) + caretColumn(); - - editorInfo.ace_caretDocChar = caretDocChar; - - const handleReturnIndentation = () => { - // on return, indent to level of previous line - if (isCaret() && caretColumn() === 0 && caretLine() > 0) { - const lineNum = caretLine(); - const thisLine = rep.lines.atIndex(lineNum); - const prevLine = rep.lines.prev(thisLine); - const prevLineText = prevLine.text; - let theIndent = /^ *(?:)/.exec(prevLineText)[0]; - const shouldIndent = parent.parent.clientVars.indentationOnNewLine; - if (shouldIndent && /[[(:{]\s*$/.exec(prevLineText)) { - theIndent += THE_TAB; - } - const cs = Changeset.builder(rep.lines.totalWidth()).keep( - rep.lines.offsetOfIndex(lineNum), lineNum).insert( - theIndent, [ - ['author', thisAuthor], - ], rep.apool).toString(); - performDocumentApplyChangeset(cs); - performSelectionChange([lineNum, theIndent.length], [lineNum, theIndent.length]); - } - }; - - const getPointForLineAndChar = (lineAndChar) => { - const line = lineAndChar[0]; - let charsLeft = lineAndChar[1]; - const lineEntry = rep.lines.atIndex(line); - charsLeft -= lineEntry.lineMarker; - if (charsLeft < 0) { - charsLeft = 0; - } - const lineNode = lineEntry.lineNode; - let n = lineNode; - let after = false; - if (charsLeft === 0) { - return { - node: lineNode, - index: 0, - maxIndex: 1, - }; - } - while (!(n === lineNode && after)) { - if (after) { - if (n.nextSibling) { - n = n.nextSibling; - after = false; - } else { n = n.parentNode; } - } else if (isNodeText(n)) { - const len = n.nodeValue.length; - if (charsLeft <= len) { - return { - node: n, - index: charsLeft, - maxIndex: len, - }; - } - charsLeft -= len; - after = true; - } else if (n.firstChild) { n = n.firstChild; } else { after = true; } - } - return { - node: lineNode, - index: 1, - maxIndex: 1, - }; - }; - - const nodeText = (n) => n.textContent || n.nodeValue || ''; - - const getLineAndCharForPoint = (point) => { - // Turn DOM node selection into [line,char] selection. - // This method has to work when the DOM is not pristine, - // assuming the point is not in a dirty node. - if (point.node === document.body) { - if (point.index === 0) { - return [0, 0]; - } else { - const N = rep.lines.length(); - const ln = rep.lines.atIndex(N - 1); - return [N - 1, ln.text.length]; - } - } else { - let n = point.node; - let col = 0; - // if this part fails, it probably means the selection node - // was dirty, and we didn't see it when collecting dirty nodes. - if (isNodeText(n)) { - col = point.index; - } else if (point.index > 0) { - col = nodeText(n).length; - } - let parNode, prevSib; - while ((parNode = n.parentNode) !== document.body) { - if ((prevSib = n.previousSibling)) { - n = prevSib; - col += nodeText(n).length; - } else { - n = parNode; - } - } - if (n.firstChild && isBlockElement(n.firstChild)) { - col += 1; // lineMarker - } - const lineEntry = rep.lines.atKey(n.id); - const lineNum = rep.lines.indexOfEntry(lineEntry); - return [lineNum, col]; - } - }; - editorInfo.ace_getLineAndCharForPoint = getLineAndCharForPoint; - - const createDomLineEntry = (lineString) => { - const info = doCreateDomLine(lineString.length > 0); - const newNode = info.node; - return { - key: uniqueId(newNode), - text: lineString, - lineNode: newNode, - domInfo: info, - lineMarker: 0, - }; - }; - - const performDocumentApplyChangeset = (changes, insertsAfterSelection) => { - const domAndRepSplice = (startLine, deleteCount, newLineStrings) => { - const keysToDelete = []; - if (deleteCount > 0) { - let entryToDelete = rep.lines.atIndex(startLine); - for (let i = 0; i < deleteCount; i++) { - keysToDelete.push(entryToDelete.key); - entryToDelete = rep.lines.next(entryToDelete); - } - } - - const lineEntries = newLineStrings.map(createDomLineEntry); - - doRepLineSplice(startLine, deleteCount, lineEntries); - - let nodeToAddAfter; - if (startLine > 0) { - nodeToAddAfter = getCleanNodeByKey(rep.lines.atIndex(startLine - 1).key); - } else { nodeToAddAfter = null; } - - insertDomLines(nodeToAddAfter, lineEntries.map((entry) => entry.domInfo)); - - for (const k of keysToDelete) { - const n = document.getElementById(k); - n.parentNode.removeChild(n); - } - - if ( - (rep.selStart && - rep.selStart[0] >= startLine && - rep.selStart[0] <= startLine + deleteCount) || - (rep.selEnd && rep.selEnd[0] >= startLine && rep.selEnd[0] <= startLine + deleteCount)) { - currentCallStack.selectionAffected = true; - } - }; - - doRepApplyChangeset(changes, insertsAfterSelection); - - let requiredSelectionSetting = null; - if (rep.selStart && rep.selEnd) { - const selStartChar = rep.lines.offsetOfIndex(rep.selStart[0]) + rep.selStart[1]; - const selEndChar = rep.lines.offsetOfIndex(rep.selEnd[0]) + rep.selEnd[1]; - const result = - Changeset.characterRangeFollow(changes, selStartChar, selEndChar, insertsAfterSelection); - requiredSelectionSetting = [result[0], result[1], rep.selFocusAtStart]; - } - - const linesMutatee = { - splice: (start, numRemoved, ...args) => { - domAndRepSplice(start, numRemoved, args.map((s) => s.slice(0, -1))); - }, - get: (i) => `${rep.lines.atIndex(i).text}\n`, - length: () => rep.lines.length(), - }; - - Changeset.mutateTextLines(changes, linesMutatee); - - if (requiredSelectionSetting) { - performSelectionChange( - lineAndColumnFromChar(requiredSelectionSetting[0]), - lineAndColumnFromChar(requiredSelectionSetting[1]), - requiredSelectionSetting[2]); - } - }; - - const doRepApplyChangeset = (changes, insertsAfterSelection) => { - Changeset.checkRep(changes); - - if (Changeset.oldLen(changes) !== rep.alltext.length) { - const errMsg = `${Changeset.oldLen(changes)}/${rep.alltext.length}`; - throw new Error(`doRepApplyChangeset length mismatch: ${errMsg}`); - } - - const editEvent = currentCallStack.editEvent; - if (editEvent.eventType === 'nonundoable') { - if (!editEvent.changeset) { - editEvent.changeset = changes; - } else { - editEvent.changeset = Changeset.compose(editEvent.changeset, changes, rep.apool); - } - } else { - const inverseChangeset = Changeset.inverse(changes, { - get: (i) => `${rep.lines.atIndex(i).text}\n`, - length: () => rep.lines.length(), - }, rep.alines, rep.apool); - - if (!editEvent.backset) { - editEvent.backset = inverseChangeset; - } else { - editEvent.backset = Changeset.compose(inverseChangeset, editEvent.backset, rep.apool); - } - } - - Changeset.mutateAttributionLines(changes, rep.alines, rep.apool); - - if (changesetTracker.isTracking()) { - changesetTracker.composeUserChangeset(changes); - } - }; - - /** - * Converts the position of a char (index in String) into a [row, col] tuple - */ - const lineAndColumnFromChar = (x) => { - const lineEntry = rep.lines.atOffset(x); - const lineStart = rep.lines.offsetOfEntry(lineEntry); - const lineNum = rep.lines.indexOfEntry(lineEntry); - return [lineNum, x - lineStart]; - }; - - const performDocumentReplaceCharRange = (startChar, endChar, newText) => { - if (startChar === endChar && newText.length === 0) { - return; - } - // Requires that the replacement preserve the property that the - // internal document text ends in a newline. Given this, we - // rewrite the splice so that it doesn't touch the very last - // char of the document. - if (endChar === rep.alltext.length) { - if (startChar === endChar) { - // an insert at end - startChar--; - endChar--; - newText = `\n${newText.substring(0, newText.length - 1)}`; - } else if (newText.length === 0) { - // a delete at end - startChar--; - endChar--; - } else { - // a replace at end - endChar--; - newText = newText.substring(0, newText.length - 1); - } - } - performDocumentReplaceRange( - lineAndColumnFromChar(startChar), lineAndColumnFromChar(endChar), newText); - }; - - const performDocumentApplyAttributesToCharRange = (start, end, attribs) => { - end = Math.min(end, rep.alltext.length - 1); - documentAttributeManager.setAttributesOnRange( - lineAndColumnFromChar(start), lineAndColumnFromChar(end), attribs); - }; - - editorInfo.ace_performDocumentApplyAttributesToCharRange = - performDocumentApplyAttributesToCharRange; - - const setAttributeOnSelection = (attributeName, attributeValue) => { - if (!(rep.selStart && rep.selEnd)) return; - - documentAttributeManager.setAttributesOnRange(rep.selStart, rep.selEnd, [ - [attributeName, attributeValue], - ]); - }; - editorInfo.ace_setAttributeOnSelection = setAttributeOnSelection; - - const getAttributeOnSelection = (attributeName, prevChar) => { - if (!(rep.selStart && rep.selEnd)) return; - const isNotSelection = (rep.selStart[0] === rep.selEnd[0] && rep.selEnd[1] === rep.selStart[1]); - if (isNotSelection) { - if (prevChar) { - // If it's not the start of the line - if (rep.selStart[1] !== 0) { - rep.selStart[1]--; - } - } - } - - const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString(); - const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`); - const hasIt = (attribs) => withItRegex.test(attribs); - - const rangeHasAttrib = (selStart, selEnd) => { - // if range is collapsed -> no attribs in range - if (selStart[1] === selEnd[1] && selStart[0] === selEnd[0]) return false; - - if (selStart[0] !== selEnd[0]) { // -> More than one line selected - let hasAttrib = true; - - // from selStart to the end of the first line - hasAttrib = hasAttrib && - rangeHasAttrib(selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]); - - // for all lines in between - for (let n = selStart[0] + 1; n < selEnd[0]; n++) { - hasAttrib = hasAttrib && rangeHasAttrib([n, 0], [n, rep.lines.atIndex(n).text.length]); - } - - // for the last, potentially partial, line - hasAttrib = hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]); - - return hasAttrib; - } - - // Logic tells us we now have a range on a single line - - const lineNum = selStart[0]; - const start = selStart[1]; - const end = selEnd[1]; - let hasAttrib = true; - - let indexIntoLine = 0; - for (const op of Changeset.deserializeOps(rep.alines[lineNum])) { - const opStartInLine = indexIntoLine; - const opEndInLine = opStartInLine + op.chars; - if (!hasIt(op.attribs)) { - // does op overlap selection? - if (!(opEndInLine <= start || opStartInLine >= end)) { - // since it's overlapping but hasn't got the attrib -> range hasn't got it - hasAttrib = false; - break; - } - } - indexIntoLine = opEndInLine; - } - - return hasAttrib; - }; - return rangeHasAttrib(rep.selStart, rep.selEnd); - }; - - editorInfo.ace_getAttributeOnSelection = getAttributeOnSelection; - - const toggleAttributeOnSelection = (attributeName) => { - if (!(rep.selStart && rep.selEnd)) return; - - let selectionAllHasIt = true; - const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString(); - const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`); - - const hasIt = (attribs) => withItRegex.test(attribs); - - const selStartLine = rep.selStart[0]; - const selEndLine = rep.selEnd[0]; - for (let n = selStartLine; n <= selEndLine; n++) { - let indexIntoLine = 0; - let selectionStartInLine = 0; - if (documentAttributeManager.lineHasMarker(n)) { - selectionStartInLine = 1; // ignore "*" used as line marker - } - let selectionEndInLine = rep.lines.atIndex(n).text.length; // exclude newline - if (n === selStartLine) { - selectionStartInLine = rep.selStart[1]; - } - if (n === selEndLine) { - selectionEndInLine = rep.selEnd[1]; - } - for (const op of Changeset.deserializeOps(rep.alines[n])) { - const opStartInLine = indexIntoLine; - const opEndInLine = opStartInLine + op.chars; - if (!hasIt(op.attribs)) { - // does op overlap selection? - if (!(opEndInLine <= selectionStartInLine || opStartInLine >= selectionEndInLine)) { - selectionAllHasIt = false; - break; - } - } - indexIntoLine = opEndInLine; - } - if (!selectionAllHasIt) { - break; - } - } - - - const attributeValue = selectionAllHasIt ? '' : 'true'; - documentAttributeManager.setAttributesOnRange( - rep.selStart, rep.selEnd, [[attributeName, attributeValue]]); - if (attribIsFormattingStyle(attributeName)) { - updateStyleButtonState(attributeName, !selectionAllHasIt); // italic, bold, ... - } - }; - editorInfo.ace_toggleAttributeOnSelection = toggleAttributeOnSelection; - - const performDocumentReplaceSelection = (newText) => { - if (!(rep.selStart && rep.selEnd)) return; - performDocumentReplaceRange(rep.selStart, rep.selEnd, newText); - }; - - // Change the abstract representation of the document to have a different set of lines. - // Must be called after rep.alltext is set. - const doRepLineSplice = (startLine, deleteCount, newLineEntries) => { - for (const entry of newLineEntries) entry.width = entry.text.length + 1; - - const startOldChar = rep.lines.offsetOfIndex(startLine); - const endOldChar = rep.lines.offsetOfIndex(startLine + deleteCount); - - rep.lines.splice(startLine, deleteCount, newLineEntries); - currentCallStack.docTextChanged = true; - currentCallStack.repChanged = true; - const newText = newLineEntries.map((e) => `${e.text}\n`).join(''); - - rep.alltext = rep.alltext.substring(0, startOldChar) + - newText + rep.alltext.substring(endOldChar, rep.alltext.length); - }; - - const doIncorpLineSplice = (startLine, deleteCount, newLineEntries, lineAttribs, hints) => { - const startOldChar = rep.lines.offsetOfIndex(startLine); - const endOldChar = rep.lines.offsetOfIndex(startLine + deleteCount); - - const oldRegionStart = rep.lines.offsetOfIndex(startLine); - - let selStartHintChar, selEndHintChar; - if (hints && hints.selStart) { - selStartHintChar = - rep.lines.offsetOfIndex(hints.selStart[0]) + hints.selStart[1] - oldRegionStart; - } - if (hints && hints.selEnd) { - selEndHintChar = rep.lines.offsetOfIndex(hints.selEnd[0]) + hints.selEnd[1] - oldRegionStart; - } - - const newText = newLineEntries.map((e) => `${e.text}\n`).join(''); - const oldText = rep.alltext.substring(startOldChar, endOldChar); - const oldAttribs = rep.alines.slice(startLine, startLine + deleteCount).join(''); - const newAttribs = `${lineAttribs.join('|1+1')}|1+1`; // not valid in a changeset - const analysis = - analyzeChange(oldText, newText, oldAttribs, newAttribs, selStartHintChar, selEndHintChar); - const commonStart = analysis[0]; - let commonEnd = analysis[1]; - let shortOldText = oldText.substring(commonStart, oldText.length - commonEnd); - let shortNewText = newText.substring(commonStart, newText.length - commonEnd); - let spliceStart = startOldChar + commonStart; - let spliceEnd = endOldChar - commonEnd; - let shiftFinalNewlineToBeforeNewText = false; - - // adjust the splice to not involve the final newline of the document; - // be very defensive - if (shortOldText.charAt(shortOldText.length - 1) === '\n' && - shortNewText.charAt(shortNewText.length - 1) === '\n') { - // replacing text that ends in newline with text that also ends in newline - // (still, after analysis, somehow) - shortOldText = shortOldText.slice(0, -1); - shortNewText = shortNewText.slice(0, -1); - spliceEnd--; - commonEnd++; - } - if (shortOldText.length === 0 && - spliceStart === rep.alltext.length && - shortNewText.length > 0) { - // inserting after final newline, bad - spliceStart--; - spliceEnd--; - shortNewText = `\n${shortNewText.slice(0, -1)}`; - shiftFinalNewlineToBeforeNewText = true; - } - if (spliceEnd === rep.alltext.length && - shortOldText.length > 0 && - shortNewText.length === 0) { - // deletion at end of rep.alltext - if (rep.alltext.charAt(spliceStart - 1) === '\n') { - // (if not then what the heck? it will definitely lead - // to a rep.alltext without a final newline) - spliceStart--; - spliceEnd--; - } - } - - if (!(shortOldText.length === 0 && shortNewText.length === 0)) { - const oldDocText = rep.alltext; - const oldLen = oldDocText.length; - - const spliceStartLine = rep.lines.indexOfOffset(spliceStart); - const spliceStartLineStart = rep.lines.offsetOfIndex(spliceStartLine); - - const startBuilder = () => { - const builder = Changeset.builder(oldLen); - builder.keep(spliceStartLineStart, spliceStartLine); - builder.keep(spliceStart - spliceStartLineStart); - return builder; - }; - - const eachAttribRun = (attribs, func /* (startInNewText, endInNewText, attribs)*/) => { - let textIndex = 0; - const newTextStart = commonStart; - const newTextEnd = newText.length - commonEnd - (shiftFinalNewlineToBeforeNewText ? 1 : 0); - for (const op of Changeset.deserializeOps(attribs)) { - const nextIndex = textIndex + op.chars; - if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) { - func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs); - } - textIndex = nextIndex; - } - }; - - const justApplyStyles = (shortNewText === shortOldText); - let theChangeset; - - if (justApplyStyles) { - // create changeset that clears the incorporated styles on - // the existing text. we compose this with the - // changeset the applies the styles found in the DOM. - // This allows us to incorporate, e.g., Safari's native "unbold". - const incorpedAttribClearer = cachedStrFunc( - (oldAtts) => Changeset.mapAttribNumbers(oldAtts, (n) => { - const k = rep.apool.getAttribKey(n); - if (isStyleAttribute(k)) { - return rep.apool.putAttrib([k, '']); - } - return false; - })); - - const builder1 = startBuilder(); - if (shiftFinalNewlineToBeforeNewText) { - builder1.keep(1, 1); - } - eachAttribRun(oldAttribs, (start, end, attribs) => { - builder1.keepText(newText.substring(start, end), incorpedAttribClearer(attribs)); - }); - const clearer = builder1.toString(); - - const builder2 = startBuilder(); - if (shiftFinalNewlineToBeforeNewText) { - builder2.keep(1, 1); - } - eachAttribRun(newAttribs, (start, end, attribs) => { - builder2.keepText(newText.substring(start, end), attribs); - }); - const styler = builder2.toString(); - - theChangeset = Changeset.compose(clearer, styler, rep.apool); - } else { - const builder = startBuilder(); - - const spliceEndLine = rep.lines.indexOfOffset(spliceEnd); - const spliceEndLineStart = rep.lines.offsetOfIndex(spliceEndLine); - if (spliceEndLineStart > spliceStart) { - builder.remove(spliceEndLineStart - spliceStart, spliceEndLine - spliceStartLine); - builder.remove(spliceEnd - spliceEndLineStart); - } else { - builder.remove(spliceEnd - spliceStart); - } - - let isNewTextMultiauthor = false; - const authorizer = cachedStrFunc((oldAtts) => { - const attribs = AttributeMap.fromString(oldAtts, rep.apool); - if (!isNewTextMultiauthor || !attribs.has('author')) attribs.set('author', thisAuthor); - return attribs.toString(); - }); - - let foundDomAuthor = ''; - eachAttribRun(newAttribs, (start, end, attribs) => { - const a = AttributeMap.fromString(attribs, rep.apool).get('author'); - if (a && a !== foundDomAuthor) { - if (!foundDomAuthor) { - foundDomAuthor = a; - } else { - isNewTextMultiauthor = true; // multiple authors in DOM! - } - } - }); - - if (shiftFinalNewlineToBeforeNewText) { - builder.insert('\n', authorizer('')); - } - - eachAttribRun(newAttribs, (start, end, attribs) => { - builder.insert(newText.substring(start, end), authorizer(attribs)); - }); - theChangeset = builder.toString(); - } - - doRepApplyChangeset(theChangeset); - } - - // do this no matter what, because we need to get the right - // line keys into the rep. - doRepLineSplice(startLine, deleteCount, newLineEntries); - }; - - const cachedStrFunc = (func) => { - const cache = {}; - return (s) => { - if (!cache[s]) { - cache[s] = func(s); - } - return cache[s]; - }; - }; - - const analyzeChange = ( - oldText, newText, oldAttribs, newAttribs, optSelStartHint, optSelEndHint) => { - // we need to take into account both the styles attributes & attributes defined by - // the plugins, so basically we can ignore only the default line attribs used by - // Etherpad - const incorpedAttribFilter = (anum) => !isDefaultLineAttribute(rep.apool.getAttribKey(anum)); - - const attribRuns = (attribs) => { - const lengs = []; - const atts = []; - for (const op of Changeset.deserializeOps(attribs)) { - lengs.push(op.chars); - atts.push(op.attribs); - } - return [lengs, atts]; - }; - - const attribIterator = (runs, backward) => { - const lengs = runs[0]; - const atts = runs[1]; - let i = (backward ? lengs.length - 1 : 0); - let j = 0; - const next = () => { - while (j >= lengs[i]) { - if (backward) i--; - else i++; - j = 0; - } - const a = atts[i]; - j++; - return a; - }; - return next; - }; - - const oldLen = oldText.length; - const newLen = newText.length; - const minLen = Math.min(oldLen, newLen); - - const oldARuns = attribRuns(Changeset.filterAttribNumbers(oldAttribs, incorpedAttribFilter)); - const newARuns = attribRuns(Changeset.filterAttribNumbers(newAttribs, incorpedAttribFilter)); - - let commonStart = 0; - const oldStartIter = attribIterator(oldARuns, false); - const newStartIter = attribIterator(newARuns, false); - while (commonStart < minLen) { - if (oldText.charAt(commonStart) === newText.charAt(commonStart) && - oldStartIter() === newStartIter()) { - commonStart++; - } else { break; } - } - - let commonEnd = 0; - const oldEndIter = attribIterator(oldARuns, true); - const newEndIter = attribIterator(newARuns, true); - while (commonEnd < minLen) { - if (commonEnd === 0) { - // assume newline in common - oldEndIter(); - newEndIter(); - commonEnd++; - } else if ( - oldText.charAt(oldLen - 1 - commonEnd) === newText.charAt(newLen - 1 - commonEnd) && - oldEndIter() === newEndIter()) { - commonEnd++; - } else { break; } - } - - let hintedCommonEnd = -1; - if ((typeof optSelEndHint) === 'number') { - hintedCommonEnd = newLen - optSelEndHint; - } - - - if (commonStart + commonEnd > oldLen) { - // ambiguous insertion - const minCommonEnd = oldLen - commonStart; - const maxCommonEnd = commonEnd; - if (hintedCommonEnd >= minCommonEnd && hintedCommonEnd <= maxCommonEnd) { - commonEnd = hintedCommonEnd; - } else { - commonEnd = minCommonEnd; - } - commonStart = oldLen - commonEnd; - } - if (commonStart + commonEnd > newLen) { - // ambiguous deletion - const minCommonEnd = newLen - commonStart; - const maxCommonEnd = commonEnd; - if (hintedCommonEnd >= minCommonEnd && hintedCommonEnd <= maxCommonEnd) { - commonEnd = hintedCommonEnd; - } else { - commonEnd = minCommonEnd; - } - commonStart = newLen - commonEnd; - } - - return [commonStart, commonEnd]; - }; - - const equalLineAndChars = (a, b) => { - if (!a) return !b; - if (!b) return !a; - return (a[0] === b[0] && a[1] === b[1]); - }; - - const performSelectionChange = (selectStart, selectEnd, focusAtStart) => { - if (repSelectionChange(selectStart, selectEnd, focusAtStart)) { - currentCallStack.selectionAffected = true; - } - }; - editorInfo.ace_performSelectionChange = performSelectionChange; - - // Change the abstract representation of the document to have a different selection. - // Should not rely on the line representation. Should not affect the DOM. - - - const repSelectionChange = (selectStart, selectEnd, focusAtStart) => { - focusAtStart = !!focusAtStart; - - const newSelFocusAtStart = (focusAtStart && ((!selectStart) || - (!selectEnd) || - (selectStart[0] !== selectEnd[0]) || - (selectStart[1] !== selectEnd[1]))); - - if ((!equalLineAndChars(rep.selStart, selectStart)) || - (!equalLineAndChars(rep.selEnd, selectEnd)) || - (rep.selFocusAtStart !== newSelFocusAtStart)) { - rep.selStart = selectStart; - rep.selEnd = selectEnd; - rep.selFocusAtStart = newSelFocusAtStart; - currentCallStack.repChanged = true; - - // select the formatting buttons when there is the style applied on selection - selectFormattingButtonIfLineHasStyleApplied(rep); - - hooks.callAll('aceSelectionChanged', { - rep, - callstack: currentCallStack, - documentAttributeManager, - }); - - // we scroll when user places the caret at the last line of the pad - // when this settings is enabled - const docTextChanged = currentCallStack.docTextChanged; - if (!docTextChanged) { - const isScrollableEvent = !isPadLoading(currentCallStack.type) && - isScrollableEditEvent(currentCallStack.type); - const innerHeight = getInnerHeight(); - scroll.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary( - rep, isScrollableEvent, innerHeight * 2); - } - - return true; - } - return false; - }; - - const isPadLoading = (t) => t === 'setup' || t === 'setBaseText' || t === 'importText'; - - const updateStyleButtonState = (attribName, hasStyleOnRepSelection) => { - const $formattingButton = parent.parent.$(`[data-key="${attribName}"]`).find('a'); - $formattingButton.toggleClass(SELECT_BUTTON_CLASS, hasStyleOnRepSelection); - }; - - const attribIsFormattingStyle = (attribName) => FORMATTING_STYLES.indexOf(attribName) !== -1; - - const selectFormattingButtonIfLineHasStyleApplied = (rep) => { - for (const style of FORMATTING_STYLES) { - const hasStyleOnRepSelection = - documentAttributeManager.hasAttributeOnSelectionOrCaretPosition(style); - updateStyleButtonState(style, hasStyleOnRepSelection); - } - }; - - const doCreateDomLine = - (nonEmpty) => domline.createDomLine(nonEmpty, doesWrap, browser, document); - - const textify = - (str) => str.replace(/[\n\r ]/g, ' ').replace(/\xa0/g, ' ').replace(/\t/g, ' '); - - const _blockElems = { - div: 1, - p: 1, - pre: 1, - li: 1, - ol: 1, - ul: 1, - }; - - for (const element of hooks.callAll('aceRegisterBlockElements')) _blockElems[element] = 1; - - const isBlockElement = (n) => !!_blockElems[(n.tagName || '').toLowerCase()]; - editorInfo.ace_isBlockElement = isBlockElement; - - const getDirtyRanges = () => { - // based on observedChanges, return a list of ranges of original lines - // that need to be removed or replaced with new user content to incorporate - // the user's changes into the line representation. ranges may be zero-length, - // indicating inserted content. for example, [0,0] means content was inserted - // at the top of the document, while [3,4] means line 3 was deleted, modified, - // or replaced with one or more new lines of content. ranges do not touch. - - const cleanNodeForIndexCache = {}; - const N = rep.lines.length(); // old number of lines - - - const cleanNodeForIndex = (i) => { - // if line (i) in the un-updated line representation maps to a clean node - // in the document, return that node. - // if (i) is out of bounds, return true. else return false. - if (cleanNodeForIndexCache[i] === undefined) { - let result; - if (i < 0 || i >= N) { - result = true; // truthy, but no actual node - } else { - const key = rep.lines.atIndex(i).key; - result = (getCleanNodeByKey(key) || false); - } - cleanNodeForIndexCache[i] = result; - } - return cleanNodeForIndexCache[i]; - }; - const isConsecutiveCache = {}; - - const isConsecutive = (i) => { - if (isConsecutiveCache[i] === undefined) { - isConsecutiveCache[i] = (() => { - // returns whether line (i) and line (i-1), assumed to be map to clean DOM nodes, - // or document boundaries, are consecutive in the changed DOM - const a = cleanNodeForIndex(i - 1); - const b = cleanNodeForIndex(i); - if ((!a) || (!b)) return false; // violates precondition - if ((a === true) && (b === true)) return !document.body.firstChild; - if ((a === true) && b.previousSibling) return false; - if ((b === true) && a.nextSibling) return false; - if ((a === true) || (b === true)) return true; - return a.nextSibling === b; - })(); - } - return isConsecutiveCache[i]; - }; - - // returns whether line (i) in the un-updated representation maps to a clean node, - // or is outside the bounds of the document - const isClean = (i) => !!cleanNodeForIndex(i); - - // list of pairs, each representing a range of lines that is clean and consecutive - // in the changed DOM. lines (-1) and (N) are always clean, but may or may not - // be consecutive with lines in the document. pairs are in sorted order. - const cleanRanges = [ - [-1, N + 1], - ]; - - // returns index of cleanRange containing i, or -1 if none - const rangeForLine = (i) => { - for (const [idx, r] of cleanRanges.entries()) { - if (i < r[0]) return -1; - if (i < r[1]) return idx; - } - return -1; - }; - - const removeLineFromRange = (rng, line) => { - // rng is index into cleanRanges, line is line number - // precond: line is in rng - const a = cleanRanges[rng][0]; - const b = cleanRanges[rng][1]; - if ((a + 1) === b) cleanRanges.splice(rng, 1); - else if (line === a) cleanRanges[rng][0]++; - else if (line === (b - 1)) cleanRanges[rng][1]--; - else cleanRanges.splice(rng, 1, [a, line], [line + 1, b]); - }; - - const splitRange = (rng, pt) => { - // precond: pt splits cleanRanges[rng] into two non-empty ranges - const a = cleanRanges[rng][0]; - const b = cleanRanges[rng][1]; - cleanRanges.splice(rng, 1, [a, pt], [pt, b]); - }; - - const correctedLines = {}; - - const correctlyAssignLine = (line) => { - if (correctedLines[line]) return true; - correctedLines[line] = true; - // "line" is an index of a line in the un-updated rep. - // returns whether line was already correctly assigned (i.e. correctly - // clean or dirty, according to cleanRanges, and if clean, correctly - // attached or not attached (i.e. in the same range as) the prev and next lines). - const rng = rangeForLine(line); - const lineClean = isClean(line); - if (rng < 0) { - if (lineClean) { - // somehow lost clean line - } - return true; - } - if (!lineClean) { - // a clean-range includes this dirty line, fix it - removeLineFromRange(rng, line); - return false; - } else { - // line is clean, but could be wrongly connected to a clean line - // above or below - const a = cleanRanges[rng][0]; - const b = cleanRanges[rng][1]; - let didSomething = false; - // we'll leave non-clean adjacent nodes in the clean range for the caller to - // detect and deal with. we deal with whether the range should be split - // just above or just below this line. - if (a < line && isClean(line - 1) && !isConsecutive(line)) { - splitRange(rng, line); - didSomething = true; - } - if (b > (line + 1) && isClean(line + 1) && !isConsecutive(line + 1)) { - splitRange(rng, line + 1); - didSomething = true; - } - return !didSomething; - } - }; - - const detectChangesAroundLine = (line, reqInARow) => { - // make sure cleanRanges is correct about line number "line" and the surrounding - // lines; only stops checking at end of document or after no changes need - // making for several consecutive lines. note that iteration is over old lines, - // so this operation takes time proportional to the number of old lines - // that are changed or missing, not the number of new lines inserted. - let correctInARow = 0; - let currentIndex = line; - while (correctInARow < reqInARow && currentIndex >= 0) { - if (correctlyAssignLine(currentIndex)) { - correctInARow++; - } else { correctInARow = 0; } - currentIndex--; - } - correctInARow = 0; - currentIndex = line; - while (correctInARow < reqInARow && currentIndex < N) { - if (correctlyAssignLine(currentIndex)) { - correctInARow++; - } else { correctInARow = 0; } - currentIndex++; - } - }; - - if (N === 0) { - if (!isConsecutive(0)) { - splitRange(0, 0); - } - } else { - detectChangesAroundLine(0, 1); - detectChangesAroundLine(N - 1, 1); - - for (const k of Object.keys(observedChanges.cleanNodesNearChanges)) { - const key = k.substring(1); - if (rep.lines.containsKey(key)) { - const line = rep.lines.indexOfKey(key); - detectChangesAroundLine(line, 2); - } - } - } - - const dirtyRanges = []; - for (let r = 0; r < cleanRanges.length - 1; r++) { - dirtyRanges.push([cleanRanges[r][1], cleanRanges[r + 1][0]]); - } - - return dirtyRanges; - }; - - const markNodeClean = (n) => { - // clean nodes have knownHTML that matches their innerHTML - setAssoc(n, 'dirtiness', {nodeId: uniqueId(n), knownHTML: n.innerHTML}); - }; - - const isNodeDirty = (n) => { - if (n.parentNode !== document.body) return true; - const data = getAssoc(n, 'dirtiness'); - if (!data) return true; - if (n.id !== data.nodeId) return true; - if (n.innerHTML !== data.knownHTML) return true; - return false; - }; - - const handleClick = (evt) => { - inCallStackIfNecessary('handleClick', () => { - idleWorkTimer.atMost(200); - }); - - const isLink = (n) => (n.tagName || '').toLowerCase() === 'a' && n.href; - - // only want to catch left-click - if ((!evt.ctrlKey) && (evt.button !== 2) && (evt.button !== 3)) { - // find A tag with HREF - let n = evt.target; - while (n && n.parentNode && !isLink(n)) { - n = n.parentNode; - } - if (n && isLink(n)) { - try { - window.open(n.href, '_blank', 'noopener,noreferrer'); - } catch (e) { - // absorb "user canceled" error in IE for certain prompts - } - evt.preventDefault(); - } - } - - hideEditBarDropdowns(); - }; - - const hideEditBarDropdowns = () => { - window.parent.parent.padeditbar.toggleDropDown('none'); - }; - - const renumberList = (lineNum) => { - // 1-check we are in a list - let type = getLineListType(lineNum); - if (!type) { - return null; - } - type = /([a-z]+)[0-9]+/.exec(type); - if (type[1] === 'indent') { - return null; - } - - // 2-find the first line of the list - while (lineNum - 1 >= 0 && (type = getLineListType(lineNum - 1))) { - type = /([a-z]+)[0-9]+/.exec(type); - if (type[1] === 'indent') break; - lineNum--; - } - - // 3-renumber every list item of the same level from the beginning, level 1 - // IMPORTANT: never skip a level because there imbrication may be arbitrary - const builder = Changeset.builder(rep.lines.totalWidth()); - let loc = [0, 0]; - const applyNumberList = (line, level) => { - // init - let position = 1; - let curLevel = level; - let listType; - // loop over the lines - while ((listType = getLineListType(line))) { - // apply new num - listType = /([a-z]+)([0-9]+)/.exec(listType); - curLevel = Number(listType[2]); - if (isNaN(curLevel) || listType[0] === 'indent') { - return line; - } else if (curLevel === level) { - ChangesetUtils.buildKeepRange(rep, builder, loc, (loc = [line, 0])); - ChangesetUtils.buildKeepRange(rep, builder, loc, (loc = [line, 1]), [ - ['start', position], - ], rep.apool); - - position++; - line++; - } else if (curLevel < level) { - return line;// back to parent - } else { - line = applyNumberList(line, level + 1);// recursive call - } - } - return line; - }; - - applyNumberList(lineNum, 1); - const cs = builder.toString(); - if (!Changeset.isIdentity(cs)) { - performDocumentApplyChangeset(cs); - } - - // 4-apply the modifications - }; - editorInfo.ace_renumberList = renumberList; - - const setLineListType = (lineNum, listType) => { - if (listType === '') { - documentAttributeManager.removeAttributeOnLine(lineNum, listAttributeName); - documentAttributeManager.removeAttributeOnLine(lineNum, 'start'); - } else { - documentAttributeManager.setAttributeOnLine(lineNum, listAttributeName, listType); - } - - // if the list has been removed, it is necessary to renumber - // starting from the *next* line because the list may have been - // separated. If it returns null, it means that the list was not cut, try - // from the current one. - if (renumberList(lineNum + 1) == null) { - renumberList(lineNum); - } - }; - - const doReturnKey = () => { - if (!(rep.selStart && rep.selEnd)) { - return; - } - - const lineNum = rep.selStart[0]; - let listType = getLineListType(lineNum); - - if (listType) { - const text = rep.lines.atIndex(lineNum).text; - listType = /([a-z]+)([0-9]+)/.exec(listType); - const type = listType[1]; - const level = Number(listType[2]); - - // detect empty list item; exclude indentation - if (text === '*' && type !== 'indent') { - // if not already on the highest level - if (level > 1) { - setLineListType(lineNum, type + (level - 1));// automatically decrease the level - } else { - setLineListType(lineNum, '');// remove the list - renumberList(lineNum + 1);// trigger renumbering of list that may be right after - } - } else if (lineNum + 1 <= rep.lines.length()) { - performDocumentReplaceSelection('\n'); - setLineListType(lineNum + 1, type + level); - } - } else { - performDocumentReplaceSelection('\n'); - handleReturnIndentation(); - } - }; - editorInfo.ace_doReturnKey = doReturnKey; - - const doIndentOutdent = (isOut) => { - if (!((rep.selStart && rep.selEnd) || - (rep.selStart[0] === rep.selEnd[0] && - rep.selStart[1] === rep.selEnd[1] && - rep.selEnd[1] > 1)) && - isOut !== true) { - return false; - } - - const firstLine = rep.selStart[0]; - const lastLine = Math.max(firstLine, rep.selEnd[0] - ((rep.selEnd[1] === 0) ? 1 : 0)); - const mods = []; - for (let n = firstLine; n <= lastLine; n++) { - let listType = getLineListType(n); - let t = 'indent'; - let level = 0; - if (listType) { - listType = /([a-z]+)([0-9]+)/.exec(listType); - if (listType) { - t = listType[1]; - level = Number(listType[2]); - } - } - const newLevel = Math.max(0, Math.min(MAX_LIST_LEVEL, level + (isOut ? -1 : 1))); - if (level !== newLevel) { - mods.push([n, (newLevel > 0) ? t + newLevel : '']); - } - } - - for (const mod of mods) setLineListType(mod[0], mod[1]); - return true; - }; - editorInfo.ace_doIndentOutdent = doIndentOutdent; - - const doTabKey = (shiftDown) => { - if (!doIndentOutdent(shiftDown)) { - performDocumentReplaceSelection(THE_TAB); - } - }; - - const doDeleteKey = (optEvt) => { - const evt = optEvt || {}; - let handled = false; - if (rep.selStart) { - if (isCaret()) { - const lineNum = caretLine(); - const col = caretColumn(); - const lineEntry = rep.lines.atIndex(lineNum); - const lineText = lineEntry.text; - const lineMarker = lineEntry.lineMarker; - if (evt.metaKey && col > lineMarker) { - // cmd-backspace deletes to start of line (if not already at start) - performDocumentReplaceRange([lineNum, lineMarker], [lineNum, col], ''); - handled = true; - } else if (/^ +$/.exec(lineText.substring(lineMarker, col))) { - const col2 = col - lineMarker; - const tabSize = THE_TAB.length; - const toDelete = ((col2 - 1) % tabSize) + 1; - performDocumentReplaceRange([lineNum, col - toDelete], [lineNum, col], ''); - handled = true; - } - } - if (!handled) { - if (isCaret()) { - const theLine = caretLine(); - const lineEntry = rep.lines.atIndex(theLine); - if (caretColumn() <= lineEntry.lineMarker) { - // delete at beginning of line - const prevLineListType = (theLine > 0 ? getLineListType(theLine - 1) : ''); - const thisLineListType = getLineListType(theLine); - const prevLineEntry = (theLine > 0 && rep.lines.atIndex(theLine - 1)); - const prevLineBlank = (prevLineEntry && - prevLineEntry.text.length === prevLineEntry.lineMarker); - - const thisLineHasMarker = documentAttributeManager.lineHasMarker(theLine); - - if (thisLineListType) { - // this line is a list - if (prevLineBlank && !prevLineListType) { - // previous line is blank, remove it - performDocumentReplaceRange( - [theLine - 1, prevLineEntry.text.length], [theLine, 0], ''); - } else { - // delistify - performDocumentReplaceRange([theLine, 0], [theLine, lineEntry.lineMarker], ''); - } - } else if (thisLineHasMarker && prevLineEntry) { - // If the line has any attributes assigned, remove them by removing the marker '*' - performDocumentReplaceRange( - [theLine - 1, prevLineEntry.text.length], [theLine, lineEntry.lineMarker], ''); - } else if (theLine > 0) { - // remove newline - performDocumentReplaceRange( - [theLine - 1, prevLineEntry.text.length], [theLine, 0], ''); - } - } else { - const docChar = caretDocChar(); - if (docChar > 0) { - if (evt.metaKey || evt.ctrlKey || evt.altKey) { - // delete as many unicode "letters or digits" in a row as possible; - // always delete one char, delete further even if that first char - // isn't actually a word char. - let deleteBackTo = docChar - 1; - while (deleteBackTo > lineEntry.lineMarker && - isWordChar(rep.alltext.charAt(deleteBackTo - 1))) { - deleteBackTo--; - } - performDocumentReplaceCharRange(deleteBackTo, docChar, ''); - } else { - // normal delete - performDocumentReplaceCharRange(docChar - 1, docChar, ''); - } - } - } - } else { - performDocumentReplaceSelection(''); - } - } - } - // if the list has been removed, it is necessary to renumber - // starting from the *next* line because the list may have been - // separated. If it returns null, it means that the list was not cut, try - // from the current one. - const line = caretLine(); - if (line !== -1 && renumberList(line + 1) == null) { - renumberList(line); - } - }; - - const isWordChar = (c) => padutils.wordCharRegex.test(c); - editorInfo.ace_isWordChar = isWordChar; - - const handleKeyEvent = (evt) => { - if (!isEditable) return; - const {type, charCode, keyCode, which, altKey, shiftKey} = evt; - - // Don't take action based on modifier keys going up and down. - // Modifier keys do not generate "keypress" events. - // 224 is the command-key under Mac Firefox. - // 91 is the Windows key in IE; it is ASCII for open-bracket but isn't the keycode for that key - // 20 is capslock in IE. - const isModKey = !charCode && (type === 'keyup' || type === 'keydown') && - (keyCode === 16 || keyCode === 17 || keyCode === 18 || - keyCode === 20 || keyCode === 224 || keyCode === 91); - if (isModKey) return; - - // If the key is a keypress and the browser is opera and the key is enter, - // do nothign at all as this fires twice. - if (keyCode === 13 && browser.opera && type === 'keypress') { - // This stops double enters in Opera but double Tabs still show on single - // tab keypress, adding keyCode == 9 to this doesn't help as the event is fired twice - return; - } - - const isTypeForSpecialKey = browser.safari || browser.chrome || browser.firefox - ? type === 'keydown' : type === 'keypress'; - const isTypeForCmdKey = browser.safari || browser.chrome || browser.firefox - ? type === 'keydown' : type === 'keypress'; - - let stopped = false; - - inCallStackIfNecessary('handleKeyEvent', function () { - if (type === 'keypress' || (isTypeForSpecialKey && keyCode === 13 /* return*/)) { - // in IE, special keys don't send keypress, the keydown does the action - if (!outsideKeyPress(evt)) { - evt.preventDefault(); - stopped = true; - } - } else if (evt.key === 'Dead') { - // If it's a dead key we don't want to do any Etherpad behavior. - stopped = true; - return true; - } else if (type === 'keydown') { - outsideKeyDown(evt); - } - let specialHandled = false; - if (!stopped) { - const specialHandledInHook = hooks.callAll('aceKeyEvent', { - callstack: currentCallStack, - editorInfo, - rep, - documentAttributeManager, - evt, - }); - - // if any hook returned true, set specialHandled with true - if (specialHandledInHook) { - specialHandled = specialHandledInHook.indexOf(true) !== -1; - } - - const padShortcutEnabled = parent.parent.clientVars.padShortcutEnabled; - if (!specialHandled && isTypeForSpecialKey && - altKey && keyCode === 120 && - padShortcutEnabled.altF9) { - // Alt F9 focuses on the File Menu and/or editbar. - // Note that while most editors use Alt F10 this is not desirable - // As ubuntu cannot use Alt F10.... - // Focus on the editbar. - // -- TODO: Move Focus back to previous state (we know it so we can use it) - const firstEditbarElement = parent.parent.$('#editbar') - .children('ul').first().children().first() - .children().first().children().first(); - $(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).trigger('blur'); - parent.parent.chat.show(); - parent.parent.$('#chatinput').trigger('focus'); - evt.preventDefault(); - } - if (!specialHandled && type === 'keydown' && - evt.ctrlKey && shiftKey && keyCode === 50 && - padShortcutEnabled.cmdShift2) { - // Control-Shift-2 shows a gritter popup showing a line author - const lineNumber = rep.selEnd[0]; - const alineAttrs = rep.alines[lineNumber]; - const apool = rep.apool; - - // TODO: support selection ranges - // TODO: Still work when authorship colors have been cleared - // TODO: i18n - // TODO: There appears to be a race condition or so. - const authorIds = new Set(); - if (alineAttrs) { - for (const op of Changeset.deserializeOps(alineAttrs)) { - const authorId = AttributeMap.fromString(op.attribs, apool).get('author'); - if (authorId) authorIds.add(authorId); - } - } - const idToName = new Map(parent.parent.pad.userList().map((a) => [a.userId, a.name])); - const myId = parent.parent.clientVars.userId; - const authors = - [...authorIds].map((id) => id === myId ? 'me' : idToName.get(id) || 'unknown'); - - parent.parent.$.gritter.add({ - title: 'Line Authors', - text: - authors.length === 0 ? 'No author information is available' - : authors.length === 1 ? `The author of this line is ${authors[0]}` - : `The authors of this line are ${authors.join(' & ')}`, - sticky: false, - time: '4000', - }); - } - if (!specialHandled && isTypeForSpecialKey && - keyCode === 8 && - padShortcutEnabled.delete) { - // "delete" key; in mozilla, if we're at the beginning of a line, normalize now, - // or else deleting a blank line can take two delete presses. - // -- - // we do deletes completely customly now: - // - allows consistent (and better) meta-delete behavior - // - normalizing and then allowing default behavior confused IE - // - probably eliminates a few minor quirks - fastIncorp(3); - evt.preventDefault(); - doDeleteKey(evt); - specialHandled = true; - } - if (!specialHandled && isTypeForSpecialKey && - keyCode === 13 && - padShortcutEnabled.return) { - // return key, handle specially; - // note that in mozilla we need to do an incorporation for proper return behavior anyway. - fastIncorp(4); - evt.preventDefault(); - doReturnKey(); - scheduler.setTimeout(() => { - outerWin.scrollBy(-100, 0); - }, 0); - specialHandled = true; - } - if (!specialHandled && isTypeForSpecialKey && - keyCode === 27 && - padShortcutEnabled.esc) { - // prevent esc key; - // in mozilla versions 14-19 avoid reconnecting pad. - - fastIncorp(4); - evt.preventDefault(); - specialHandled = true; - - // close all gritters when the user hits escape key - parent.parent.$.gritter.removeAll(); - } - if (!specialHandled && isTypeForCmdKey && - /* Do a saved revision on ctrl S */ - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 's' && - !evt.altKey && - padShortcutEnabled.cmdS) { - evt.preventDefault(); - const originalBackground = parent.parent.$('#revisionlink').css('background'); - parent.parent.$('#revisionlink').css({background: 'lightyellow'}); - scheduler.setTimeout(() => { - parent.parent.$('#revisionlink').css({background: originalBackground}); - }, 1000); - /* The parent.parent part of this is BAD and I feel bad.. It may break something */ - parent.parent.pad.collabClient.sendMessage({type: 'SAVE_REVISION'}); - specialHandled = true; - } - if (!specialHandled && isTypeForSpecialKey && - // tab - keyCode === 9 && - !(evt.metaKey || evt.ctrlKey) && - padShortcutEnabled.tab) { - fastIncorp(5); - evt.preventDefault(); - doTabKey(evt.shiftKey); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-Z (undo) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'z' && - !evt.altKey && - padShortcutEnabled.cmdZ) { - fastIncorp(6); - evt.preventDefault(); - if (evt.shiftKey) { - doUndoRedo('redo'); - } else { - doUndoRedo('undo'); - } - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-Y (redo) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'y' && - padShortcutEnabled.cmdY) { - fastIncorp(10); - evt.preventDefault(); - doUndoRedo('redo'); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-B (bold) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'b' && - padShortcutEnabled.cmdB) { - fastIncorp(13); - evt.preventDefault(); - toggleAttributeOnSelection('bold'); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-I (italic) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'i' && - padShortcutEnabled.cmdI) { - fastIncorp(14); - evt.preventDefault(); - toggleAttributeOnSelection('italic'); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-U (underline) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'u' && - padShortcutEnabled.cmdU) { - fastIncorp(15); - evt.preventDefault(); - toggleAttributeOnSelection('underline'); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-5 (strikethrough) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === '5' && - evt.altKey !== true && - padShortcutEnabled.cmd5) { - fastIncorp(13); - evt.preventDefault(); - toggleAttributeOnSelection('strikethrough'); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-shift-L (unorderedlist) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'l' && - evt.shiftKey && - padShortcutEnabled.cmdShiftL) { - fastIncorp(9); - evt.preventDefault(); - doInsertUnorderedList(); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-shift-N and cmd-shift-1 (orderedlist) - (evt.metaKey || evt.ctrlKey) && evt.shiftKey && - ((String.fromCharCode(which).toLowerCase() === 'n' && padShortcutEnabled.cmdShiftN) || - (String.fromCharCode(which) === '1' && padShortcutEnabled.cmdShift1))) { - fastIncorp(9); - evt.preventDefault(); - doInsertOrderedList(); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-shift-C (clearauthorship) - (evt.metaKey || evt.ctrlKey) && evt.shiftKey && - String.fromCharCode(which).toLowerCase() === 'c' && - padShortcutEnabled.cmdShiftC) { - fastIncorp(9); - evt.preventDefault(); - CMDS.clearauthorship(); - } - if (!specialHandled && isTypeForCmdKey && - // cmd-H (backspace) - (evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'h' && - padShortcutEnabled.cmdH) { - fastIncorp(20); - evt.preventDefault(); - doDeleteKey(); - specialHandled = true; - } - if (evt.ctrlKey === true && evt.which === 36 && - // Control Home send to Y = 0 - padShortcutEnabled.ctrlHome) { - scroll.setScrollY(0); - } - if ((evt.which === 33 || evt.which === 34) && type === 'keydown' && !evt.ctrlKey) { - // This is required, browsers will try to do normal default behavior on - // page up / down and the default behavior SUCKS - evt.preventDefault(); - const oldVisibleLineRange = scroll.getVisibleLineRange(rep); - let topOffset = rep.selStart[0] - oldVisibleLineRange[0]; - if (topOffset < 0) { - topOffset = 0; - } - - const isPageDown = evt.which === 34; - const isPageUp = evt.which === 33; - - scheduler.setTimeout(() => { - // the visible lines IE 1,10 - const newVisibleLineRange = scroll.getVisibleLineRange(rep); - // total count of lines in pad IE 10 - const linesCount = rep.lines.length(); - // How many lines are in the viewport right now? - const numberOfLinesInViewport = newVisibleLineRange[1] - newVisibleLineRange[0]; - - if (isPageUp && padShortcutEnabled.pageUp) { - // move to the bottom line +1 in the viewport (essentially skipping over a page) - rep.selEnd[0] -= numberOfLinesInViewport; - // move to the bottom line +1 in the viewport (essentially skipping over a page) - rep.selStart[0] -= numberOfLinesInViewport; - } - - // if we hit page down - if (isPageDown && padShortcutEnabled.pageDown) { - // If the new viewpoint position is actually further than where we are right now - if (rep.selEnd[0] >= oldVisibleLineRange[0]) { - // dont go further in the page down than what's visible IE go from 0 to 50 - // if 50 is visible on screen but dont go below that else we miss content - rep.selStart[0] = oldVisibleLineRange[1] - 1; - // dont go further in the page down than what's visible IE go from 0 to 50 - // if 50 is visible on screen but dont go below that else we miss content - rep.selEnd[0] = oldVisibleLineRange[1] - 1; - } - } - - // ensure min and max - if (rep.selEnd[0] < 0) { - rep.selEnd[0] = 0; - } - if (rep.selStart[0] < 0) { - rep.selStart[0] = 0; - } - if (rep.selEnd[0] >= linesCount) { - rep.selEnd[0] = linesCount - 1; - } - updateBrowserSelectionFromRep(); - // get the current caret selection, can't use rep. here because that only gives - // us the start position not the current - const myselection = document.getSelection(); - // get the carets selection offset in px IE 214 - let caretOffsetTop = myselection.focusNode.parentNode.offsetTop || - myselection.focusNode.offsetTop; - - // sometimes the first selection is -1 which causes problems - // (Especially with ep_page_view) - // so use focusNode.offsetTop value. - if (caretOffsetTop === -1) caretOffsetTop = myselection.focusNode.offsetTop; - // set the scrollY offset of the viewport on the document - scroll.setScrollY(caretOffsetTop); - }, 200); - } - } - - if (type === 'keydown') { - idleWorkTimer.atLeast(500); - } else if (type === 'keypress') { - // OPINION ASKED. What's going on here? :D - if (!specialHandled) { - idleWorkTimer.atMost(0); - } else { - idleWorkTimer.atLeast(500); - } - } else if (type === 'keyup') { - const wait = 0; - idleWorkTimer.atLeast(wait); - idleWorkTimer.atMost(wait); - } - - // Is part of multi-keystroke international character on Firefox Mac - const isFirefoxHalfCharacter = - (browser.firefox && evt.altKey && charCode === 0 && keyCode === 0); - - // Is part of multi-keystroke international character on Safari Mac - const isSafariHalfCharacter = - (browser.safari && evt.altKey && keyCode === 229); - - if (thisKeyDoesntTriggerNormalize || isFirefoxHalfCharacter || isSafariHalfCharacter) { - idleWorkTimer.atLeast(3000); // give user time to type - // if this is a keydown, e.g., the keyup shouldn't trigger a normalize - thisKeyDoesntTriggerNormalize = true; - } - - if (!specialHandled && !thisKeyDoesntTriggerNormalize && !inInternationalComposition && - type !== 'keyup') { - observeChangesAroundSelection(); - } - - if (type === 'keyup') { - thisKeyDoesntTriggerNormalize = false; - } - }); - }; - - let thisKeyDoesntTriggerNormalize = false; - - const doUndoRedo = (which) => { - // precond: normalized DOM - if (undoModule.enabled) { - let whichMethod; - if (which === 'undo') whichMethod = 'performUndo'; - if (which === 'redo') whichMethod = 'performRedo'; - if (whichMethod) { - const oldEventType = currentCallStack.editEvent.eventType; - currentCallStack.startNewEvent(which); - undoModule[whichMethod]((backset, selectionInfo) => { - if (backset) { - performDocumentApplyChangeset(backset); - } - if (selectionInfo) { - performSelectionChange( - lineAndColumnFromChar(selectionInfo.selStart), - lineAndColumnFromChar(selectionInfo.selEnd), - selectionInfo.selFocusAtStart); - } - const oldEvent = currentCallStack.startNewEvent(oldEventType, true); - return oldEvent; - }); - } - } - }; - editorInfo.ace_doUndoRedo = doUndoRedo; - - const setSelection = (selection) => { - const copyPoint = (pt) => ({ - node: pt.node, - index: pt.index, - maxIndex: pt.maxIndex, - }); - let isCollapsed; - - const pointToRangeBound = (pt) => { - const p = copyPoint(pt); - // Make sure Firefox cursor is deep enough; fixes cursor jumping when at top level, - // and also problem where cut/copy of a whole line selected with fake arrow-keys - // copies the next line too. - if (isCollapsed) { - const diveDeep = () => { - while (p.node.childNodes.length > 0) { - if (p.index === 0) { - p.node = p.node.firstChild; - p.maxIndex = nodeMaxIndex(p.node); - } else if (p.index === p.maxIndex) { - p.node = p.node.lastChild; - p.maxIndex = nodeMaxIndex(p.node); - p.index = p.maxIndex; - } else { break; } - } - }; - // now fix problem where cursor at end of text node at end of span-like element - // with background doesn't seem to show up... - if (isNodeText(p.node) && p.index === p.maxIndex) { - let n = p.node; - while (!n.nextSibling && n !== document.body && n.parentNode !== document.body) { - n = n.parentNode; - } - if (n.nextSibling && - !(typeof n.nextSibling.tagName === 'string' && - n.nextSibling.tagName.toLowerCase() === 'br') && - n !== p.node && n !== document.body && n.parentNode !== document.body) { - // found a parent, go to next node and dive in - p.node = n.nextSibling; - p.maxIndex = nodeMaxIndex(p.node); - p.index = 0; - diveDeep(); - } - } - // try to make sure insertion point is styled; - // also fixes other FF problems - if (!isNodeText(p.node)) { - diveDeep(); - } - } - if (isNodeText(p.node)) { - return { - container: p.node, - offset: p.index, - }; - } else { - // p.index in {0,1} - return { - container: p.node.parentNode, - offset: childIndex(p.node) + p.index, - }; - } - }; - const browserSelection = window.getSelection(); - if (browserSelection) { - browserSelection.removeAllRanges(); - if (selection) { - isCollapsed = (selection.startPoint.node === selection.endPoint.node && - selection.startPoint.index === selection.endPoint.index); - const start = pointToRangeBound(selection.startPoint); - const end = pointToRangeBound(selection.endPoint); - - if (!isCollapsed && selection.focusAtStart && - browserSelection.collapse && browserSelection.extend) { - // can handle "backwards"-oriented selection, shift-arrow-keys move start - // of selection - browserSelection.collapse(end.container, end.offset); - browserSelection.extend(start.container, start.offset); - } else { - const range = document.createRange(); - range.setStart(start.container, start.offset); - range.setEnd(end.container, end.offset); - browserSelection.removeAllRanges(); - browserSelection.addRange(range); - } - } - } - }; - - const updateBrowserSelectionFromRep = () => { - // requires normalized DOM! - const selStart = rep.selStart; - const selEnd = rep.selEnd; - - if (!(selStart && selEnd)) { - setSelection(null); - return; - } - - const selection = {}; - - const ss = [selStart[0], selStart[1]]; - selection.startPoint = getPointForLineAndChar(ss); - - const se = [selEnd[0], selEnd[1]]; - selection.endPoint = getPointForLineAndChar(se); - - selection.focusAtStart = !!rep.selFocusAtStart; - setSelection(selection); - }; - editorInfo.ace_updateBrowserSelectionFromRep = updateBrowserSelectionFromRep; - editorInfo.ace_focus = focus; - editorInfo.ace_importText = importText; - editorInfo.ace_importAText = importAText; - editorInfo.ace_exportText = exportText; - editorInfo.ace_editorChangedSize = editorChangedSize; - editorInfo.ace_setOnKeyPress = setOnKeyPress; - editorInfo.ace_setOnKeyDown = setOnKeyDown; - editorInfo.ace_setNotifyDirty = setNotifyDirty; - editorInfo.ace_dispose = dispose; - editorInfo.ace_setEditable = setEditable; - editorInfo.ace_execCommand = execCommand; - editorInfo.ace_replaceRange = replaceRange; - editorInfo.ace_getAuthorInfos = getAuthorInfos; - editorInfo.ace_performDocumentReplaceRange = performDocumentReplaceRange; - editorInfo.ace_performDocumentReplaceCharRange = performDocumentReplaceCharRange; - editorInfo.ace_setSelection = setSelection; - - const nodeMaxIndex = (nd) => { - if (isNodeText(nd)) return nd.nodeValue.length; - else return 1; - }; - - const getSelection = () => { - // returns null, or a structure containing startPoint and endPoint, - // each of which has node (a magicdom node), index, and maxIndex. If the node - // is a text node, maxIndex is the length of the text; else maxIndex is 1. - // index is between 0 and maxIndex, inclusive. - const browserSelection = window.getSelection(); - if (!browserSelection || browserSelection.type === 'None' || - browserSelection.rangeCount === 0) { - return null; - } - const range = browserSelection.getRangeAt(0); - - const isInBody = (n) => { - while (n && !(n.tagName && n.tagName.toLowerCase() === 'body')) { - n = n.parentNode; - } - return !!n; - }; - - const pointFromRangeBound = (container, offset) => { - if (!isInBody(container)) { - // command-click in Firefox selects whole document, HEAD and BODY! - return { - node: document.body, - index: 0, - maxIndex: 1, - }; - } - const n = container; - const childCount = n.childNodes.length; - if (isNodeText(n)) { - return { - node: n, - index: offset, - maxIndex: n.nodeValue.length, - }; - } else if (childCount === 0) { - return { - node: n, - index: 0, - maxIndex: 1, - }; - // treat point between two nodes as BEFORE the second (rather than after the first) - // if possible; this way point at end of a line block-element is treated as - // at beginning of next line - } else if (offset === childCount) { - const nd = n.childNodes.item(childCount - 1); - const max = nodeMaxIndex(nd); - return { - node: nd, - index: max, - maxIndex: max, - }; - } else { - const nd = n.childNodes.item(offset); - const max = nodeMaxIndex(nd); - return { - node: nd, - index: 0, - maxIndex: max, - }; - } - }; - const selection = { - startPoint: pointFromRangeBound(range.startContainer, range.startOffset), - endPoint: pointFromRangeBound(range.endContainer, range.endOffset), - focusAtStart: - (range.startContainer !== range.endContainer || range.startOffset !== range.endOffset) && - browserSelection.anchorNode && - browserSelection.anchorNode === range.endContainer && - browserSelection.anchorOffset === range.endOffset, - }; - - if (selection.startPoint.node.ownerDocument !== window.document) { - return null; - } - - return selection; - }; - - const childIndex = (n) => { - let idx = 0; - while (n.previousSibling) { - idx++; - n = n.previousSibling; - } - return idx; - }; - - const fixView = () => { - // calling this method repeatedly should be fast - if (getInnerWidth() === 0 || getInnerHeight() === 0) { - return; - } - - enforceEditability(); - - $(sideDiv).addClass('sidedivdelayed'); - }; - - const _teardownActions = []; - - const teardown = () => { for (const a of _teardownActions) a(); }; - - let inInternationalComposition = null; - editorInfo.ace_getInInternationalComposition = () => inInternationalComposition; - - const bindTheEventHandlers = () => { - $(document).on('keydown', handleKeyEvent); - $(document).on('keypress', handleKeyEvent); - $(document).on('keyup', handleKeyEvent); - $(document).on('click', handleClick); - // dropdowns on edit bar need to be closed on clicks on both pad inner and pad outer - $(outerDoc).on('click', hideEditBarDropdowns); - - // If non-nullish, pasting on a link should be suppressed. - let suppressPasteOnLink = null; - - $(document.body).on('auxclick', (e) => { - if (e.originalEvent.button === 1 && (e.target.a || e.target.localName === 'a')) { - // The user middle-clicked on a link. Usually users do this to open a link in a new tab, but - // in X11 (Linux) this will instead paste the contents of the primary selection at the mouse - // cursor. Users almost certainly do not want to paste when middle-clicking on a link, so - // tell the 'paste' event handler to suppress the paste. This is done by starting a - // short-lived timer that suppresses paste (when the target is a link) until either the - // paste event arrives or the timer fires. - // - // Why it is implemented this way: - // * Users want to be able to paste on a link via Ctrl-V, the Edit menu, or the context - // menu (https://github.com/ether/etherpad-lite/issues/2775) so we cannot simply - // suppress all paste actions when the target is a link. - // * Non-X11 systems do not paste when the user middle-clicks, so the paste suppression - // must be self-resetting. - // * On non-X11 systems, middle click should continue to open the link in a new tab. - // Suppressing the middle click here in the 'auxclick' handler (via e.preventDefault()) - // would break that behavior. - suppressPasteOnLink = scheduler.setTimeout(() => { suppressPasteOnLink = null; }, 0); - } - }); - - $(document.body).on('paste', (e) => { - if (suppressPasteOnLink != null && (e.target.a || e.target.localName === 'a')) { - scheduler.clearTimeout(suppressPasteOnLink); - suppressPasteOnLink = null; - e.preventDefault(); - return; - } - - // Call paste hook - hooks.callAll('acePaste', { - editorInfo, - rep, - documentAttributeManager, - e, - }); - }); - - // We reference document here, this is because if we don't this will expose a bug - // in Google Chrome. This bug will cause the last character on the last line to - // not fire an event when dropped into.. - $(document).on('drop', (e) => { - if (e.target.a || e.target.localName === 'a') { - e.preventDefault(); - } - - // Bug fix: when user drags some content and drop it far from its origin, we - // need to merge the changes into a single changeset. So mark origin with empty
      ', - wantHTML: 'empty

      ', - wantText: 'empty\n\n', - }, - 'indentedListsAreNotBullets': { - description: 'Indented lists are represented with tabs and without bullets', - input: '
      • indent
      • indent
      ', - wantHTML: '
      • indent
      • indent

      ', - wantText: '\tindent\n\tindent\n\n', - }, - 'lineWithMultipleSpaces': { - description: 'Multiple spaces should be collapsed', - input: 'Text with more than one space.
      ', - wantHTML: 'Text with more than one space.

      ', - wantText: 'Text with more than one space.\n\n', - }, - 'lineWithMultipleNonBreakingAndNormalSpaces': { - // XXX the HTML between "than" and "one" looks strange - description: 'non-breaking space should be preserved, but can be replaced when it', - input: 'Text with  more   than  one space.
      ', - wantHTML: 'Text with  more   than  one space.

      ', - wantText: 'Text with more than one space.\n\n', - }, - 'multiplenbsp': { - description: 'Multiple non-breaking space should be preserved', - input: '  
      ', - wantHTML: '  

      ', - wantText: ' \n\n', - }, - 'multipleNonBreakingSpaceBetweenWords': { - description: 'A normal space is always inserted before a word', - input: '  word1  word2   word3
      ', - wantHTML: '  word1  word2   word3

      ', - wantText: ' word1 word2 word3\n\n', - }, - 'nonBreakingSpacePreceededBySpaceBetweenWords': { - description: 'A non-breaking space preceded by a normal space', - input: '  word1  word2  word3
      ', - wantHTML: ' word1  word2  word3

      ', - wantText: ' word1 word2 word3\n\n', - }, - 'nonBreakingSpaceFollowededBySpaceBetweenWords': { - description: 'A non-breaking space followed by a normal space', - input: '  word1  word2  word3
      ', - wantHTML: '  word1  word2  word3

      ', - wantText: ' word1 word2 word3\n\n', - }, - 'spacesAfterNewline': { - description: 'Collapse spaces that follow a newline', - input: 'something
      something
      ', - wantHTML: 'something
      something

      ', - wantText: 'something\nsomething\n\n', - }, - 'spacesAfterNewlineP': { - description: 'Collapse spaces that follow a paragraph', - input: 'something

      something
      ', - wantHTML: 'something

      something

      ', - wantText: 'something\n\nsomething\n\n', - }, - 'spacesAtEndOfLine': { - description: 'Collapse spaces that preceed/follow a newline', - input: 'something
      something
      ', - wantHTML: 'something
      something

      ', - wantText: 'something\nsomething\n\n', - }, - 'spacesAtEndOfLineP': { - description: 'Collapse spaces that preceed/follow a paragraph', - input: 'something

      something
      ', - wantHTML: 'something

      something

      ', - wantText: 'something\n\nsomething\n\n', - }, - 'nonBreakingSpacesAfterNewlines': { - description: 'Don\'t collapse non-breaking spaces that follow a newline', - input: 'something
         something
      ', - wantHTML: 'something
         something

      ', - wantText: 'something\n something\n\n', - }, - 'nonBreakingSpacesAfterNewlinesP': { - description: 'Don\'t collapse non-breaking spaces that follow a paragraph', - input: 'something

         something
      ', - wantHTML: 'something

         something

      ', - wantText: 'something\n\n something\n\n', - }, - 'collapseSpacesInsideElements': { - description: 'Preserve only one space when multiple are present', - input: 'Need more space s !
      ', - wantHTML: 'Need more space s !

      ', - wantText: 'Need more space s !\n\n', - }, - 'collapseSpacesAcrossNewlines': { - description: 'Newlines and multiple spaces across newlines should be collapsed', - input: ` + ignoreAnyTagsOutsideBody: { + description: "Content outside body should be ignored", + input: + "titleempty
      ", + wantHTML: "empty

      ", + wantText: "empty\n\n", + }, + indentedListsAreNotBullets: { + description: "Indented lists are represented with tabs and without bullets", + input: + '
      • indent
      • indent
      ', + wantHTML: + '
      • indent
      • indent

      ', + wantText: "\tindent\n\tindent\n\n", + }, + lineWithMultipleSpaces: { + description: "Multiple spaces should be collapsed", + input: "Text with more than one space.
      ", + wantHTML: + "Text with more than one space.

      ", + wantText: "Text with more than one space.\n\n", + }, + lineWithMultipleNonBreakingAndNormalSpaces: { + // XXX the HTML between "than" and "one" looks strange + description: + "non-breaking space should be preserved, but can be replaced when it", + input: + "Text with  more   than  one space.
      ", + wantHTML: + "Text with  more   than  one space.

      ", + wantText: "Text with more than one space.\n\n", + }, + multiplenbsp: { + description: "Multiple non-breaking space should be preserved", + input: "  
      ", + wantHTML: "  

      ", + wantText: " \n\n", + }, + multipleNonBreakingSpaceBetweenWords: { + description: "A normal space is always inserted before a word", + input: + "  word1  word2   word3
      ", + wantHTML: + "  word1  word2   word3

      ", + wantText: " word1 word2 word3\n\n", + }, + nonBreakingSpacePreceededBySpaceBetweenWords: { + description: "A non-breaking space preceded by a normal space", + input: "  word1  word2  word3
      ", + wantHTML: + " word1  word2  word3

      ", + wantText: " word1 word2 word3\n\n", + }, + nonBreakingSpaceFollowededBySpaceBetweenWords: { + description: "A non-breaking space followed by a normal space", + input: "  word1  word2  word3
      ", + wantHTML: + "  word1  word2  word3

      ", + wantText: " word1 word2 word3\n\n", + }, + spacesAfterNewline: { + description: "Collapse spaces that follow a newline", + input: + "something
      something
      ", + wantHTML: + "something
      something

      ", + wantText: "something\nsomething\n\n", + }, + spacesAfterNewlineP: { + description: "Collapse spaces that follow a paragraph", + input: + "something

      something
      ", + wantHTML: + "something

      something

      ", + wantText: "something\n\nsomething\n\n", + }, + spacesAtEndOfLine: { + description: "Collapse spaces that preceed/follow a newline", + input: + "something
      something
      ", + wantHTML: + "something
      something

      ", + wantText: "something\nsomething\n\n", + }, + spacesAtEndOfLineP: { + description: "Collapse spaces that preceed/follow a paragraph", + input: + "something

      something
      ", + wantHTML: + "something

      something

      ", + wantText: "something\n\nsomething\n\n", + }, + nonBreakingSpacesAfterNewlines: { + description: "Don't collapse non-breaking spaces that follow a newline", + input: + "something
         something
      ", + wantHTML: + "something
         something

      ", + wantText: "something\n something\n\n", + }, + nonBreakingSpacesAfterNewlinesP: { + description: "Don't collapse non-breaking spaces that follow a paragraph", + input: + "something

         something
      ", + wantHTML: + "something

         something

      ", + wantText: "something\n\n something\n\n", + }, + collapseSpacesInsideElements: { + description: "Preserve only one space when multiple are present", + input: + "Need more space s !
      ", + wantHTML: + "Need more space s !

      ", + wantText: "Need more space s !\n\n", + }, + collapseSpacesAcrossNewlines: { + description: + "Newlines and multiple spaces across newlines should be collapsed", + input: ` Need more space s !
      `, - wantHTML: 'Need more space s !

      ', - wantText: 'Need more space s !\n\n', - }, - 'multipleNewLinesAtBeginning': { - description: 'Multiple new lines and paragraphs at the beginning should be preserved', - input: '

      first line

      second line
      ', - wantHTML: '



      first line

      second line

      ', - wantText: '\n\n\n\nfirst line\n\nsecond line\n\n', - }, - 'multiLineParagraph': { - description: 'A paragraph with multiple lines should not loose spaces when lines are combined', - input: ` + wantHTML: + "Need more space s !

      ", + wantText: "Need more space s !\n\n", + }, + multipleNewLinesAtBeginning: { + description: + "Multiple new lines and paragraphs at the beginning should be preserved", + input: + "

      first line

      second line
      ", + wantHTML: + "



      first line

      second line

      ", + wantText: "\n\n\n\nfirst line\n\nsecond line\n\n", + }, + multiLineParagraph: { + description: + "A paragraph with multiple lines should not loose spaces when lines are combined", + input: `

      а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь

      `, - wantHTML: 'а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь

      ', - wantText: 'а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь\n\n', - }, - 'multiLineParagraphWithPre': { - // XXX why is there   before "in"? - description: 'lines in preformatted text should be kept intact', - input: ` + wantHTML: + "а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь

      ", + wantText: + "а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь\n\n", + }, + multiLineParagraphWithPre: { + // XXX why is there   before "in"? + description: "lines in preformatted text should be kept intact", + input: `

      а б в г ґ д е є ж з и і ї й к л м н о

      multiple
          lines
      @@ -188,101 +228,121 @@ const testImports:MapArrayType = {
       

      п р с т у ф х ц ч ш щ ю я ь

      `, - wantHTML: 'а б в г ґ д е є ж з и і ї й к л м н о
      multiple
         lines
       in
            pre

      п р с т у ф х ц ч ш щ ю я ь

      ', - wantText: 'а б в г ґ д е є ж з и і ї й к л м н о\nmultiple\n lines\n in\n pre\n\nп р с т у ф х ц ч ш щ ю я ь\n\n', - }, - 'preIntroducesASpace': { - description: 'pre should be on a new line not preceded by a space', - input: `

      + wantHTML: + "а б в г ґ д е є ж з и і ї й к л м н о
      multiple
         lines
       in
            pre

      п р с т у ф х ц ч ш щ ю я ь

      ", + wantText: + "а б в г ґ д е є ж з и і ї й к л м н о\nmultiple\n lines\n in\n pre\n\nп р с т у ф х ц ч ш щ ю я ь\n\n", + }, + preIntroducesASpace: { + description: "pre should be on a new line not preceded by a space", + input: `

      1

      preline
       

      `, - wantHTML: '1
      preline


      ', - wantText: '1\npreline\n\n\n', - }, - 'dontDeleteSpaceInsideElements': { - description: 'Preserve spaces inside elements', - input: 'Need more space s !
      ', - wantHTML: 'Need more space s !

      ', - wantText: 'Need more space s !\n\n', - }, - 'dontDeleteSpaceOutsideElements': { - description: 'Preserve spaces outside elements', - input: 'Need more space s !
      ', - wantHTML: 'Need more space s !

      ', - wantText: 'Need more space s !\n\n', - }, - 'dontDeleteSpaceAtEndOfElement': { - description: 'Preserve spaces at the end of an element', - input: 'Need more space s !
      ', - wantHTML: 'Need more space s !

      ', - wantText: 'Need more space s !\n\n', - }, - 'dontDeleteSpaceAtBeginOfElements': { - description: 'Preserve spaces at the start of an element', - input: 'Need more space s !
      ', - wantHTML: 'Need more space s !

      ', - wantText: 'Need more space s !\n\n', - }, + wantHTML: + "1
      preline


      ", + wantText: "1\npreline\n\n\n", + }, + dontDeleteSpaceInsideElements: { + description: "Preserve spaces inside elements", + input: + "Need more space s !
      ", + wantHTML: + "Need more space s !

      ", + wantText: "Need more space s !\n\n", + }, + dontDeleteSpaceOutsideElements: { + description: "Preserve spaces outside elements", + input: + "Need more space s !
      ", + wantHTML: + "Need more space s !

      ", + wantText: "Need more space s !\n\n", + }, + dontDeleteSpaceAtEndOfElement: { + description: "Preserve spaces at the end of an element", + input: + "Need more space s !
      ", + wantHTML: + "Need more space s !

      ", + wantText: "Need more space s !\n\n", + }, + dontDeleteSpaceAtBeginOfElements: { + description: "Preserve spaces at the start of an element", + input: + "Need more space s !
      ", + wantHTML: + "Need more space s !

      ", + wantText: "Need more space s !\n\n", + }, }; describe(__filename, function () { - this.timeout(1000); + this.timeout(1000); - before(async function () { agent = await common.init(); }); + before(async function () { + agent = await common.init(); + }); - Object.keys(testImports).forEach((testName) => { - describe(testName, function () { - const testPadId = makeid(); - const test = testImports[testName]; - if (test.disabled) { - return xit(`DISABLED: ${testName}`, function (done) { - done(); - }); - } + Object.keys(testImports).forEach((testName) => { + describe(testName, function () { + const testPadId = makeid(); + const test = testImports[testName]; + if (test.disabled) { + return xit(`DISABLED: ${testName}`, function (done) { + done(); + }); + } - it('createPad', async function () { - const res = await agent.get(`${endPoint('createPad')}?padID=${testPadId}`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); + it("createPad", async function () { + const res = await agent + .get(`${endPoint("createPad")}?padID=${testPadId}`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); - it('setHTML', async function () { - const res = await agent.get(`${endPoint('setHTML')}?padID=${testPadId}` + - `&html=${encodeURIComponent(test.input)}`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); + it("setHTML", async function () { + const res = await agent + .get( + `${endPoint("setHTML")}?padID=${testPadId}` + + `&html=${encodeURIComponent(test.input)}`, + ) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); - it('getHTML', async function () { - const res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.data.html, test.wantHTML); - }); + it("getHTML", async function () { + const res = await agent + .get(`${endPoint("getHTML")}?padID=${testPadId}`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.data.html, test.wantHTML); + }); - it('getText', async function () { - const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.data.text, test.wantText); - }); - }); - }); + it("getText", async function () { + const res = await agent + .get(`${endPoint("getText")}?padID=${testPadId}`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.data.text, test.wantText); + }); + }); + }); }); function makeid() { - let text = ''; - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - for (let i = 0; i < 5; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - return text; + for (let i = 0; i < 5; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; } diff --git a/src/tests/backend/specs/api/importexportGetPost.ts b/src/tests/backend/specs/api/importexportGetPost.ts index 355699bc2..687005f93 100644 --- a/src/tests/backend/specs/api/importexportGetPost.ts +++ b/src/tests/backend/specs/api/importexportGetPost.ts @@ -1,20 +1,20 @@ -'use strict'; +"use strict"; /* * Import and Export tests for the /p/whateverPadId/import and /p/whateverPadId/export endpoints. */ -import {MapArrayType} from "../../../../node/types/MapType"; -import {SuperTestStatic} from "supertest"; +import { MapArrayType } from "../../../../node/types/MapType"; +import { SuperTestStatic } from "supertest"; import TestAgent from "supertest/lib/agent"; -const assert = require('assert').strict; -const common = require('../../common'); -const fs = require('fs'); -const settings = require('../../../../node/utils/Settings'); -const superagent = require('superagent'); -const padManager = require('../../../../node/db/PadManager'); -const plugins = require('../../../../static/js/pluginfw/plugin_defs'); +const assert = require("assert").strict; +const common = require("../../common"); +const fs = require("fs"); +const settings = require("../../../../node/utils/Settings"); +const superagent = require("superagent"); +const padManager = require("../../../../node/db/PadManager"); +const plugins = require("../../../../static/js/pluginfw/plugin_defs"); const padText = fs.readFileSync(`${__dirname}/test.txt`); const etherpadDoc = fs.readFileSync(`${__dirname}/test.etherpad`); @@ -29,35 +29,39 @@ const testPadId = makeid(); const testPadIdEnc = encodeURIComponent(testPadId); const deleteTestPad = async () => { - if (await padManager.doesPadExist(testPadId)) { - const pad = await padManager.getPad(testPadId); - await pad.remove(); - } + if (await padManager.doesPadExist(testPadId)) { + const pad = await padManager.getPad(testPadId); + await pad.remove(); + } }; describe(__filename, function () { - this.timeout(45000); - before(async function () { agent = await common.init(); }); + this.timeout(45000); + before(async function () { + agent = await common.init(); + }); - describe('Connectivity', function () { - it('can connect', async function () { - await agent.get('/api/') - .set("authorization", await common.generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/); - }); - }); + describe("Connectivity", function () { + it("can connect", async function () { + await agent + .get("/api/") + .set("authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + }); + }); - describe('API Versioning', function () { - it('finds the version tag', async function () { - await agent.get('/api/') - .set("authorization", await common.generateJWTToken()) - .expect(200) - .expect((res:any) => assert(res.body.currentVersion)); - }); - }); + describe("API Versioning", function () { + it("finds the version tag", async function () { + await agent + .get("/api/") + .set("authorization", await common.generateJWTToken()) + .expect(200) + .expect((res: any) => assert(res.body.currentVersion)); + }); + }); - /* + /* Tests ----- @@ -83,723 +87,861 @@ describe(__filename, function () { curl -s -v --form file=@/home/jose/test.txt http://127.0.0.1:9001/p/foo/import */ - describe('Imports and Exports', function () { - const backups:MapArrayType = {}; + describe("Imports and Exports", function () { + const backups: MapArrayType = {}; - beforeEach(async function () { - backups.hooks = {}; - for (const hookName of ['preAuthorize', 'authenticate', 'authorize']) { - backups.hooks[hookName] = plugins.hooks[hookName]; - plugins.hooks[hookName] = []; - } - // Note: This is a shallow copy. - backups.settings = Object.assign({}, settings); - settings.requireAuthentication = false; - settings.requireAuthorization = false; - settings.users = {user: {password: 'user-password'}}; - }); + beforeEach(async function () { + backups.hooks = {}; + for (const hookName of ["preAuthorize", "authenticate", "authorize"]) { + backups.hooks[hookName] = plugins.hooks[hookName]; + plugins.hooks[hookName] = []; + } + // Note: This is a shallow copy. + backups.settings = Object.assign({}, settings); + settings.requireAuthentication = false; + settings.requireAuthorization = false; + settings.users = { user: { password: "user-password" } }; + }); - afterEach(async function () { - Object.assign(plugins.hooks, backups.hooks); - // Note: This does not unset settings that were added. - Object.assign(settings, backups.settings); - }); + afterEach(async function () { + Object.assign(plugins.hooks, backups.hooks); + // Note: This does not unset settings that were added. + Object.assign(settings, backups.settings); + }); - it('creates a new Pad, imports content to it, checks that content', async function () { - await agent.get(`${endPoint('createPad')}?padID=${testPadId}`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => assert.equal(res.body.code, 0)); - await agent.post(`/p/${testPadId}/import`) - .set("authorization", await common.generateJWTToken()) - .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) - .expect(200); - await agent.get(`${endPoint('getText')}?padID=${testPadId}`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .expect((res:any) => assert.equal(res.body.data.text, padText.toString())); - }); + it("creates a new Pad, imports content to it, checks that content", async function () { + await agent + .get(`${endPoint("createPad")}?padID=${testPadId}`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => assert.equal(res.body.code, 0)); + await agent + .post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) + .attach("file", padText, { + filename: "/test.txt", + contentType: "text/plain", + }) + .expect(200); + await agent + .get(`${endPoint("getText")}?padID=${testPadId}`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .expect((res: any) => + assert.equal(res.body.data.text, padText.toString()), + ); + }); - describe('export from read-only pad ID', function () { - let readOnlyId:string; + describe("export from read-only pad ID", function () { + let readOnlyId: string; - // This ought to be before(), but it must run after the top-level beforeEach() above. - beforeEach(async function () { - if (readOnlyId != null) return; - await agent.post(`/p/${testPadId}/import`) - .set("authorization", await common.generateJWTToken()) - .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) - .expect(200); - const res = await agent.get(`${endPoint('getReadOnlyID')}?padID=${testPadId}`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => assert.equal(res.body.code, 0)); - readOnlyId = res.body.data.readOnlyID; - }); + // This ought to be before(), but it must run after the top-level beforeEach() above. + beforeEach(async function () { + if (readOnlyId != null) return; + await agent + .post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) + .attach("file", padText, { + filename: "/test.txt", + contentType: "text/plain", + }) + .expect(200); + const res = await agent + .get(`${endPoint("getReadOnlyID")}?padID=${testPadId}`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => assert.equal(res.body.code, 0)); + readOnlyId = res.body.data.readOnlyID; + }); - for (const authn of [false, true]) { - describe(`requireAuthentication = ${authn}`, function () { - // This ought to be before(), but it must run after the top-level beforeEach() above. - beforeEach(async function () { - settings.requireAuthentication = authn; - }); + for (const authn of [false, true]) { + describe(`requireAuthentication = ${authn}`, function () { + // This ought to be before(), but it must run after the top-level beforeEach() above. + beforeEach(async function () { + settings.requireAuthentication = authn; + }); - for (const exportType of ['html', 'txt', 'etherpad']) { - describe(`export to ${exportType}`, function () { - let text:string; + for (const exportType of ["html", "txt", "etherpad"]) { + describe(`export to ${exportType}`, function () { + let text: string; - // This ought to be before(), but it must run after the top-level beforeEach() above. - beforeEach(async function () { - if (text != null) return; - let req = agent.get(`/p/${readOnlyId}/export/${exportType}`) - .set("authorization", await common.generateJWTToken()); - if (authn) req = req.auth('user', 'user-password'); - const res = await req - .expect(200) - .buffer(true).parse(superagent.parse.text); - text = res.text; - }); + // This ought to be before(), but it must run after the top-level beforeEach() above. + beforeEach(async function () { + if (text != null) return; + let req = agent + .get(`/p/${readOnlyId}/export/${exportType}`) + .set("authorization", await common.generateJWTToken()); + if (authn) req = req.auth("user", "user-password"); + const res = await req + .expect(200) + .buffer(true) + .parse(superagent.parse.text); + text = res.text; + }); - it('export OK', async function () { - assert.match(text, /This is the/); - }); + it("export OK", async function () { + assert.match(text, /This is the/); + }); - it('writable pad ID is not leaked', async function () { - assert(!text.includes(testPadId)); - }); + it("writable pad ID is not leaked", async function () { + assert(!text.includes(testPadId)); + }); - it('re-import to read-only pad ID gives 403 forbidden', async function () { - let req = agent.post(`/p/${readOnlyId}/import`) - .set("authorization", await common.generateJWTToken()) - .attach('file', Buffer.from(text), { - filename: `/test.${exportType}`, - contentType: 'text/plain', - }); - if (authn) req = req.auth('user', 'user-password'); - await req.expect(403); - }); + it("re-import to read-only pad ID gives 403 forbidden", async function () { + let req = agent + .post(`/p/${readOnlyId}/import`) + .set("authorization", await common.generateJWTToken()) + .attach("file", Buffer.from(text), { + filename: `/test.${exportType}`, + contentType: "text/plain", + }); + if (authn) req = req.auth("user", "user-password"); + await req.expect(403); + }); - it('re-import to read-write pad ID gives 200 OK', async function () { - // The new pad ID must differ from testPadId because Etherpad refuses to import - // .etherpad files on top of a pad that already has edits. - let req = agent.post(`/p/${testPadId}_import/import`) - .set("authorization", await common.generateJWTToken()) - .attach('file', Buffer.from(text), { - filename: `/test.${exportType}`, - contentType: 'text/plain', - }); - if (authn) req = req.auth('user', 'user-password'); - await req.expect(200); - }); - }); - } - }); - } - }); + it("re-import to read-write pad ID gives 200 OK", async function () { + // The new pad ID must differ from testPadId because Etherpad refuses to import + // .etherpad files on top of a pad that already has edits. + let req = agent + .post(`/p/${testPadId}_import/import`) + .set("authorization", await common.generateJWTToken()) + .attach("file", Buffer.from(text), { + filename: `/test.${exportType}`, + contentType: "text/plain", + }); + if (authn) req = req.auth("user", "user-password"); + await req.expect(200); + }); + }); + } + }); + } + }); - describe('Import/Export tests requiring AbiWord/LibreOffice', function () { - before(async function () { - if ((!settings.abiword || settings.abiword.indexOf('/') === -1) && - (!settings.soffice || settings.soffice.indexOf('/') === -1)) { - this.skip(); - } - }); + describe("Import/Export tests requiring AbiWord/LibreOffice", function () { + before(async function () { + if ( + (!settings.abiword || settings.abiword.indexOf("/") === -1) && + (!settings.soffice || settings.soffice.indexOf("/") === -1) + ) { + this.skip(); + } + }); - // For some reason word import does not work in testing.. - // TODO: fix support for .doc files.. - it('Tries to import .doc that uses soffice or abiword', async function () { - await agent.post(`/p/${testPadId}/import`) - .set("authorization", await common.generateJWTToken()) - .attach('file', wordDoc, {filename: '/test.doc', contentType: 'application/msword'}) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => assert.deepEqual(res.body, { - code: 0, - message: 'ok', - data: {directDatabaseAccess: false}, - })); - }); + // For some reason word import does not work in testing.. + // TODO: fix support for .doc files.. + it("Tries to import .doc that uses soffice or abiword", async function () { + await agent + .post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) + .attach("file", wordDoc, { + filename: "/test.doc", + contentType: "application/msword", + }) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => + assert.deepEqual(res.body, { + code: 0, + message: "ok", + data: { directDatabaseAccess: false }, + }), + ); + }); - it('exports DOC', async function () { - await agent.get(`/p/${testPadId}/export/doc`) - .set("authorization", await common.generateJWTToken()) - .buffer(true).parse(superagent.parse['application/octet-stream']) - .expect(200) - .expect((res:any) => assert(res.body.length >= 9000)); - }); + it("exports DOC", async function () { + await agent + .get(`/p/${testPadId}/export/doc`) + .set("authorization", await common.generateJWTToken()) + .buffer(true) + .parse(superagent.parse["application/octet-stream"]) + .expect(200) + .expect((res: any) => assert(res.body.length >= 9000)); + }); - it('Tries to import .docx that uses soffice or abiword', async function () { - await agent.post(`/p/${testPadId}/import`) - .set("authorization", await common.generateJWTToken()) - .attach('file', wordXDoc, { - filename: '/test.docx', - contentType: - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - }) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => assert.deepEqual(res.body, { - code: 0, - message: 'ok', - data: {directDatabaseAccess: false}, - })); - }); + it("Tries to import .docx that uses soffice or abiword", async function () { + await agent + .post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) + .attach("file", wordXDoc, { + filename: "/test.docx", + contentType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => + assert.deepEqual(res.body, { + code: 0, + message: "ok", + data: { directDatabaseAccess: false }, + }), + ); + }); - it('exports DOC from imported DOCX', async function () { - await agent.get(`/p/${testPadId}/export/doc`) - .set("authorization", await common.generateJWTToken()) - .buffer(true).parse(superagent.parse['application/octet-stream']) - .expect(200) - .expect((res:any) => assert(res.body.length >= 9100)); - }); + it("exports DOC from imported DOCX", async function () { + await agent + .get(`/p/${testPadId}/export/doc`) + .set("authorization", await common.generateJWTToken()) + .buffer(true) + .parse(superagent.parse["application/octet-stream"]) + .expect(200) + .expect((res: any) => assert(res.body.length >= 9100)); + }); - it('Tries to import .pdf that uses soffice or abiword', async function () { - await agent.post(`/p/${testPadId}/import`) - .set("authorization", await common.generateJWTToken()) - .attach('file', pdfDoc, {filename: '/test.pdf', contentType: 'application/pdf'}) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => assert.deepEqual(res.body, { - code: 0, - message: 'ok', - data: {directDatabaseAccess: false}, - })); - }); + it("Tries to import .pdf that uses soffice or abiword", async function () { + await agent + .post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) + .attach("file", pdfDoc, { + filename: "/test.pdf", + contentType: "application/pdf", + }) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => + assert.deepEqual(res.body, { + code: 0, + message: "ok", + data: { directDatabaseAccess: false }, + }), + ); + }); - it('exports PDF', async function () { - await agent.get(`/p/${testPadId}/export/pdf`) - .set("authorization", await common.generateJWTToken()) - .buffer(true).parse(superagent.parse['application/octet-stream']) - .expect(200) - .expect((res:any) => assert(res.body.length >= 1000)); - }); + it("exports PDF", async function () { + await agent + .get(`/p/${testPadId}/export/pdf`) + .set("authorization", await common.generateJWTToken()) + .buffer(true) + .parse(superagent.parse["application/octet-stream"]) + .expect(200) + .expect((res: any) => assert(res.body.length >= 1000)); + }); - it('Tries to import .odt that uses soffice or abiword', async function () { - await agent.post(`/p/${testPadId}/import`) - .set("authorization", await common.generateJWTToken()) - .attach('file', odtDoc, {filename: '/test.odt', contentType: 'application/odt'}) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => assert.deepEqual(res.body, { - code: 0, - message: 'ok', - data: {directDatabaseAccess: false}, - })); - }); + it("Tries to import .odt that uses soffice or abiword", async function () { + await agent + .post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) + .attach("file", odtDoc, { + filename: "/test.odt", + contentType: "application/odt", + }) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => + assert.deepEqual(res.body, { + code: 0, + message: "ok", + data: { directDatabaseAccess: false }, + }), + ); + }); - it('exports ODT', async function () { - await agent.get(`/p/${testPadId}/export/odt`) - .set("authorization", await common.generateJWTToken()) - .buffer(true).parse(superagent.parse['application/octet-stream']) - .expect(200) - .expect((res:any) => assert(res.body.length >= 7000)); - }); - }); // End of AbiWord/LibreOffice tests. + it("exports ODT", async function () { + await agent + .get(`/p/${testPadId}/export/odt`) + .set("authorization", await common.generateJWTToken()) + .buffer(true) + .parse(superagent.parse["application/octet-stream"]) + .expect(200) + .expect((res: any) => assert(res.body.length >= 7000)); + }); + }); // End of AbiWord/LibreOffice tests. - it('Tries to import .etherpad', async function () { - this.timeout(3000); - await agent.post(`/p/${testPadId}/import`) - .set("authorization", await common.generateJWTToken()) - .attach('file', etherpadDoc, { - filename: '/test.etherpad', - contentType: 'application/etherpad', - }) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => assert.deepEqual(res.body, { - code: 0, - message: 'ok', - data: {directDatabaseAccess: true}, - })); - }); + it("Tries to import .etherpad", async function () { + this.timeout(3000); + await agent + .post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) + .attach("file", etherpadDoc, { + filename: "/test.etherpad", + contentType: "application/etherpad", + }) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => + assert.deepEqual(res.body, { + code: 0, + message: "ok", + data: { directDatabaseAccess: true }, + }), + ); + }); - it('exports Etherpad', async function () { - this.timeout(3000); - await agent.get(`/p/${testPadId}/export/etherpad`) - .set("authorization", await common.generateJWTToken()) - .buffer(true).parse(superagent.parse.text) - .expect(200) - .expect(/hello/); - }); + it("exports Etherpad", async function () { + this.timeout(3000); + await agent + .get(`/p/${testPadId}/export/etherpad`) + .set("authorization", await common.generateJWTToken()) + .buffer(true) + .parse(superagent.parse.text) + .expect(200) + .expect(/hello/); + }); - it('exports HTML for this Etherpad file', async function () { - this.timeout(3000); - await agent.get(`/p/${testPadId}/export/html`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .expect('content-type', 'text/html; charset=utf-8') - .expect(/
        • hello<\/ul><\/li><\/ul>/); - }); + it("exports HTML for this Etherpad file", async function () { + this.timeout(3000); + await agent + .get(`/p/${testPadId}/export/html`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .expect("content-type", "text/html; charset=utf-8") + .expect( + /
            • hello<\/ul><\/li><\/ul>/, + ); + }); - it('Tries to import unsupported file type', async function () { - this.timeout(3000); - settings.allowUnknownFileEnds = false; - await agent.post(`/p/${testPadId}/import`) - .set("authorization", await common.generateJWTToken()) - .attach('file', padText, {filename: '/test.xasdasdxx', contentType: 'weirdness/jobby'}) - .expect(400) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 1); - assert.equal(res.body.message, 'uploadFailed'); - }); - }); + it("Tries to import unsupported file type", async function () { + this.timeout(3000); + settings.allowUnknownFileEnds = false; + await agent + .post(`/p/${testPadId}/import`) + .set("authorization", await common.generateJWTToken()) + .attach("file", padText, { + filename: "/test.xasdasdxx", + contentType: "weirdness/jobby", + }) + .expect(400) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 1); + assert.equal(res.body.message, "uploadFailed"); + }); + }); - describe('malformed .etherpad files are rejected', function () { - const makeGoodExport = () => ({ - 'pad:testing': { - atext: { - text: 'foo\n', - attribs: '|1+4', - }, - pool: { - numToAttrib: { - 0: ['author', 'a.foo'], - }, - nextNum: 1, - }, - chatHead: 0, - head: 0, - savedRevisions: [], - }, - 'globalAuthor:a.foo': { - colorId: '#000000', - name: 'author foo', - timestamp: 1598747784631, - padIDs: 'testing', - }, - 'pad:testing:revs:0': { - changeset: 'Z:1>3+3$foo', - meta: { - author: 'a.foo', - timestamp: 1597632398288, - pool: { - numToAttrib: {}, - nextNum: 0, - }, - atext: { - text: 'foo\n', - attribs: '|1+4', - }, - }, - }, - 'pad:testing:chat:0': { - text: 'this is a test', - authorId: 'a.foo', - time: 1637966993265, - }, - }); + describe("malformed .etherpad files are rejected", function () { + const makeGoodExport = () => ({ + "pad:testing": { + atext: { + text: "foo\n", + attribs: "|1+4", + }, + pool: { + numToAttrib: { + 0: ["author", "a.foo"], + }, + nextNum: 1, + }, + chatHead: 0, + head: 0, + savedRevisions: [], + }, + "globalAuthor:a.foo": { + colorId: "#000000", + name: "author foo", + timestamp: 1598747784631, + padIDs: "testing", + }, + "pad:testing:revs:0": { + changeset: "Z:1>3+3$foo", + meta: { + author: "a.foo", + timestamp: 1597632398288, + pool: { + numToAttrib: {}, + nextNum: 0, + }, + atext: { + text: "foo\n", + attribs: "|1+4", + }, + }, + }, + "pad:testing:chat:0": { + text: "this is a test", + authorId: "a.foo", + time: 1637966993265, + }, + }); - const importEtherpad = (records:any) => agent.post(`/p/${testPadId}/import`) - .attach('file', Buffer.from(JSON.stringify(records), 'utf8'), { - filename: '/test.etherpad', - contentType: 'application/etherpad', - }); + const importEtherpad = (records: any) => + agent + .post(`/p/${testPadId}/import`) + .attach("file", Buffer.from(JSON.stringify(records), "utf8"), { + filename: "/test.etherpad", + contentType: "application/etherpad", + }); - before(async function () { - // makeGoodExport() is assumed to produce good .etherpad records. Verify that assumption so - // that a buggy makeGoodExport() doesn't cause checks to accidentally pass. - const records = makeGoodExport(); - await deleteTestPad(); - const importedPads = await importEtherpad(records) - console.log(importedPads) - await importEtherpad(records) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => assert.deepEqual(res.body, { - code: 0, - message: 'ok', - data: {directDatabaseAccess: true}, - })); - await agent.get(`/p/${testPadId}/export/txt`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .buffer(true).parse(superagent.parse.text) - .expect((res:any) => assert.match(res.text, /foo/)); - }); + before(async function () { + // makeGoodExport() is assumed to produce good .etherpad records. Verify that assumption so + // that a buggy makeGoodExport() doesn't cause checks to accidentally pass. + const records = makeGoodExport(); + await deleteTestPad(); + const importedPads = await importEtherpad(records); + console.log(importedPads); + await importEtherpad(records) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => + assert.deepEqual(res.body, { + code: 0, + message: "ok", + data: { directDatabaseAccess: true }, + }), + ); + await agent + .get(`/p/${testPadId}/export/txt`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .buffer(true) + .parse(superagent.parse.text) + .expect((res: any) => assert.match(res.text, /foo/)); + }); - it('missing rev', async function () { - const records:MapArrayType = makeGoodExport(); - delete records['pad:testing:revs:0']; - importEtherpad(records).expect(500); - }); + it("missing rev", async function () { + const records: MapArrayType = makeGoodExport(); + delete records["pad:testing:revs:0"]; + importEtherpad(records).expect(500); + }); - it('bad changeset', async function () { - const records = makeGoodExport(); - records['pad:testing:revs:0'].changeset = 'garbage'; - importEtherpad(records).expect(500); - }); + it("bad changeset", async function () { + const records = makeGoodExport(); + records["pad:testing:revs:0"].changeset = "garbage"; + importEtherpad(records).expect(500); + }); - it('missing attrib in pool', async function () { - const records = makeGoodExport(); - records['pad:testing'].pool.nextNum++; - (importEtherpad(records)).expect(500); - }); + it("missing attrib in pool", async function () { + const records = makeGoodExport(); + records["pad:testing"].pool.nextNum++; + importEtherpad(records).expect(500); + }); - it('extra attrib in pool', async function () { - const records = makeGoodExport(); - const pool = records['pad:testing'].pool; - // @ts-ignore - pool.numToAttrib[pool.nextNum] = ['key', 'value']; - (importEtherpad(records)).expect(500); - }); + it("extra attrib in pool", async function () { + const records = makeGoodExport(); + const pool = records["pad:testing"].pool; + // @ts-ignore + pool.numToAttrib[pool.nextNum] = ["key", "value"]; + importEtherpad(records).expect(500); + }); - it('changeset refers to non-existent attrib', async function () { - const records:MapArrayType = makeGoodExport(); - records['pad:testing:revs:1'] = { - changeset: 'Z:4>4*1+4$asdf', - meta: { - author: 'a.foo', - timestamp: 1597632398288, - }, - }; - records['pad:testing'].head = 1; - records['pad:testing'].atext = { - text: 'asdffoo\n', - attribs: '*1+4|1+4', - }; - (importEtherpad(records)).expect(500); - }); + it("changeset refers to non-existent attrib", async function () { + const records: MapArrayType = makeGoodExport(); + records["pad:testing:revs:1"] = { + changeset: "Z:4>4*1+4$asdf", + meta: { + author: "a.foo", + timestamp: 1597632398288, + }, + }; + records["pad:testing"].head = 1; + records["pad:testing"].atext = { + text: "asdffoo\n", + attribs: "*1+4|1+4", + }; + importEtherpad(records).expect(500); + }); - it('pad atext does not match', async function () { - const records = makeGoodExport(); - records['pad:testing'].atext.attribs = `*0${records['pad:testing'].atext.attribs}`; - (importEtherpad(records)).expect(500); - }); + it("pad atext does not match", async function () { + const records = makeGoodExport(); + records["pad:testing"].atext.attribs = + `*0${records["pad:testing"].atext.attribs}`; + importEtherpad(records).expect(500); + }); - it('missing chat message', async function () { - const records:MapArrayType = makeGoodExport(); - delete records['pad:testing:chat:0']; - importEtherpad(records).expect(500); - }); - }); + it("missing chat message", async function () { + const records: MapArrayType = makeGoodExport(); + delete records["pad:testing:chat:0"]; + importEtherpad(records).expect(500); + }); + }); - describe('revisions are supported in txt and html export', function () { - const makeGoodExport = () => ({ - 'pad:testing': { - atext: { - text: 'oofoo\n', - attribs: '|1+6', - }, - pool: { - numToAttrib: { - 0: ['author', 'a.foo'], - }, - nextNum: 1, - }, - head: 2, - savedRevisions: [], - }, - 'globalAuthor:a.foo': { - colorId: '#000000', - name: 'author foo', - timestamp: 1598747784631, - padIDs: 'testing', - }, - 'pad:testing:revs:0': { - changeset: 'Z:1>3+3$foo', - meta: { - author: 'a.foo', - timestamp: 1597632398288, - pool: { - nextNum: 1, - numToAttrib: { - 0: ['author', 'a.foo'], - }, - }, - atext: { - text: 'foo\n', - attribs: '|1+4', - }, - }, - }, - 'pad:testing:revs:1': { - changeset: 'Z:4>1+1$o', - meta: { - author: 'a.foo', - timestamp: 1597632398288, - pool: { - nextNum: 1, - numToAttrib: { - 0: ['author', 'a.foo'], - }, - }, - atext: { - text: 'fooo\n', - attribs: '*0|1+5', - }, - }, - }, - 'pad:testing:revs:2': { - changeset: 'Z:5>1+1$o', - meta: { - author: 'a.foo', - timestamp: 1597632398288, - pool: { - numToAttrib: {}, - nextNum: 0, - }, - atext: { - text: 'foooo\n', - attribs: '*0|1+6', - }, - }, - }, - }); + describe("revisions are supported in txt and html export", function () { + const makeGoodExport = () => ({ + "pad:testing": { + atext: { + text: "oofoo\n", + attribs: "|1+6", + }, + pool: { + numToAttrib: { + 0: ["author", "a.foo"], + }, + nextNum: 1, + }, + head: 2, + savedRevisions: [], + }, + "globalAuthor:a.foo": { + colorId: "#000000", + name: "author foo", + timestamp: 1598747784631, + padIDs: "testing", + }, + "pad:testing:revs:0": { + changeset: "Z:1>3+3$foo", + meta: { + author: "a.foo", + timestamp: 1597632398288, + pool: { + nextNum: 1, + numToAttrib: { + 0: ["author", "a.foo"], + }, + }, + atext: { + text: "foo\n", + attribs: "|1+4", + }, + }, + }, + "pad:testing:revs:1": { + changeset: "Z:4>1+1$o", + meta: { + author: "a.foo", + timestamp: 1597632398288, + pool: { + nextNum: 1, + numToAttrib: { + 0: ["author", "a.foo"], + }, + }, + atext: { + text: "fooo\n", + attribs: "*0|1+5", + }, + }, + }, + "pad:testing:revs:2": { + changeset: "Z:5>1+1$o", + meta: { + author: "a.foo", + timestamp: 1597632398288, + pool: { + numToAttrib: {}, + nextNum: 0, + }, + atext: { + text: "foooo\n", + attribs: "*0|1+6", + }, + }, + }, + }); - const importEtherpad = (records: MapArrayType) => agent.post(`/p/${testPadId}/import`) - .attach('file', Buffer.from(JSON.stringify(records), 'utf8'), { - filename: '/test.etherpad', - contentType: 'application/etherpad', - }); + const importEtherpad = (records: MapArrayType) => + agent + .post(`/p/${testPadId}/import`) + .attach("file", Buffer.from(JSON.stringify(records), "utf8"), { + filename: "/test.etherpad", + contentType: "application/etherpad", + }); - before(async function () { - // makeGoodExport() is assumed to produce good .etherpad records. Verify that assumption so - // that a buggy makeGoodExport() doesn't cause checks to accidentally pass. - const records = makeGoodExport(); - await deleteTestPad(); - await importEtherpad(records) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => assert.deepEqual(res.body, { - code: 0, - message: 'ok', - data: {directDatabaseAccess: true}, - })); - await agent.get(`/p/${testPadId}/export/txt`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .buffer(true).parse(superagent.parse.text) - .expect((res:any) => assert.equal(res.text, 'oofoo\n')); - }); + before(async function () { + // makeGoodExport() is assumed to produce good .etherpad records. Verify that assumption so + // that a buggy makeGoodExport() doesn't cause checks to accidentally pass. + const records = makeGoodExport(); + await deleteTestPad(); + await importEtherpad(records) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => + assert.deepEqual(res.body, { + code: 0, + message: "ok", + data: { directDatabaseAccess: true }, + }), + ); + await agent + .get(`/p/${testPadId}/export/txt`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .buffer(true) + .parse(superagent.parse.text) + .expect((res: any) => assert.equal(res.text, "oofoo\n")); + }); - it('txt request rev 1', async function () { - await agent.get(`/p/${testPadId}/1/export/txt`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .buffer(true).parse(superagent.parse.text) - .expect((res:any) => assert.equal(res.text, 'ofoo\n')); - }); + it("txt request rev 1", async function () { + await agent + .get(`/p/${testPadId}/1/export/txt`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .buffer(true) + .parse(superagent.parse.text) + .expect((res: any) => assert.equal(res.text, "ofoo\n")); + }); - it('txt request rev 2', async function () { - await agent.get(`/p/${testPadId}/2/export/txt`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .buffer(true).parse(superagent.parse.text) - .expect((res:any) => assert.equal(res.text, 'oofoo\n')); - }); + it("txt request rev 2", async function () { + await agent + .get(`/p/${testPadId}/2/export/txt`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .buffer(true) + .parse(superagent.parse.text) + .expect((res: any) => assert.equal(res.text, "oofoo\n")); + }); - it('txt request rev 1test returns rev 1', async function () { - await agent.get(`/p/${testPadId}/1test/export/txt`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .buffer(true).parse(superagent.parse.text) - .expect((res:any) => assert.equal(res.text, 'ofoo\n')); - }); + it("txt request rev 1test returns rev 1", async function () { + await agent + .get(`/p/${testPadId}/1test/export/txt`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .buffer(true) + .parse(superagent.parse.text) + .expect((res: any) => assert.equal(res.text, "ofoo\n")); + }); - it('txt request rev test1 is 403', async function () { - await agent.get(`/p/${testPadId}/test1/export/txt`) - .set("authorization", await common.generateJWTToken()) - .expect(500) - .buffer(true).parse(superagent.parse.text) - .expect((res:any) => assert.match(res.text, /rev is not a number/)); - }); + it("txt request rev test1 is 403", async function () { + await agent + .get(`/p/${testPadId}/test1/export/txt`) + .set("authorization", await common.generateJWTToken()) + .expect(500) + .buffer(true) + .parse(superagent.parse.text) + .expect((res: any) => assert.match(res.text, /rev is not a number/)); + }); - it('txt request rev 5 returns head rev', async function () { - await agent.get(`/p/${testPadId}/5/export/txt`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .buffer(true).parse(superagent.parse.text) - .expect((res:any) => assert.equal(res.text, 'oofoo\n')); - }); + it("txt request rev 5 returns head rev", async function () { + await agent + .get(`/p/${testPadId}/5/export/txt`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .buffer(true) + .parse(superagent.parse.text) + .expect((res: any) => assert.equal(res.text, "oofoo\n")); + }); - it('html request rev 1', async function () { - await agent.get(`/p/${testPadId}/1/export/html`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .buffer(true).parse(superagent.parse.text) - .expect((res:any) => assert.match(res.text, /ofoo
              /)); - }); + it("html request rev 1", async function () { + await agent + .get(`/p/${testPadId}/1/export/html`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .buffer(true) + .parse(superagent.parse.text) + .expect((res: any) => assert.match(res.text, /ofoo
              /)); + }); - it('html request rev 2', async function () { - await agent.get(`/p/${testPadId}/2/export/html`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .buffer(true).parse(superagent.parse.text) - .expect((res:any) => assert.match(res.text, /oofoo
              /)); - }); + it("html request rev 2", async function () { + await agent + .get(`/p/${testPadId}/2/export/html`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .buffer(true) + .parse(superagent.parse.text) + .expect((res: any) => assert.match(res.text, /oofoo
              /)); + }); - it('html request rev 1test returns rev 1', async function () { - await agent.get(`/p/${testPadId}/1test/export/html`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .buffer(true).parse(superagent.parse.text) - .expect((res:any) => assert.match(res.text, /ofoo
              /)); - }); + it("html request rev 1test returns rev 1", async function () { + await agent + .get(`/p/${testPadId}/1test/export/html`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .buffer(true) + .parse(superagent.parse.text) + .expect((res: any) => assert.match(res.text, /ofoo
              /)); + }); - it('html request rev test1 results in 500 response', async function () { - await agent.get(`/p/${testPadId}/test1/export/html`) - .set("authorization", await common.generateJWTToken()) - .expect(500) - .buffer(true).parse(superagent.parse.text) - .expect((res:any) => assert.match(res.text, /rev is not a number/)); - }); + it("html request rev test1 results in 500 response", async function () { + await agent + .get(`/p/${testPadId}/test1/export/html`) + .set("authorization", await common.generateJWTToken()) + .expect(500) + .buffer(true) + .parse(superagent.parse.text) + .expect((res: any) => assert.match(res.text, /rev is not a number/)); + }); - it('html request rev 5 returns head rev', async function () { - await agent.get(`/p/${testPadId}/5/export/html`) - .set("authorization", await common.generateJWTToken()) - .expect(200) - .buffer(true).parse(superagent.parse.text) - .expect((res:any) => assert.match(res.text, /oofoo
              /)); - }); - }); + it("html request rev 5 returns head rev", async function () { + await agent + .get(`/p/${testPadId}/5/export/html`) + .set("authorization", await common.generateJWTToken()) + .expect(200) + .buffer(true) + .parse(superagent.parse.text) + .expect((res: any) => assert.match(res.text, /oofoo
              /)); + }); + }); - describe('Import authorization checks', function () { - let authorize: (arg0: any) => any; + describe("Import authorization checks", function () { + let authorize: (arg0: any) => any; - const createTestPad = async (text:string) => { - const pad = await padManager.getPad(testPadId); - if (text) await pad.setText(text); - return pad; - }; + const createTestPad = async (text: string) => { + const pad = await padManager.getPad(testPadId); + if (text) await pad.setText(text); + return pad; + }; - this.timeout(1000); + this.timeout(1000); - beforeEach(async function () { - await deleteTestPad(); - settings.requireAuthorization = true; - authorize = () => true; - plugins.hooks.authorize = [{hook_fn: (hookName: string, {req}:any, cb:Function) => cb([authorize(req)])}]; - }); + beforeEach(async function () { + await deleteTestPad(); + settings.requireAuthorization = true; + authorize = () => true; + plugins.hooks.authorize = [ + { + hook_fn: (hookName: string, { req }: any, cb: Function) => + cb([authorize(req)]), + }, + ]; + }); - afterEach(async function () { - await deleteTestPad(); - }); + afterEach(async function () { + await deleteTestPad(); + }); - it('!authn !exist -> create', async function () { - await agent.post(`/p/${testPadIdEnc}/import`) - .set("authorization", await common.generateJWTToken()) - .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) - .expect(200); - assert(await padManager.doesPadExist(testPadId)); - const pad = await padManager.getPad(testPadId); - assert.equal(pad.text(), padText.toString()); - }); + it("!authn !exist -> create", async function () { + await agent + .post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) + .attach("file", padText, { + filename: "/test.txt", + contentType: "text/plain", + }) + .expect(200); + assert(await padManager.doesPadExist(testPadId)); + const pad = await padManager.getPad(testPadId); + assert.equal(pad.text(), padText.toString()); + }); - it('!authn exist -> replace', async function () { - const pad = await createTestPad('before import'); - await agent.post(`/p/${testPadIdEnc}/import`) - .set("authorization", await common.generateJWTToken()) - .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) - .expect(200); - assert(await padManager.doesPadExist(testPadId)); - assert.equal(pad.text(), padText.toString()); - }); + it("!authn exist -> replace", async function () { + const pad = await createTestPad("before import"); + await agent + .post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) + .attach("file", padText, { + filename: "/test.txt", + contentType: "text/plain", + }) + .expect(200); + assert(await padManager.doesPadExist(testPadId)); + assert.equal(pad.text(), padText.toString()); + }); - it('authn anonymous !exist -> fail', async function () { - settings.requireAuthentication = true; - await agent.post(`/p/${testPadIdEnc}/import`) - .set("authorization", await common.generateJWTToken()) - .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) - .expect(401); - assert(!(await padManager.doesPadExist(testPadId))); - }); + it("authn anonymous !exist -> fail", async function () { + settings.requireAuthentication = true; + await agent + .post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) + .attach("file", padText, { + filename: "/test.txt", + contentType: "text/plain", + }) + .expect(401); + assert(!(await padManager.doesPadExist(testPadId))); + }); - it('authn anonymous exist -> fail', async function () { - settings.requireAuthentication = true; - const pad = await createTestPad('before import\n'); - await agent.post(`/p/${testPadIdEnc}/import`) - .set("authorization", await common.generateJWTToken()) - .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) - .expect(401); - assert.equal(pad.text(), 'before import\n'); - }); + it("authn anonymous exist -> fail", async function () { + settings.requireAuthentication = true; + const pad = await createTestPad("before import\n"); + await agent + .post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) + .attach("file", padText, { + filename: "/test.txt", + contentType: "text/plain", + }) + .expect(401); + assert.equal(pad.text(), "before import\n"); + }); - it('authn user create !exist -> create', async function () { - settings.requireAuthentication = true; - await agent.post(`/p/${testPadIdEnc}/import`) - .set("authorization", await common.generateJWTToken()) - .auth('user', 'user-password') - .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) - .expect(200); - assert(await padManager.doesPadExist(testPadId)); - const pad = await padManager.getPad(testPadId); - assert.equal(pad.text(), padText.toString()); - }); + it("authn user create !exist -> create", async function () { + settings.requireAuthentication = true; + await agent + .post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) + .auth("user", "user-password") + .attach("file", padText, { + filename: "/test.txt", + contentType: "text/plain", + }) + .expect(200); + assert(await padManager.doesPadExist(testPadId)); + const pad = await padManager.getPad(testPadId); + assert.equal(pad.text(), padText.toString()); + }); - it('authn user modify !exist -> fail', async function () { - settings.requireAuthentication = true; - authorize = () => 'modify'; - await agent.post(`/p/${testPadIdEnc}/import`) - .set("authorization", await common.generateJWTToken()) - .auth('user', 'user-password') - .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) - .expect(403); - assert(!(await padManager.doesPadExist(testPadId))); - }); + it("authn user modify !exist -> fail", async function () { + settings.requireAuthentication = true; + authorize = () => "modify"; + await agent + .post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) + .auth("user", "user-password") + .attach("file", padText, { + filename: "/test.txt", + contentType: "text/plain", + }) + .expect(403); + assert(!(await padManager.doesPadExist(testPadId))); + }); - it('authn user readonly !exist -> fail', async function () { - settings.requireAuthentication = true; - authorize = () => 'readOnly'; - await agent.post(`/p/${testPadIdEnc}/import`) - .set("authorization", await common.generateJWTToken()) - .auth('user', 'user-password') - .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) - .expect(403); - assert(!(await padManager.doesPadExist(testPadId))); - }); + it("authn user readonly !exist -> fail", async function () { + settings.requireAuthentication = true; + authorize = () => "readOnly"; + await agent + .post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) + .auth("user", "user-password") + .attach("file", padText, { + filename: "/test.txt", + contentType: "text/plain", + }) + .expect(403); + assert(!(await padManager.doesPadExist(testPadId))); + }); - it('authn user create exist -> replace', async function () { - settings.requireAuthentication = true; - const pad = await createTestPad('before import\n'); - await agent.post(`/p/${testPadIdEnc}/import`) - .set("authorization", await common.generateJWTToken()) - .auth('user', 'user-password') - .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) - .expect(200); - assert.equal(pad.text(), padText.toString()); - }); + it("authn user create exist -> replace", async function () { + settings.requireAuthentication = true; + const pad = await createTestPad("before import\n"); + await agent + .post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) + .auth("user", "user-password") + .attach("file", padText, { + filename: "/test.txt", + contentType: "text/plain", + }) + .expect(200); + assert.equal(pad.text(), padText.toString()); + }); - it('authn user modify exist -> replace', async function () { - settings.requireAuthentication = true; - authorize = () => 'modify'; - const pad = await createTestPad('before import\n'); - await agent.post(`/p/${testPadIdEnc}/import`) - .set("authorization", await common.generateJWTToken()) - .auth('user', 'user-password') - .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) - .expect(200); - assert.equal(pad.text(), padText.toString()); - }); + it("authn user modify exist -> replace", async function () { + settings.requireAuthentication = true; + authorize = () => "modify"; + const pad = await createTestPad("before import\n"); + await agent + .post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) + .auth("user", "user-password") + .attach("file", padText, { + filename: "/test.txt", + contentType: "text/plain", + }) + .expect(200); + assert.equal(pad.text(), padText.toString()); + }); - it('authn user readonly exist -> fail', async function () { - const pad = await createTestPad('before import\n'); - settings.requireAuthentication = true; - authorize = () => 'readOnly'; - await agent.post(`/p/${testPadIdEnc}/import`) - .set("authorization", await common.generateJWTToken()) - .auth('user', 'user-password') - .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'}) - .expect(403); - assert.equal(pad.text(), 'before import\n'); - }); - }); - }); + it("authn user readonly exist -> fail", async function () { + const pad = await createTestPad("before import\n"); + settings.requireAuthentication = true; + authorize = () => "readOnly"; + await agent + .post(`/p/${testPadIdEnc}/import`) + .set("authorization", await common.generateJWTToken()) + .auth("user", "user-password") + .attach("file", padText, { + filename: "/test.txt", + contentType: "text/plain", + }) + .expect(403); + assert.equal(pad.text(), "before import\n"); + }); + }); + }); }); // End of tests. - -const endPoint = (point: string, version?:string) => { - return `/api/${version || apiVersion}/${point}`; +const endPoint = (point: string, version?: string) => { + return `/api/${version || apiVersion}/${point}`; }; function makeid() { - let text = ''; - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - for (let i = 0; i < 5; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - return text; + for (let i = 0; i < 5; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; } diff --git a/src/tests/backend/specs/api/instance.ts b/src/tests/backend/specs/api/instance.ts index 2bf51bf86..c5f58fc6e 100644 --- a/src/tests/backend/specs/api/instance.ts +++ b/src/tests/backend/specs/api/instance.ts @@ -1,54 +1,75 @@ -'use strict'; +"use strict"; /* * Tests for the instance-level APIs * * Section "GLOBAL FUNCTIONS" in src/node/db/API.js */ -const common = require('../../common'); +const common = require("../../common"); -let agent:any; -const apiVersion = '1.2.14'; +let agent: any; +const apiVersion = "1.2.14"; -const endPoint = (point: string, version?: number) => `/api/${version || apiVersion}/${point}`; +const endPoint = (point: string, version?: number) => + `/api/${version || apiVersion}/${point}`; describe(__filename, function () { - before(async function () { agent = await common.init(); }); + before(async function () { + agent = await common.init(); + }); - describe('Connectivity for instance-level API tests', function () { - it('can connect', async function () { - await agent.get('/api/') - .expect('Content-Type', /json/) - .expect(200); - }); - }); + describe("Connectivity for instance-level API tests", function () { + it("can connect", async function () { + await agent.get("/api/").expect("Content-Type", /json/).expect(200); + }); + }); - describe('getStats', function () { - it('Gets the stats of a running instance', async function () { - await agent.get(endPoint('getStats')) - .set("Authorization", await common.generateJWTToken()) - .expect((res:any) => { - if (res.body.code !== 0) throw new Error('getStats() failed'); + describe("getStats", function () { + it("Gets the stats of a running instance", async function () { + await agent + .get(endPoint("getStats")) + .set("Authorization", await common.generateJWTToken()) + .expect((res: any) => { + if (res.body.code !== 0) throw new Error("getStats() failed"); - if (!('totalPads' in res.body.data && typeof res.body.data.totalPads === 'number')) { - throw new Error('Response to getStats() does not contain field totalPads, or ' + - `it's not a number: ${JSON.stringify(res.body.data)}`); - } + if ( + !( + "totalPads" in res.body.data && + typeof res.body.data.totalPads === "number" + ) + ) { + throw new Error( + "Response to getStats() does not contain field totalPads, or " + + `it's not a number: ${JSON.stringify(res.body.data)}`, + ); + } - if (!('totalSessions' in res.body.data && - typeof res.body.data.totalSessions === 'number')) { - throw new Error('Response to getStats() does not contain field totalSessions, or ' + - `it's not a number: ${JSON.stringify(res.body.data)}`); - } + if ( + !( + "totalSessions" in res.body.data && + typeof res.body.data.totalSessions === "number" + ) + ) { + throw new Error( + "Response to getStats() does not contain field totalSessions, or " + + `it's not a number: ${JSON.stringify(res.body.data)}`, + ); + } - if (!('totalActivePads' in res.body.data && - typeof res.body.data.totalActivePads === 'number')) { - throw new Error('Response to getStats() does not contain field totalActivePads, or ' + - `it's not a number: ${JSON.stringify(res.body.data)}`); - } - }) - .expect('Content-Type', /json/) - .expect(200); - }); - }); + if ( + !( + "totalActivePads" in res.body.data && + typeof res.body.data.totalActivePads === "number" + ) + ) { + throw new Error( + "Response to getStats() does not contain field totalActivePads, or " + + `it's not a number: ${JSON.stringify(res.body.data)}`, + ); + } + }) + .expect("Content-Type", /json/) + .expect(200); + }); + }); }); diff --git a/src/tests/backend/specs/api/pad.ts b/src/tests/backend/specs/api/pad.ts index f4d081ef4..974794577 100644 --- a/src/tests/backend/specs/api/pad.ts +++ b/src/tests/backend/specs/api/pad.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /* * ACHTUNG: there is a copied & modified version of this file in @@ -7,67 +7,77 @@ * TODO: unify those two files, and merge in a single one. */ -const assert = require('assert').strict; -const common = require('../../common'); -const padManager = require('../../../../node/db/PadManager'); +const assert = require("assert").strict; +const common = require("../../common"); +const padManager = require("../../../../node/db/PadManager"); -let agent:any; +let agent: any; let apiVersion = 1; const testPadId = makeid(); const newPadId = makeid(); const copiedPadId = makeid(); const anotherPadId = makeid(); -let lastEdited = ''; +let lastEdited = ""; const text = generateLongText(); -const endPoint = (point: string, version?: string) => `/api/${version || apiVersion}/${point}`; +const endPoint = (point: string, version?: string) => + `/api/${version || apiVersion}/${point}`; /* * Html document with nested lists of different types, to test its import and * verify it is exported back correctly */ -const ulHtml = '
              • one
              • two
              • 0
              • 1
              • 2
                • 3
                • 4
              1. item
                1. item1
                2. item2
              '; +const ulHtml = + '
              • one
              • two
              • 0
              • 1
              • 2
                • 3
                • 4
              1. item
                1. item1
                2. item2
              '; /* * When exported back, Etherpad produces an html which is not exactly the same * textually, but at least it remains standard compliant and has an equal DOM * structure. */ -const expectedHtml = '
              • one
              • two
              • 0
              • 1
              • 2
                • 3
                • 4
              1. item
                1. item1
                2. item2
              '; +const expectedHtml = + '
              • one
              • two
              • 0
              • 1
              • 2
                • 3
                • 4
              1. item
                1. item1
                2. item2
              '; /* * Html document with space between list items, to test its import and * verify it is exported back correctly */ -const ulSpaceHtml = '
              • one
              '; +const ulSpaceHtml = + '
              • one
              '; /* * When exported back, Etherpad produces an html which is not exactly the same * textually, but at least it remains standard compliant and has an equal DOM * structure. */ -const expectedSpaceHtml = '
              • one
              '; +const expectedSpaceHtml = + '
              • one
              '; describe(__filename, function () { - before(async function () { - agent = await common.init(); - const res = await agent.get('/api/') - .expect(200) - .expect('Content-Type', /json/); - apiVersion = res.body.currentVersion; - assert(apiVersion); - }); + before(async function () { + agent = await common.init(); + const res = await agent + .get("/api/") + .expect(200) + .expect("Content-Type", /json/); + apiVersion = res.body.currentVersion; + assert(apiVersion); + }); - describe('Sanity checks', function () { - it('errors with invalid oauth token', async function () { - // This is broken because Etherpad doesn't handle HTTP codes properly see #2343 - await agent.get(`/api/${apiVersion}/createPad?padID=test`) - .set("Authorization", (await common.generateJWTToken()).substring(0, 10)) - .expect(401); - }); - }); + describe("Sanity checks", function () { + it("errors with invalid oauth token", async function () { + // This is broken because Etherpad doesn't handle HTTP codes properly see #2343 + await agent + .get(`/api/${apiVersion}/createPad?padID=test`) + .set( + "Authorization", + (await common.generateJWTToken()).substring(0, 10), + ) + .expect(401); + }); + }); - /* Pad Tests Order of execution + /* Pad Tests Order of execution -> deletePad -- This gives us a guaranteed clear environment -> createPad -> getRevisions -- Should be 0 @@ -110,624 +120,725 @@ describe(__filename, function () { */ - describe('Tests', function () { - it('deletes a Pad that does not exist', async function () { - await agent.get(`${endPoint('deletePad')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) // @TODO: we shouldn't expect 200 here since the pad may not exist - .expect('Content-Type', /json/); - }); - - it('creates a new Pad', async function () { - const res = await agent.get(`${endPoint('createPad')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it('gets revision count of Pad', async function () { - const res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - assert.equal(res.body.data.revisions, 0); - }); - - it('gets saved revisions count of Pad', async function () { - const res = await agent.get(`${endPoint('getSavedRevisionsCount')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - assert.equal(res.body.data.savedRevisions, 0); - }); - - it('gets saved revision list of Pad', async function () { - const res = await agent.get(`${endPoint('listSavedRevisions')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - assert.deepEqual(res.body.data.savedRevisions, []); - }); - - it('get the HTML of Pad', async function () { - const res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert(res.body.data.html.length > 1); - }); - - it('list all pads', async function () { - const res = await agent.get(endPoint('listAllPads')) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert(res.body.data.padIDs.includes(testPadId)); - }); - - it('deletes the Pad', async function () { - const res = await agent.get(`${endPoint('deletePad')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it('list all pads again', async function () { - const res = await agent.get(endPoint('listAllPads')) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert(!res.body.data.padIDs.includes(testPadId)); - }); - - it('get the HTML of a Pad -- Should return a failure', async function () { - const res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 1); - }); - - it('creates a new Pad with text', async function () { - const res = await agent.get(`${endPoint('createPad')}?padID=${testPadId}&text=testText`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it('gets the Pad text and expect it to be testText with trailing \\n', async function () { - const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.data.text, 'testText\n'); - }); - - it('set text', async function () { - const res = await agent.post(endPoint('setText')) - .set("Authorization", (await common.generateJWTToken())) - .send({ - padID: testPadId, - text: 'testTextTwo', - }) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it('gets the Pad text', async function () { - const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.data.text, 'testTextTwo\n'); - }); - - it('gets Revision Count of a Pad', async function () { - const res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.data.revisions, 1); - }); - - it('saves Revision', async function () { - const res = await agent.get(`${endPoint('saveRevision')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it('gets saved revisions count of Pad again', async function () { - const res = await agent.get(`${endPoint('getSavedRevisionsCount')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - assert.equal(res.body.data.savedRevisions, 1); - }); - - it('gets saved revision list of Pad again', async function () { - const res = await agent.get(`${endPoint('listSavedRevisions')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - assert.deepEqual(res.body.data.savedRevisions, [1]); - }); - - it('gets User Count of a Pad', async function () { - const res = await agent.get(`${endPoint('padUsersCount')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.data.padUsersCount, 0); - }); - - it('Gets the Read Only ID of a Pad', async function () { - const res = await agent.get(`${endPoint('getReadOnlyID')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert(res.body.data.readOnlyID); - }); - - it('Get Authors of the Pad', async function () { - const res = await agent.get(`${endPoint('listAuthorsOfPad')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.data.authorIDs.length, 0); - }); - - it('Get When Pad was left Edited', async function () { - const res = await agent.get(`${endPoint('getLastEdited')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert(res.body.data.lastEdited); - lastEdited = res.body.data.lastEdited; - }); - - it('set text again', async function () { - const res = await agent.post(endPoint('setText')) - .set("Authorization", (await common.generateJWTToken())) - .send({ - padID: testPadId, - text: 'testTextThree', - }) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it('Get When Pad was left Edited again', async function () { - const res = await agent.get(`${endPoint('getLastEdited')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert(res.body.data.lastEdited > lastEdited); - }); - - it('gets User Count of a Pad again', async function () { - const res = await agent.get(`${endPoint('padUsers')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.data.padUsers.length, 0); - }); - - it('deletes a Pad', async function () { - const res = await agent.get(`${endPoint('deletePad')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it('creates the Pad again', async function () { - const res = await agent.get(`${endPoint('createPad')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it('Sets text on a pad Id', async function () { - const res = await agent.post(`${endPoint('setText')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .field({text}) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it('Gets text on a pad Id', async function () { - const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - assert.equal(res.body.data.text, `${text}\n`); - }); - - it('Sets text on a pad Id including an explicit newline', async function () { - const res = await agent.post(`${endPoint('setText')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .field({text: `${text}\n`}) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it("Gets text on a pad Id and doesn't have an excess newline", async function () { - const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - assert.equal(res.body.data.text, `${text}\n`); - }); - - it('Gets when pad was last edited', async function () { - const res = await agent.get(`${endPoint('getLastEdited')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.notEqual(res.body.lastEdited, 0); - }); - - it('Move a Pad to a different Pad ID', async function () { - const res = await agent.get( - `${endPoint('movePad')}?sourceID=${testPadId}&destinationID=${newPadId}&force=true`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it('Gets text from new pad', async function () { - const res = await agent.get(`${endPoint('getText')}?padID=${newPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.data.text, `${text}\n`); - }); - - it('Move pad back to original ID', async function () { - const res = await agent.get( - `${endPoint('movePad')}?sourceID=${newPadId}&destinationID=${testPadId}&force=false`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it('Get text using original ID', async function () { - const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.data.text, `${text}\n`); - }); - - it('Get last edit of original ID', async function () { - const res = await agent.get(`${endPoint('getLastEdited')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.notEqual(res.body.lastEdited, 0); - }); - - it('Append text to a pad Id', async function () { - let res = await agent.get( - `${endPoint('appendText', '1.2.13')}?padID=${testPadId}&text=hello`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - assert.equal(res.body.data.text, `${text}hello\n`); - }); - - it('getText of old revision', async function () { - let res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - const rev = res.body.data.revisions; - assert(rev != null); - assert(Number.isInteger(rev)); - assert(rev > 0); - res = await agent.get(`${endPoint('getText')}?padID=${testPadId}&rev=${rev - 1}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - assert.equal(res.body.data.text, `${text}\n`); - }); - - it('Sets the HTML of a Pad attempting to pass ugly HTML', async function () { - const html = '
              Hello HTML
              '; - const res = await agent.post(endPoint('setHTML')) - .set("Authorization", (await common.generateJWTToken())) - .send({ - padID: testPadId, - html, - }) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it('Pad with complex nested lists of different types', async function () { - let res = await agent.post(endPoint('setHTML')) - .set("Authorization", (await common.generateJWTToken())) - .send({ - padID: testPadId, - html: ulHtml, - }) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - const receivedHtml = res.body.data.html.replace('
              ', '').toLowerCase(); - assert.equal(receivedHtml, expectedHtml); - }); - - it('Pad with white space between list items', async function () { - let res = await agent.get(`${endPoint('setHTML')}?padID=${testPadId}&html=${ulSpaceHtml}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - const receivedHtml = res.body.data.html.replace('
              ', '').toLowerCase(); - assert.equal(receivedHtml, expectedSpaceHtml); - }); - - it('errors if pad can be created', async function () { - await Promise.all(['/', '%23', '%3F', '%26'].map(async (badUrlChar) => { - const res = await agent.get(`${endPoint('createPad')}?padID=${badUrlChar}`) - .set("Authorization", (await common.generateJWTToken())) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 1); - })); - }); - - it('copies the content of a existent pad', async function () { - const res = await agent.get( - `${endPoint('copyPad')}?sourceID=${testPadId}&destinationID=${copiedPadId}&force=true`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it('does not add an useless revision', async function () { - let res = await agent.post(`${endPoint('setText')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .field({text: 'identical text\n'}) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - - res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.data.text, 'identical text\n'); - - res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - const revCount = res.body.data.revisions; - - res = await agent.post(`${endPoint('setText')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .field({text: 'identical text\n'}) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - - res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${testPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .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=`) - .set("Authorization", (await common.generateJWTToken())) - .expect('Content-Type', /json/) - .expect(200) - .expect((res:any) => { - assert.equal(res.body.code, 0, 'Unable to create new Pad'); - }); - await agent.get(`${endPoint('getText')}?padID=${anotherPadId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect('Content-Type', /json/) - .expect(200) - .expect((res:any) => { - 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}`) - .set("Authorization", (await common.generateJWTToken())) - .expect('Content-Type', /json/) - .expect(200) - .expect((res: any) => { - assert.equal(res.body.code, 0, 'Unable to delete empty Pad'); - }); - }); - }); - - describe('copyPadWithoutHistory', function () { - const sourcePadId = makeid(); - let newPad:string; - - before(async function () { - await createNewPadWithHtml(sourcePadId, ulHtml); - }); - - beforeEach(async function () { - newPad = makeid(); - }); - - it('returns a successful response', async function () { - const res = await agent.get(`${endPoint('copyPadWithoutHistory')}?sourceID=${sourcePadId}` + - `&destinationID=${newPad}&force=false`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - // this test validates if the source pad's text and attributes are kept - it('creates a new pad with the same content as the source pad', async function () { - let res = await agent.get(`${endPoint('copyPadWithoutHistory')}?sourceID=${sourcePadId}` + - `&destinationID=${newPad}&force=false`) - .set("Authorization", (await common.generateJWTToken())); - assert.equal(res.body.code, 0); - res = await agent.get(`${endPoint('getHTML')}?padID=${newPad}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200); - const receivedHtml = res.body.data.html.replace('

              ', '').toLowerCase(); - assert.equal(receivedHtml, expectedHtml); - }); - - it('copying to a non-existent group throws an error', async function () { - const padWithNonExistentGroup = `notExistentGroup$${newPad}`; - const res = await agent.get(`${endPoint('copyPadWithoutHistory')}` + - `?sourceID=${sourcePadId}` + - `&destinationID=${padWithNonExistentGroup}&force=true`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200); - assert.equal(res.body.code, 1); - }); - - describe('copying to an existing pad', function () { - beforeEach(async function () { - await createNewPadWithHtml(newPad, ulHtml); - }); - - it('force=false fails', async function () { - const res = await agent.get(`${endPoint('copyPadWithoutHistory')}` + - `?sourceID=${sourcePadId}` + - `&destinationID=${newPad}&force=false`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200); - assert.equal(res.body.code, 1); - }); - - it('force=true succeeds', async function () { - const res = await agent.get(`${endPoint('copyPadWithoutHistory')}` + - `?sourceID=${sourcePadId}` + - `&destinationID=${newPad}&force=true`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200); - assert.equal(res.body.code, 0); - }); - }); - - // Regression test for https://github.com/ether/etherpad-lite/issues/5296 - it('source and destination attribute pools are independent', async function () { - // Strategy for this test: - // 1. Create a new pad without bold or italic text - // 2. Use copyPadWithoutHistory to copy the pad. - // 3. Add some bold text (but no italic text!) to the source pad. This should add a bold - // attribute to the source pad's pool but not to the destination pad's pool. - // 4. Add some italic text (but no bold text!) to the destination pad. This should add an - // italic attribute to the destination pad's pool with the same number as the newly added - // bold attribute in the source pad's pool. - // 5. Add some more text (bold or plain) to the source pad. This will save the source pad to - // the database after the destination pad has had an opportunity to corrupt the source - // pad. - // 6. Export the source and destination pads. Make sure that doesn't appear in the - // source pad's HTML, and that doesn't appear int he destination pad's HTML. - // 7. Force the server to re-init the pads from the database. - // 8. Repeat step 6. - // If appears in the source pad, or appears in the destination pad, then shared - // state between the two attribute pools caused corruption. - - const getHtml = async (padId:string) => { - const res = await agent.get(`${endPoint('getHTML')}?padID=${padId}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - return res.body.data.html; - }; - - const setBody = async (padId: string, bodyHtml: string) => { - await agent.post(endPoint('setHTML')) - .set("Authorization", (await common.generateJWTToken())) - .send({padID: padId, html: `${bodyHtml}`}) - .expect(200) - .expect('Content-Type', /json/) - .expect((res: any) => assert.equal(res.body.code, 0)); - }; - - const origHtml = await getHtml(sourcePadId); - assert.doesNotMatch(origHtml, //); - assert.doesNotMatch(origHtml, //); - await agent.get(`${endPoint('copyPadWithoutHistory')}?sourceID=${sourcePadId}` + - `&destinationID=${newPad}&force=false`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => assert.equal(res.body.code, 0)); - - const newBodySrc = 'bold'; - const newBodyDst = 'italic'; - await setBody(sourcePadId, newBodySrc); - await setBody(newPad, newBodyDst); - await setBody(sourcePadId, `${newBodySrc} foo`); - - let [srcHtml, dstHtml] = await Promise.all([getHtml(sourcePadId), getHtml(newPad)]); - assert.match(srcHtml, new RegExp(newBodySrc)); - assert.match(dstHtml, new RegExp(newBodyDst)); - - // Force the server to re-read the pads from the database. This rebuilds the attribute pool - // objects from scratch, ensuring that an internally inconsistent attribute pool object did - // not cause the above tests to accidentally pass. - const reInitPad = async (padId:string) => { - const pad = await padManager.getPad(padId); - await pad.init(); - }; - await Promise.all([ - reInitPad(sourcePadId), - reInitPad(newPad), - ]); - - [srcHtml, dstHtml] = await Promise.all([getHtml(sourcePadId), getHtml(newPad)]); - assert.match(srcHtml, new RegExp(newBodySrc)); - assert.match(dstHtml, new RegExp(newBodyDst)); - }); - }); + describe("Tests", function () { + it("deletes a Pad that does not exist", async function () { + await agent + .get(`${endPoint("deletePad")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) // @TODO: we shouldn't expect 200 here since the pad may not exist + .expect("Content-Type", /json/); + }); + + it("creates a new Pad", async function () { + const res = await agent + .get(`${endPoint("createPad")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); + + it("gets revision count of Pad", async function () { + const res = await agent + .get(`${endPoint("getRevisionsCount")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + assert.equal(res.body.data.revisions, 0); + }); + + it("gets saved revisions count of Pad", async function () { + const res = await agent + .get(`${endPoint("getSavedRevisionsCount")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + assert.equal(res.body.data.savedRevisions, 0); + }); + + it("gets saved revision list of Pad", async function () { + const res = await agent + .get(`${endPoint("listSavedRevisions")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + assert.deepEqual(res.body.data.savedRevisions, []); + }); + + it("get the HTML of Pad", async function () { + const res = await agent + .get(`${endPoint("getHTML")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert(res.body.data.html.length > 1); + }); + + it("list all pads", async function () { + const res = await agent + .get(endPoint("listAllPads")) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert(res.body.data.padIDs.includes(testPadId)); + }); + + it("deletes the Pad", async function () { + const res = await agent + .get(`${endPoint("deletePad")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); + + it("list all pads again", async function () { + const res = await agent + .get(endPoint("listAllPads")) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert(!res.body.data.padIDs.includes(testPadId)); + }); + + it("get the HTML of a Pad -- Should return a failure", async function () { + const res = await agent + .get(`${endPoint("getHTML")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 1); + }); + + it("creates a new Pad with text", async function () { + const res = await agent + .get(`${endPoint("createPad")}?padID=${testPadId}&text=testText`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); + + it("gets the Pad text and expect it to be testText with trailing \\n", async function () { + const res = await agent + .get(`${endPoint("getText")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.data.text, "testText\n"); + }); + + it("set text", async function () { + const res = await agent + .post(endPoint("setText")) + .set("Authorization", await common.generateJWTToken()) + .send({ + padID: testPadId, + text: "testTextTwo", + }) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); + + it("gets the Pad text", async function () { + const res = await agent + .get(`${endPoint("getText")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.data.text, "testTextTwo\n"); + }); + + it("gets Revision Count of a Pad", async function () { + const res = await agent + .get(`${endPoint("getRevisionsCount")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.data.revisions, 1); + }); + + it("saves Revision", async function () { + const res = await agent + .get(`${endPoint("saveRevision")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); + + it("gets saved revisions count of Pad again", async function () { + const res = await agent + .get(`${endPoint("getSavedRevisionsCount")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + assert.equal(res.body.data.savedRevisions, 1); + }); + + it("gets saved revision list of Pad again", async function () { + const res = await agent + .get(`${endPoint("listSavedRevisions")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + assert.deepEqual(res.body.data.savedRevisions, [1]); + }); + + it("gets User Count of a Pad", async function () { + const res = await agent + .get(`${endPoint("padUsersCount")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.data.padUsersCount, 0); + }); + + it("Gets the Read Only ID of a Pad", async function () { + const res = await agent + .get(`${endPoint("getReadOnlyID")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert(res.body.data.readOnlyID); + }); + + it("Get Authors of the Pad", async function () { + const res = await agent + .get(`${endPoint("listAuthorsOfPad")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.data.authorIDs.length, 0); + }); + + it("Get When Pad was left Edited", async function () { + const res = await agent + .get(`${endPoint("getLastEdited")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert(res.body.data.lastEdited); + lastEdited = res.body.data.lastEdited; + }); + + it("set text again", async function () { + const res = await agent + .post(endPoint("setText")) + .set("Authorization", await common.generateJWTToken()) + .send({ + padID: testPadId, + text: "testTextThree", + }) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); + + it("Get When Pad was left Edited again", async function () { + const res = await agent + .get(`${endPoint("getLastEdited")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert(res.body.data.lastEdited > lastEdited); + }); + + it("gets User Count of a Pad again", async function () { + const res = await agent + .get(`${endPoint("padUsers")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.data.padUsers.length, 0); + }); + + it("deletes a Pad", async function () { + const res = await agent + .get(`${endPoint("deletePad")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); + + it("creates the Pad again", async function () { + const res = await agent + .get(`${endPoint("createPad")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); + + it("Sets text on a pad Id", async function () { + const res = await agent + .post(`${endPoint("setText")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .field({ text }) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); + + it("Gets text on a pad Id", async function () { + const res = await agent + .get(`${endPoint("getText")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + assert.equal(res.body.data.text, `${text}\n`); + }); + + it("Sets text on a pad Id including an explicit newline", async function () { + const res = await agent + .post(`${endPoint("setText")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .field({ text: `${text}\n` }) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); + + it("Gets text on a pad Id and doesn't have an excess newline", async function () { + const res = await agent + .get(`${endPoint("getText")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + assert.equal(res.body.data.text, `${text}\n`); + }); + + it("Gets when pad was last edited", async function () { + const res = await agent + .get(`${endPoint("getLastEdited")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.notEqual(res.body.lastEdited, 0); + }); + + it("Move a Pad to a different Pad ID", async function () { + const res = await agent + .get( + `${endPoint( + "movePad", + )}?sourceID=${testPadId}&destinationID=${newPadId}&force=true`, + ) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); + + it("Gets text from new pad", async function () { + const res = await agent + .get(`${endPoint("getText")}?padID=${newPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.data.text, `${text}\n`); + }); + + it("Move pad back to original ID", async function () { + const res = await agent + .get( + `${endPoint( + "movePad", + )}?sourceID=${newPadId}&destinationID=${testPadId}&force=false`, + ) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); + + it("Get text using original ID", async function () { + const res = await agent + .get(`${endPoint("getText")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.data.text, `${text}\n`); + }); + + it("Get last edit of original ID", async function () { + const res = await agent + .get(`${endPoint("getLastEdited")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.notEqual(res.body.lastEdited, 0); + }); + + it("Append text to a pad Id", async function () { + let res = await agent + .get( + `${endPoint("appendText", "1.2.13")}?padID=${testPadId}&text=hello`, + ) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + res = await agent + .get(`${endPoint("getText")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + assert.equal(res.body.data.text, `${text}hello\n`); + }); + + it("getText of old revision", async function () { + let res = await agent + .get(`${endPoint("getRevisionsCount")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + const rev = res.body.data.revisions; + assert(rev != null); + assert(Number.isInteger(rev)); + assert(rev > 0); + res = await agent + .get(`${endPoint("getText")}?padID=${testPadId}&rev=${rev - 1}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + assert.equal(res.body.data.text, `${text}\n`); + }); + + it("Sets the HTML of a Pad attempting to pass ugly HTML", async function () { + const html = "
              Hello HTML
              "; + const res = await agent + .post(endPoint("setHTML")) + .set("Authorization", await common.generateJWTToken()) + .send({ + padID: testPadId, + html, + }) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); + + it("Pad with complex nested lists of different types", async function () { + let res = await agent + .post(endPoint("setHTML")) + .set("Authorization", await common.generateJWTToken()) + .send({ + padID: testPadId, + html: ulHtml, + }) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + res = await agent + .get(`${endPoint("getHTML")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + const receivedHtml = res.body.data.html + .replace("
              ", "") + .toLowerCase(); + assert.equal(receivedHtml, expectedHtml); + }); + + it("Pad with white space between list items", async function () { + let res = await agent + .get(`${endPoint("setHTML")}?padID=${testPadId}&html=${ulSpaceHtml}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + res = await agent + .get(`${endPoint("getHTML")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + const receivedHtml = res.body.data.html + .replace("
              ", "") + .toLowerCase(); + assert.equal(receivedHtml, expectedSpaceHtml); + }); + + it("errors if pad can be created", async function () { + await Promise.all( + ["/", "%23", "%3F", "%26"].map(async (badUrlChar) => { + const res = await agent + .get(`${endPoint("createPad")}?padID=${badUrlChar}`) + .set("Authorization", await common.generateJWTToken()) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 1); + }), + ); + }); + + it("copies the content of a existent pad", async function () { + const res = await agent + .get( + `${endPoint( + "copyPad", + )}?sourceID=${testPadId}&destinationID=${copiedPadId}&force=true`, + ) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); + + it("does not add an useless revision", async function () { + let res = await agent + .post(`${endPoint("setText")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .field({ text: "identical text\n" }) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + + res = await agent + .get(`${endPoint("getText")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.data.text, "identical text\n"); + + res = await agent + .get(`${endPoint("getRevisionsCount")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + const revCount = res.body.data.revisions; + + res = await agent + .post(`${endPoint("setText")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .field({ text: "identical text\n" }) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + + res = await agent + .get(`${endPoint("getRevisionsCount")}?padID=${testPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .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=`) + .set("Authorization", await common.generateJWTToken()) + .expect("Content-Type", /json/) + .expect(200) + .expect((res: any) => { + assert.equal(res.body.code, 0, "Unable to create new Pad"); + }); + await agent + .get(`${endPoint("getText")}?padID=${anotherPadId}`) + .set("Authorization", await common.generateJWTToken()) + .expect("Content-Type", /json/) + .expect(200) + .expect((res: any) => { + 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}`) + .set("Authorization", await common.generateJWTToken()) + .expect("Content-Type", /json/) + .expect(200) + .expect((res: any) => { + assert.equal(res.body.code, 0, "Unable to delete empty Pad"); + }); + }); + }); + + describe("copyPadWithoutHistory", function () { + const sourcePadId = makeid(); + let newPad: string; + + before(async function () { + await createNewPadWithHtml(sourcePadId, ulHtml); + }); + + beforeEach(async function () { + newPad = makeid(); + }); + + it("returns a successful response", async function () { + const res = await agent + .get( + `${endPoint("copyPadWithoutHistory")}?sourceID=${sourcePadId}` + + `&destinationID=${newPad}&force=false`, + ) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }); + + // this test validates if the source pad's text and attributes are kept + it("creates a new pad with the same content as the source pad", async function () { + let res = await agent + .get( + `${endPoint("copyPadWithoutHistory")}?sourceID=${sourcePadId}` + + `&destinationID=${newPad}&force=false`, + ) + .set("Authorization", await common.generateJWTToken()); + assert.equal(res.body.code, 0); + res = await agent + .get(`${endPoint("getHTML")}?padID=${newPad}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200); + const receivedHtml = res.body.data.html + .replace("

              ", "") + .toLowerCase(); + assert.equal(receivedHtml, expectedHtml); + }); + + it("copying to a non-existent group throws an error", async function () { + const padWithNonExistentGroup = `notExistentGroup$${newPad}`; + const res = await agent + .get( + `${endPoint("copyPadWithoutHistory")}` + + `?sourceID=${sourcePadId}` + + `&destinationID=${padWithNonExistentGroup}&force=true`, + ) + .set("Authorization", await common.generateJWTToken()) + .expect(200); + assert.equal(res.body.code, 1); + }); + + describe("copying to an existing pad", function () { + beforeEach(async function () { + await createNewPadWithHtml(newPad, ulHtml); + }); + + it("force=false fails", async function () { + const res = await agent + .get( + `${endPoint("copyPadWithoutHistory")}` + + `?sourceID=${sourcePadId}` + + `&destinationID=${newPad}&force=false`, + ) + .set("Authorization", await common.generateJWTToken()) + .expect(200); + assert.equal(res.body.code, 1); + }); + + it("force=true succeeds", async function () { + const res = await agent + .get( + `${endPoint("copyPadWithoutHistory")}` + + `?sourceID=${sourcePadId}` + + `&destinationID=${newPad}&force=true`, + ) + .set("Authorization", await common.generateJWTToken()) + .expect(200); + assert.equal(res.body.code, 0); + }); + }); + + // Regression test for https://github.com/ether/etherpad-lite/issues/5296 + it("source and destination attribute pools are independent", async function () { + // Strategy for this test: + // 1. Create a new pad without bold or italic text + // 2. Use copyPadWithoutHistory to copy the pad. + // 3. Add some bold text (but no italic text!) to the source pad. This should add a bold + // attribute to the source pad's pool but not to the destination pad's pool. + // 4. Add some italic text (but no bold text!) to the destination pad. This should add an + // italic attribute to the destination pad's pool with the same number as the newly added + // bold attribute in the source pad's pool. + // 5. Add some more text (bold or plain) to the source pad. This will save the source pad to + // the database after the destination pad has had an opportunity to corrupt the source + // pad. + // 6. Export the source and destination pads. Make sure that doesn't appear in the + // source pad's HTML, and that doesn't appear int he destination pad's HTML. + // 7. Force the server to re-init the pads from the database. + // 8. Repeat step 6. + // If appears in the source pad, or appears in the destination pad, then shared + // state between the two attribute pools caused corruption. + + const getHtml = async (padId: string) => { + const res = await agent + .get(`${endPoint("getHTML")}?padID=${padId}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + return res.body.data.html; + }; + + const setBody = async (padId: string, bodyHtml: string) => { + await agent + .post(endPoint("setHTML")) + .set("Authorization", await common.generateJWTToken()) + .send({ + padID: padId, + html: `${bodyHtml}`, + }) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => assert.equal(res.body.code, 0)); + }; + + const origHtml = await getHtml(sourcePadId); + assert.doesNotMatch(origHtml, //); + assert.doesNotMatch(origHtml, //); + await agent + .get( + `${endPoint("copyPadWithoutHistory")}?sourceID=${sourcePadId}` + + `&destinationID=${newPad}&force=false`, + ) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => assert.equal(res.body.code, 0)); + + const newBodySrc = "bold"; + const newBodyDst = "italic"; + await setBody(sourcePadId, newBodySrc); + await setBody(newPad, newBodyDst); + await setBody(sourcePadId, `${newBodySrc} foo`); + + let [srcHtml, dstHtml] = await Promise.all([ + getHtml(sourcePadId), + getHtml(newPad), + ]); + assert.match(srcHtml, new RegExp(newBodySrc)); + assert.match(dstHtml, new RegExp(newBodyDst)); + + // Force the server to re-read the pads from the database. This rebuilds the attribute pool + // objects from scratch, ensuring that an internally inconsistent attribute pool object did + // not cause the above tests to accidentally pass. + const reInitPad = async (padId: string) => { + const pad = await padManager.getPad(padId); + await pad.init(); + }; + await Promise.all([reInitPad(sourcePadId), reInitPad(newPad)]); + + [srcHtml, dstHtml] = await Promise.all([ + getHtml(sourcePadId), + getHtml(newPad), + ]); + assert.match(srcHtml, new RegExp(newBodySrc)); + assert.match(dstHtml, new RegExp(newBodyDst)); + }); + }); }); /* @@ -736,32 +847,36 @@ describe(__filename, function () { */ const createNewPadWithHtml = async (padId: string, html: string) => { - await agent.get(`${endPoint('createPad')}?padID=${padId}`) - .set("Authorization", (await common.generateJWTToken())); - await agent.post(endPoint('setHTML')) - .set("Authorization", (await common.generateJWTToken())) - .send({ - padID: padId, - html, - }); + await agent + .get(`${endPoint("createPad")}?padID=${padId}`) + .set("Authorization", await common.generateJWTToken()); + await agent + .post(endPoint("setHTML")) + .set("Authorization", await common.generateJWTToken()) + .send({ + padID: padId, + html, + }); }; function makeid() { - let text = ''; - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - for (let i = 0; i < 5; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - return text; + for (let i = 0; i < 5; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; } function generateLongText() { - let text = ''; - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - for (let i = 0; i < 80000; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - return text; + for (let i = 0; i < 80000; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; } diff --git a/src/tests/backend/specs/api/restoreRevision.ts b/src/tests/backend/specs/api/restoreRevision.ts index e30c8aa25..b9ddde27b 100644 --- a/src/tests/backend/specs/api/restoreRevision.ts +++ b/src/tests/backend/specs/api/restoreRevision.ts @@ -1,84 +1,92 @@ -'use strict'; +"use strict"; -import {PadType} from "../../../../node/types/PadType"; +import { PadType } from "../../../../node/types/PadType"; -const assert = require('assert').strict; -const authorManager = require('../../../../node/db/AuthorManager'); -const common = require('../../common'); -const padManager = require('../../../../node/db/PadManager'); +const assert = require("assert").strict; +const authorManager = require("../../../../node/db/AuthorManager"); +const common = require("../../common"); +const padManager = require("../../../../node/db/PadManager"); describe(__filename, function () { - let agent:any; - let authorId: string; - let padId: string; - let pad: PadType; + let agent: any; + let authorId: string; + let padId: string; + let pad: PadType; - const restoreRevision = async (v:string, padId: string, rev: number, authorId:string|null = null) => { - // @ts-ignore - const p = new URLSearchParams(Object.entries({ - padID: padId, - rev, - ...(authorId == null ? {} : {authorId}), - })); - const res = await agent.get(`/api/${v}/restoreRevision?${p}`) - .set("Authorization", (await common.generateJWTToken())) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }; + const restoreRevision = async ( + v: string, + padId: string, + rev: number, + authorId: string | null = null, + ) => { + // @ts-ignore + const p = new URLSearchParams( + Object.entries({ + padID: padId, + rev, + ...(authorId == null ? {} : { authorId }), + }), + ); + const res = await agent + .get(`/api/${v}/restoreRevision?${p}`) + .set("Authorization", await common.generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/); + assert.equal(res.body.code, 0); + }; - before(async function () { - agent = await common.init(); - authorId = await authorManager.getAuthor4Token('test-restoreRevision'); - assert(authorId); - }); + before(async function () { + agent = await common.init(); + authorId = await authorManager.getAuthor4Token("test-restoreRevision"); + assert(authorId); + }); - beforeEach(async function () { - padId = common.randomString(); - if (await padManager.doesPadExist(padId)) await padManager.removePad(padId); - pad = await padManager.getPad(padId); - await pad.appendText('\nfoo'); - await pad.appendText('\nbar'); - assert.equal(pad.head, 2); - }); + beforeEach(async function () { + padId = common.randomString(); + if (await padManager.doesPadExist(padId)) await padManager.removePad(padId); + pad = await padManager.getPad(padId); + await pad.appendText("\nfoo"); + await pad.appendText("\nbar"); + assert.equal(pad.head, 2); + }); - afterEach(async function () { - if (await padManager.doesPadExist(padId)) await padManager.removePad(padId); - }); + afterEach(async function () { + if (await padManager.doesPadExist(padId)) await padManager.removePad(padId); + }); - describe('v1.2.11', function () { - // TODO: Enable once the end-of-pad newline bugs are fixed. See: - // https://github.com/ether/etherpad-lite/pull/5253 - xit('content matches', async function () { - const oldHead = pad.head; - const wantAText = await pad.getInternalRevisionAText(pad.head - 1); - assert(wantAText.text.endsWith('\nfoo\n')); - await restoreRevision('1.2.11', padId, pad.head - 1); - assert.equal(pad.head, oldHead + 1); - assert.deepEqual(await pad.getInternalRevisionAText(pad.head), wantAText); - }); + describe("v1.2.11", function () { + // TODO: Enable once the end-of-pad newline bugs are fixed. See: + // https://github.com/ether/etherpad-lite/pull/5253 + xit("content matches", async function () { + const oldHead = pad.head; + const wantAText = await pad.getInternalRevisionAText(pad.head - 1); + assert(wantAText.text.endsWith("\nfoo\n")); + await restoreRevision("1.2.11", padId, pad.head - 1); + assert.equal(pad.head, oldHead + 1); + assert.deepEqual(await pad.getInternalRevisionAText(pad.head), wantAText); + }); - it('authorId ignored', async function () { - const oldHead = pad.head; - await restoreRevision('1.2.11', padId, pad.head - 1, authorId); - assert.equal(pad.head, oldHead + 1); - assert.equal(await pad.getRevisionAuthor(pad.head), ''); - }); - }); + it("authorId ignored", async function () { + const oldHead = pad.head; + await restoreRevision("1.2.11", padId, pad.head - 1, authorId); + assert.equal(pad.head, oldHead + 1); + assert.equal(await pad.getRevisionAuthor(pad.head), ""); + }); + }); - describe('v1.3.0', function () { - it('change is attributed to given authorId', async function () { - const oldHead = pad.head; - await restoreRevision('1.3.0', padId, pad.head - 1, authorId); - assert.equal(pad.head, oldHead + 1); - assert.equal(await pad.getRevisionAuthor(pad.head), authorId); - }); + describe("v1.3.0", function () { + it("change is attributed to given authorId", async function () { + const oldHead = pad.head; + await restoreRevision("1.3.0", padId, pad.head - 1, authorId); + assert.equal(pad.head, oldHead + 1); + assert.equal(await pad.getRevisionAuthor(pad.head), authorId); + }); - it('authorId can be omitted', async function () { - const oldHead = pad.head; - await restoreRevision('1.3.0', padId, pad.head - 1); - assert.equal(pad.head, oldHead + 1); - assert.equal(await pad.getRevisionAuthor(pad.head), ''); - }); - }); + it("authorId can be omitted", async function () { + const oldHead = pad.head; + await restoreRevision("1.3.0", padId, pad.head - 1); + assert.equal(pad.head, oldHead + 1); + assert.equal(await pad.getRevisionAuthor(pad.head), ""); + }); + }); }); diff --git a/src/tests/backend/specs/api/sessionsAndGroups.ts b/src/tests/backend/specs/api/sessionsAndGroups.ts index a7e85fbe9..ffbc3b418 100644 --- a/src/tests/backend/specs/api/sessionsAndGroups.ts +++ b/src/tests/backend/specs/api/sessionsAndGroups.ts @@ -1,46 +1,47 @@ -'use strict'; +"use strict"; -import {agent, generateJWTToken, init, logger} from "../../common"; +import { agent, generateJWTToken, init, logger } from "../../common"; import TestAgent from "supertest/lib/agent"; import supertest from "supertest"; -const assert = require('assert').strict; -const db = require('../../../../node/db/DB'); +const assert = require("assert").strict; +const db = require("../../../../node/db/DB"); let apiVersion = 1; -let groupID = ''; -let authorID = ''; -let sessionID = ''; +let groupID = ""; +let authorID = ""; +let sessionID = ""; let padID = makeid(); -const endPoint = (point:string) => { - return `/api/${apiVersion}/${point}`; -} +const endPoint = (point: string) => { + return `/api/${apiVersion}/${point}`; +}; -let preparedAgent: TestAgent +let preparedAgent: TestAgent; describe(__filename, function () { - before(async function () { - preparedAgent = await init(); - }); + before(async function () { + preparedAgent = await init(); + }); - describe('API Versioning', function () { - it('errors if can not connect', async function () { - await agent!.get('/api/') - .set('Accept', 'application/json') - .expect(200) - .expect((res:any) => { - assert(res.body.currentVersion); - apiVersion = res.body.currentVersion; - }); - }); - }); + describe("API Versioning", function () { + it("errors if can not connect", async function () { + await agent! + .get("/api/") + .set("Accept", "application/json") + .expect(200) + .expect((res: any) => { + assert(res.body.currentVersion); + apiVersion = res.body.currentVersion; + }); + }); + }); - // BEGIN GROUP AND AUTHOR TESTS - // /////////////////////////////////// - // /////////////////////////////////// + // BEGIN GROUP AND AUTHOR TESTS + // /////////////////////////////////// + // /////////////////////////////////// - /* Tests performed + /* Tests performed -> createGroup() -- should return a groupID -> listSessionsOfGroup(groupID) -- should be 0 -> deleteGroup(groupID) @@ -66,369 +67,417 @@ describe(__filename, function () { -> listPadsOfAuthor(authorID) */ - describe('API: Group creation and deletion', function () { - it('createGroup', async function () { - await agent!.get(endPoint('createGroup')) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert(res.body.data.groupID); - groupID = res.body.data.groupID; - }); - }); + describe("API: Group creation and deletion", function () { + it("createGroup", async function () { + await agent! + .get(endPoint("createGroup")) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert(res.body.data.groupID); + groupID = res.body.data.groupID; + }); + }); - it('listSessionsOfGroup for empty group', async function () { - await agent!.get(`${endPoint('listSessionsOfGroup')}?groupID=${groupID}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert.equal(res.body.data, null); - }); - }); + it("listSessionsOfGroup for empty group", async function () { + await agent! + .get(`${endPoint("listSessionsOfGroup")}?groupID=${groupID}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert.equal(res.body.data, null); + }); + }); - it('deleteGroup', async function () { - await agent! - .get(`${endPoint('deleteGroup')}?groupID=${groupID}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - }); - }); + it("deleteGroup", async function () { + await agent! + .get(`${endPoint("deleteGroup")}?groupID=${groupID}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + }); + }); - it('createGroupIfNotExistsFor', async function () { - const mapper = makeid(); - let groupId: string; - await preparedAgent.get(`${endPoint('createGroupIfNotExistsFor')}?groupMapper=${mapper}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - groupId = res.body.data.groupID; - assert(groupId); - }); - // Passing the same mapper should return the same group ID. - await preparedAgent.get(`${endPoint('createGroupIfNotExistsFor')}?groupMapper=${mapper}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert.equal(res.body.data.groupID, groupId); - }); - // Deleting the group should clean up the mapping. - assert.equal(await db.get(`mapper2group:${mapper}`), groupId!); - await preparedAgent.get(`${endPoint('deleteGroup')}?groupID=${groupId!}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - }); - assert(await db.get(`mapper2group:${mapper}`) == null); - }); + it("createGroupIfNotExistsFor", async function () { + const mapper = makeid(); + let groupId: string; + await preparedAgent + .get(`${endPoint("createGroupIfNotExistsFor")}?groupMapper=${mapper}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + groupId = res.body.data.groupID; + assert(groupId); + }); + // Passing the same mapper should return the same group ID. + await preparedAgent + .get(`${endPoint("createGroupIfNotExistsFor")}?groupMapper=${mapper}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert.equal(res.body.data.groupID, groupId); + }); + // Deleting the group should clean up the mapping. + assert.equal(await db.get(`mapper2group:${mapper}`), groupId!); + await preparedAgent + .get(`${endPoint("deleteGroup")}?groupID=${groupId!}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + }); + assert((await db.get(`mapper2group:${mapper}`)) == null); + }); - // Test coverage for https://github.com/ether/etherpad-lite/issues/4227 - // Creates a group, creates 2 sessions, 2 pads and then deletes the group. - it('createGroup', async function () { - await preparedAgent.get(endPoint('createGroup')) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert(res.body.data.groupID); - groupID = res.body.data.groupID; - }); - }); + // Test coverage for https://github.com/ether/etherpad-lite/issues/4227 + // Creates a group, creates 2 sessions, 2 pads and then deletes the group. + it("createGroup", async function () { + await preparedAgent + .get(endPoint("createGroup")) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert(res.body.data.groupID); + groupID = res.body.data.groupID; + }); + }); - it('createAuthor', async function () { - await preparedAgent.get(endPoint('createAuthor')) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert(res.body.data.authorID); - authorID = res.body.data.authorID; - }); - }); + it("createAuthor", async function () { + await preparedAgent + .get(endPoint("createAuthor")) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert(res.body.data.authorID); + authorID = res.body.data.authorID; + }); + }); - it('createSession', async function () { - await preparedAgent.get(`${endPoint('createSession')}?authorID=${authorID}&groupID=${groupID}` + - '&validUntil=999999999999') - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert(res.body.data.sessionID); - sessionID = res.body.data.sessionID; - }); - }); + it("createSession", async function () { + await preparedAgent + .get( + `${endPoint( + "createSession", + )}?authorID=${authorID}&groupID=${groupID}` + + "&validUntil=999999999999", + ) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert(res.body.data.sessionID); + sessionID = res.body.data.sessionID; + }); + }); - it('createSession', async function () { - await preparedAgent.get(`${endPoint('createSession')}?authorID=${authorID}&groupID=${groupID}` + - '&validUntil=999999999999') - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert(res.body.data.sessionID); - sessionID = res.body.data.sessionID; - }); - }); + it("createSession", async function () { + await preparedAgent + .get( + `${endPoint( + "createSession", + )}?authorID=${authorID}&groupID=${groupID}` + + "&validUntil=999999999999", + ) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert(res.body.data.sessionID); + sessionID = res.body.data.sessionID; + }); + }); - it('createGroupPad', async function () { - await preparedAgent.get(`${endPoint('createGroupPad')}?groupID=${groupID}&padName=x1234567`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - }); - }); + it("createGroupPad", async function () { + await preparedAgent + .get( + `${endPoint("createGroupPad")}?groupID=${groupID}&padName=x1234567`, + ) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + }); + }); - it('createGroupPad', async function () { - await preparedAgent.get(`${endPoint('createGroupPad')}?groupID=${groupID}&padName=x12345678`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - }); - }); + it("createGroupPad", async function () { + await preparedAgent + .get( + `${endPoint("createGroupPad")}?groupID=${groupID}&padName=x12345678`, + ) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + }); + }); - it('deleteGroup', async function () { - await preparedAgent.get(`${endPoint('deleteGroup')}?groupID=${groupID}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - }); - }); - // End of coverage for https://github.com/ether/etherpad-lite/issues/4227 - }); + it("deleteGroup", async function () { + await preparedAgent + .get(`${endPoint("deleteGroup")}?groupID=${groupID}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + }); + }); + // End of coverage for https://github.com/ether/etherpad-lite/issues/4227 + }); - describe('API: Author creation', function () { - it('createGroup', async function () { - await preparedAgent.get(endPoint('createGroup')) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert(res.body.data.groupID); - groupID = res.body.data.groupID; - }); - }); + describe("API: Author creation", function () { + it("createGroup", async function () { + await preparedAgent + .get(endPoint("createGroup")) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert(res.body.data.groupID); + groupID = res.body.data.groupID; + }); + }); - it('createAuthor', async function () { - await preparedAgent.get(endPoint('createAuthor')) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert(res.body.data.authorID); - }); - }); + it("createAuthor", async function () { + await preparedAgent + .get(endPoint("createAuthor")) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert(res.body.data.authorID); + }); + }); - it('createAuthor with name', async function () { - await preparedAgent.get(`${endPoint('createAuthor')}?name=john`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert(res.body.data.authorID); - authorID = res.body.data.authorID; // we will be this author for the rest of the tests - }); - }); + it("createAuthor with name", async function () { + await preparedAgent + .get(`${endPoint("createAuthor")}?name=john`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert(res.body.data.authorID); + authorID = res.body.data.authorID; // we will be this author for the rest of the tests + }); + }); - it('createAuthorIfNotExistsFor', async function () { - await preparedAgent.get(`${endPoint('createAuthorIfNotExistsFor')}?authorMapper=chris`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert(res.body.data.authorID); - }); - }); + it("createAuthorIfNotExistsFor", async function () { + await preparedAgent + .get(`${endPoint("createAuthorIfNotExistsFor")}?authorMapper=chris`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert(res.body.data.authorID); + }); + }); - it('getAuthorName', async function () { - await preparedAgent.get(`${endPoint('getAuthorName')}?authorID=${authorID}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert.equal(res.body.data, 'john'); - }); - }); - }); + it("getAuthorName", async function () { + await preparedAgent + .get(`${endPoint("getAuthorName")}?authorID=${authorID}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert.equal(res.body.data, "john"); + }); + }); + }); - describe('API: Sessions', function () { - it('createSession', async function () { - await preparedAgent.get(`${endPoint('createSession')}?authorID=${authorID}&groupID=${groupID}` + - '&validUntil=999999999999') - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert(res.body.data.sessionID); - sessionID = res.body.data.sessionID; - }); - }); + describe("API: Sessions", function () { + it("createSession", async function () { + await preparedAgent + .get( + `${endPoint( + "createSession", + )}?authorID=${authorID}&groupID=${groupID}` + + "&validUntil=999999999999", + ) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert(res.body.data.sessionID); + sessionID = res.body.data.sessionID; + }); + }); - it('getSessionInfo', async function () { - await preparedAgent.get(`${endPoint('getSessionInfo')}?sessionID=${sessionID}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert(res.body.data.groupID); - assert(res.body.data.authorID); - assert(res.body.data.validUntil); - }); - }); + it("getSessionInfo", async function () { + await preparedAgent + .get(`${endPoint("getSessionInfo")}?sessionID=${sessionID}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert(res.body.data.groupID); + assert(res.body.data.authorID); + assert(res.body.data.validUntil); + }); + }); - it('listSessionsOfGroup', async function () { - await preparedAgent.get(`${endPoint('listSessionsOfGroup')}?groupID=${groupID}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert.equal(typeof res.body.data, 'object'); - }); - }); + it("listSessionsOfGroup", async function () { + await preparedAgent + .get(`${endPoint("listSessionsOfGroup")}?groupID=${groupID}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert.equal(typeof res.body.data, "object"); + }); + }); - it('deleteSession', async function () { - await preparedAgent.get(`${endPoint('deleteSession')}?sessionID=${sessionID}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - }); - }); + it("deleteSession", async function () { + await preparedAgent + .get(`${endPoint("deleteSession")}?sessionID=${sessionID}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + }); + }); - it('getSessionInfo of deleted session', async function () { - await preparedAgent.get(`${endPoint('getSessionInfo')}?sessionID=${sessionID}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 1); - }); - }); - }); + it("getSessionInfo of deleted session", async function () { + await preparedAgent + .get(`${endPoint("getSessionInfo")}?sessionID=${sessionID}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 1); + }); + }); + }); - describe('API: Group pad management', function () { - it('listPads', async function () { - await preparedAgent.get(`${endPoint('listPads')}?groupID=${groupID}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert.equal(res.body.data.padIDs.length, 0); - }); - }); + describe("API: Group pad management", function () { + it("listPads", async function () { + await preparedAgent + .get(`${endPoint("listPads")}?groupID=${groupID}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert.equal(res.body.data.padIDs.length, 0); + }); + }); - it('createGroupPad', async function () { - await preparedAgent.get(`${endPoint('createGroupPad')}?groupID=${groupID}&padName=${padID}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - padID = res.body.data.padID; - }); - }); + it("createGroupPad", async function () { + await preparedAgent + .get( + `${endPoint("createGroupPad")}?groupID=${groupID}&padName=${padID}`, + ) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + padID = res.body.data.padID; + }); + }); - it('listPads after creating a group pad', async function () { - await preparedAgent.get(`${endPoint('listPads')}?groupID=${groupID}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res) => { - assert.equal(res.body.code, 0); - assert.equal(res.body.data.padIDs.length, 1); - }); - }); - }); + it("listPads after creating a group pad", async function () { + await preparedAgent + .get(`${endPoint("listPads")}?groupID=${groupID}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res) => { + assert.equal(res.body.code, 0); + assert.equal(res.body.data.padIDs.length, 1); + }); + }); + }); - describe('API: Pad security', function () { - it('getPublicStatus', async function () { - await preparedAgent.get(`${endPoint('getPublicStatus')}?padID=${padID}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert.equal(res.body.data.publicStatus, false); - }); - }); + describe("API: Pad security", function () { + it("getPublicStatus", async function () { + await preparedAgent + .get(`${endPoint("getPublicStatus")}?padID=${padID}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert.equal(res.body.data.publicStatus, false); + }); + }); - it('setPublicStatus', async function () { - await preparedAgent.get(`${endPoint('setPublicStatus')}?padID=${padID}&publicStatus=true`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - }); - }); + it("setPublicStatus", async function () { + await preparedAgent + .get(`${endPoint("setPublicStatus")}?padID=${padID}&publicStatus=true`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + }); + }); - it('getPublicStatus after changing public status', async function () { - await preparedAgent.get(`${endPoint('getPublicStatus')}?padID=${padID}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert.equal(res.body.data.publicStatus, true); - }); - }); - }); + it("getPublicStatus after changing public status", async function () { + await preparedAgent + .get(`${endPoint("getPublicStatus")}?padID=${padID}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert.equal(res.body.data.publicStatus, true); + }); + }); + }); - // NOT SURE HOW TO POPULAT THIS /-_-\ - // ///////////////////////////////////// - // ///////////////////////////////////// + // NOT SURE HOW TO POPULAT THIS /-_-\ + // ///////////////////////////////////// + // ///////////////////////////////////// - describe('API: Misc', function () { - it('listPadsOfAuthor', async function () { - await preparedAgent.get(`${endPoint('listPadsOfAuthor')}?authorID=${authorID}`) - .set("Authorization", await generateJWTToken()) - .expect(200) - .expect('Content-Type', /json/) - .expect((res:any) => { - assert.equal(res.body.code, 0); - assert.equal(res.body.data.padIDs.length, 0); - }); - }); - }); + describe("API: Misc", function () { + it("listPadsOfAuthor", async function () { + await preparedAgent + .get(`${endPoint("listPadsOfAuthor")}?authorID=${authorID}`) + .set("Authorization", await generateJWTToken()) + .expect(200) + .expect("Content-Type", /json/) + .expect((res: any) => { + assert.equal(res.body.code, 0); + assert.equal(res.body.data.padIDs.length, 0); + }); + }); + }); }); function makeid() { - let text = ''; - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - for (let i = 0; i < 5; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - return text; + for (let i = 0; i < 5; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; } diff --git a/src/tests/backend/specs/caching_middleware.ts b/src/tests/backend/specs/caching_middleware.ts index 3051ee1e7..4eb71a286 100644 --- a/src/tests/backend/specs/caching_middleware.ts +++ b/src/tests/backend/specs/caching_middleware.ts @@ -1,6 +1,6 @@ -'use strict'; +"use strict"; -import {MapArrayType} from "../../../node/types/MapType"; +import { MapArrayType } from "../../../node/types/MapType"; /** * caching_middleware is responsible for serving everything under path `/javascripts/` @@ -8,11 +8,11 @@ import {MapArrayType} from "../../../node/types/MapType"; * */ -const common = require('../common'); -import {strict as assert} from 'assert'; -import queryString from 'querystring'; -const settings = require('../../../node/utils/Settings'); -import {it, describe} from 'mocha' +const common = require("../common"); +import { strict as assert } from "assert"; +import queryString from "querystring"; +const settings = require("../../../node/utils/Settings"); +import { it, describe } from "mocha"; let agent: any; @@ -25,20 +25,22 @@ let agent: any; * @param {URL} resource resource URI * @returns {boolean} if it is plaintext */ -const isPlaintextResponse = (fileContent: string, resource:string): boolean => { - // callback=require.define&v=1234 - const query = (new URL(resource, 'http://localhost')).search.slice(1); - // require.define - const jsonp = queryString.parse(query).callback; +const isPlaintextResponse = ( + fileContent: string, + resource: string, +): boolean => { + // callback=require.define&v=1234 + const query = new URL(resource, "http://localhost").search.slice(1); + // require.define + const jsonp = queryString.parse(query).callback; - // returns true if the first letters in fileContent equal the content of `jsonp` - return fileContent.substring(0, jsonp!.length) === jsonp; + // returns true if the first letters in fileContent equal the content of `jsonp` + return fileContent.substring(0, jsonp!.length) === jsonp; }; - type RequestType = { - _shouldUnzip: () => boolean; -} + _shouldUnzip: () => boolean; +}; /** * A hack to disable `superagent`'s auto unzip functionality @@ -46,80 +48,89 @@ type RequestType = { * @param {Request} request */ const disableAutoDeflate = (request: RequestType) => { - request._shouldUnzip = () => false; + request._shouldUnzip = () => false; }; describe(__filename, function () { - const backups:MapArrayType = {}; - const fantasyEncoding = 'brainwaves'; // non-working encoding until https://github.com/visionmedia/superagent/pull/1560 is resolved - const packages = [ - '/javascripts/lib/ep_etherpad-lite/static/js/ace2_common.js?callback=require.define', - '/javascripts/lib/ep_etherpad-lite/static/js/ace2_inner.js?callback=require.define', - '/javascripts/lib/ep_etherpad-lite/static/js/pad.js?callback=require.define', - '/javascripts/lib/ep_etherpad-lite/static/js/timeslider.js?callback=require.define', - ]; + const backups: MapArrayType = {}; + const fantasyEncoding = "brainwaves"; // non-working encoding until https://github.com/visionmedia/superagent/pull/1560 is resolved + const packages = [ + "/javascripts/lib/ep_etherpad-lite/static/js/ace2_common.js?callback=require.define", + "/javascripts/lib/ep_etherpad-lite/static/js/ace2_inner.js?callback=require.define", + "/javascripts/lib/ep_etherpad-lite/static/js/pad.js?callback=require.define", + "/javascripts/lib/ep_etherpad-lite/static/js/timeslider.js?callback=require.define", + ]; - before(async function () { - agent = await common.init(); - backups.settings = {}; - backups.settings.minify = settings.minify; - }); - after(async function () { - Object.assign(settings, backups.settings); - }); + before(async function () { + agent = await common.init(); + backups.settings = {}; + backups.settings.minify = settings.minify; + }); + after(async function () { + Object.assign(settings, backups.settings); + }); - for (const minify of [false, true]) { - context(`when minify is ${minify}`, function () { - before(async function () { - settings.minify = minify; - }); + for (const minify of [false, true]) { + context(`when minify is ${minify}`, function () { + before(async function () { + settings.minify = minify; + }); - describe('gets packages uncompressed without Accept-Encoding gzip', function () { - for (const resource of packages) { - it(resource, async function () { - await agent.get(resource) - .set('Accept-Encoding', fantasyEncoding) - .use(disableAutoDeflate) - .expect(200) - .expect('Content-Type', /application\/javascript/) - .expect((res:any) => { - assert.equal(res.header['content-encoding'], undefined); - assert(isPlaintextResponse(res.text, resource)); - }); - }); - } - }); + describe("gets packages uncompressed without Accept-Encoding gzip", function () { + for (const resource of packages) { + it(resource, async function () { + await agent + .get(resource) + .set("Accept-Encoding", fantasyEncoding) + .use(disableAutoDeflate) + .expect(200) + .expect("Content-Type", /application\/javascript/) + .expect((res: any) => { + assert.equal(res.header["content-encoding"], undefined); + assert(isPlaintextResponse(res.text, resource)); + }); + }); + } + }); - describe('gets packages compressed with Accept-Encoding gzip', function () { - for (const resource of packages) { - it(resource, async function () { - await agent.get(resource) - .set('Accept-Encoding', 'gzip') - .use(disableAutoDeflate) - .expect(200) - .expect('Content-Type', /application\/javascript/) - .expect('Content-Encoding', 'gzip') - .expect((res:any) => { - assert(!isPlaintextResponse(res.text, resource)); - }); - }); - } - }); + describe("gets packages compressed with Accept-Encoding gzip", function () { + for (const resource of packages) { + it(resource, async function () { + await agent + .get(resource) + .set("Accept-Encoding", "gzip") + .use(disableAutoDeflate) + .expect(200) + .expect("Content-Type", /application\/javascript/) + .expect("Content-Encoding", "gzip") + .expect((res: any) => { + assert(!isPlaintextResponse(res.text, resource)); + }); + }); + } + }); - it('does not cache content-encoding headers', async function () { - await agent.get(packages[0]) - .set('Accept-Encoding', fantasyEncoding) - .expect(200) - .expect((res:any) => assert.equal(res.header['content-encoding'], undefined)); - await agent.get(packages[0]) - .set('Accept-Encoding', 'gzip') - .expect(200) - .expect('Content-Encoding', 'gzip'); - await agent.get(packages[0]) - .set('Accept-Encoding', fantasyEncoding) - .expect(200) - .expect((res:any) => assert.equal(res.header['content-encoding'], undefined)); - }); - }); - } + it("does not cache content-encoding headers", async function () { + await agent + .get(packages[0]) + .set("Accept-Encoding", fantasyEncoding) + .expect(200) + .expect((res: any) => + assert.equal(res.header["content-encoding"], undefined), + ); + await agent + .get(packages[0]) + .set("Accept-Encoding", "gzip") + .expect(200) + .expect("Content-Encoding", "gzip"); + await agent + .get(packages[0]) + .set("Accept-Encoding", fantasyEncoding) + .expect(200) + .expect((res: any) => + assert.equal(res.header["content-encoding"], undefined), + ); + }); + }); + } }); diff --git a/src/tests/backend/specs/chat.ts b/src/tests/backend/specs/chat.ts index 5070a30a1..5987710e8 100644 --- a/src/tests/backend/specs/chat.ts +++ b/src/tests/backend/specs/chat.ts @@ -1,179 +1,188 @@ -'use strict'; +"use strict"; -import {MapArrayType} from "../../../node/types/MapType"; -import {PluginDef} from "../../../node/types/PartType"; +import { MapArrayType } from "../../../node/types/MapType"; +import { PluginDef } from "../../../node/types/PartType"; -const ChatMessage = require('../../../static/js/ChatMessage'); -const {Pad} = require('../../../node/db/Pad'); -const assert = require('assert').strict; -const common = require('../common'); -const padManager = require('../../../node/db/PadManager'); -const pluginDefs = require('../../../static/js/pluginfw/plugin_defs'); +const ChatMessage = require("../../../static/js/ChatMessage"); +const { Pad } = require("../../../node/db/Pad"); +const assert = require("assert").strict; +const common = require("../common"); +const padManager = require("../../../node/db/PadManager"); +const pluginDefs = require("../../../static/js/pluginfw/plugin_defs"); const logger = common.logger; -type CheckFN = ({message, pad, padId}:{ - message?: typeof ChatMessage, - pad?: typeof Pad, - padId?: string, -})=>void; +type CheckFN = ({ + message, + pad, + padId, +}: { + message?: typeof ChatMessage; + pad?: typeof Pad; + padId?: string; +}) => void; -const checkHook = async (hookName: string, checkFn?:CheckFN) => { - if (pluginDefs.hooks[hookName] == null) pluginDefs.hooks[hookName] = []; - await new Promise((resolve, reject) => { - pluginDefs.hooks[hookName].push({ - hook_fn: async (hookName: string, context:any) => { - if (checkFn == null) return; - logger.debug(`hook ${hookName} invoked`); - try { - // Make sure checkFn is called only once. - const _checkFn = checkFn; - // @ts-ignore - checkFn = null; - await _checkFn(context); - } catch (err) { - reject(err); - return; - } - resolve(); - }, - }); - }); +const checkHook = async (hookName: string, checkFn?: CheckFN) => { + if (pluginDefs.hooks[hookName] == null) pluginDefs.hooks[hookName] = []; + await new Promise((resolve, reject) => { + pluginDefs.hooks[hookName].push({ + hook_fn: async (hookName: string, context: any) => { + if (checkFn == null) return; + logger.debug(`hook ${hookName} invoked`); + try { + // Make sure checkFn is called only once. + const _checkFn = checkFn; + // @ts-ignore + checkFn = null; + await _checkFn(context); + } catch (err) { + reject(err); + return; + } + resolve(); + }, + }); + }); }; -const sendMessage = (socket: any, data:any) => { - socket.emit('message', { - type: 'COLLABROOM', - component: 'pad', - data, - }); +const sendMessage = (socket: any, data: any) => { + socket.emit("message", { + type: "COLLABROOM", + component: "pad", + data, + }); }; -const sendChat = (socket:any, message:{ - text: string, - -}) => sendMessage(socket, {type: 'CHAT_MESSAGE', message}); +const sendChat = ( + socket: any, + message: { + text: string; + }, +) => sendMessage(socket, { type: "CHAT_MESSAGE", message }); describe(__filename, function () { - const padId = 'testChatPad'; - const hooksBackup:MapArrayType = {}; + const padId = "testChatPad"; + const hooksBackup: MapArrayType = {}; - before(async function () { - for (const [name, defs] of Object.entries(pluginDefs.hooks)) { - if (defs == null) continue; - hooksBackup[name] = defs as PluginDef[]; - } - }); + before(async function () { + for (const [name, defs] of Object.entries(pluginDefs.hooks)) { + if (defs == null) continue; + hooksBackup[name] = defs as PluginDef[]; + } + }); - beforeEach(async function () { - for (const [name, defs] of Object.entries(hooksBackup)) pluginDefs.hooks[name] = [...defs]; - for (const name of Object.keys(pluginDefs.hooks)) { - if (hooksBackup[name] == null) delete pluginDefs.hooks[name]; - } - if (await padManager.doesPadExist(padId)) { - const pad = await padManager.getPad(padId); - await pad.remove(); - } - }); + beforeEach(async function () { + for (const [name, defs] of Object.entries(hooksBackup)) + pluginDefs.hooks[name] = [...defs]; + for (const name of Object.keys(pluginDefs.hooks)) { + if (hooksBackup[name] == null) delete pluginDefs.hooks[name]; + } + if (await padManager.doesPadExist(padId)) { + const pad = await padManager.getPad(padId); + await pad.remove(); + } + }); - after(async function () { - Object.assign(pluginDefs.hooks, hooksBackup); - for (const name of Object.keys(pluginDefs.hooks)) { - if (hooksBackup[name] == null) delete pluginDefs.hooks[name]; - } - }); + after(async function () { + Object.assign(pluginDefs.hooks, hooksBackup); + for (const name of Object.keys(pluginDefs.hooks)) { + if (hooksBackup[name] == null) delete pluginDefs.hooks[name]; + } + }); - describe('chatNewMessage hook', function () { - let authorId: string; - let socket: any; + describe("chatNewMessage hook", function () { + let authorId: string; + let socket: any; - beforeEach(async function () { - socket = await common.connect(); - const {data: clientVars} = await common.handshake(socket, padId); - authorId = clientVars.userId; - }); + beforeEach(async function () { + socket = await common.connect(); + const { data: clientVars } = await common.handshake(socket, padId); + authorId = clientVars.userId; + }); - afterEach(async function () { - socket.close(); - }); + afterEach(async function () { + socket.close(); + }); - it('message', async function () { - const start = Date.now(); - await Promise.all([ - checkHook('chatNewMessage', ({message}) => { - assert(message != null); - assert(message instanceof ChatMessage); - assert.equal(message.authorId, authorId); - assert.equal(message.text, this.test!.title); - assert(message.time >= start); - assert(message.time <= Date.now()); - }), - sendChat(socket, {text: this.test!.title}), - ]); - }); + it("message", async function () { + const start = Date.now(); + await Promise.all([ + checkHook("chatNewMessage", ({ message }) => { + assert(message != null); + assert(message instanceof ChatMessage); + assert.equal(message.authorId, authorId); + assert.equal(message.text, this.test!.title); + assert(message.time >= start); + assert(message.time <= Date.now()); + }), + sendChat(socket, { text: this.test!.title }), + ]); + }); - it('pad', async function () { - await Promise.all([ - checkHook('chatNewMessage', ({pad}) => { - assert(pad != null); - assert(pad instanceof Pad); - assert.equal(pad.id, padId); - }), - sendChat(socket, {text: this.test!.title}), - ]); - }); + it("pad", async function () { + await Promise.all([ + checkHook("chatNewMessage", ({ pad }) => { + assert(pad != null); + assert(pad instanceof Pad); + assert.equal(pad.id, padId); + }), + sendChat(socket, { text: this.test!.title }), + ]); + }); - it('padId', async function () { - await Promise.all([ - checkHook('chatNewMessage', (context) => { - assert.equal(context.padId, padId); - }), - sendChat(socket, {text: this.test!.title}), - ]); - }); + it("padId", async function () { + await Promise.all([ + checkHook("chatNewMessage", (context) => { + assert.equal(context.padId, padId); + }), + sendChat(socket, { text: this.test!.title }), + ]); + }); - it('mutations propagate', async function () { + it("mutations propagate", async function () { + type Message = { + type: string; + data: any; + }; - type Message = { - type: string, - data: any, - } + const listen = async (type: string) => + await new Promise((resolve) => { + const handler = (msg: Message) => { + if (msg.type !== "COLLABROOM") return; + if (msg.data == null || msg.data.type !== type) return; + resolve(msg.data); + socket.off("message", handler); + }; + socket.on("message", handler); + }); - const listen = async (type: string) => await new Promise((resolve) => { - const handler = (msg:Message) => { - if (msg.type !== 'COLLABROOM') return; - if (msg.data == null || msg.data.type !== type) return; - resolve(msg.data); - socket.off('message', handler); - }; - socket.on('message', handler); - }); - - const modifiedText = `${this.test!.title} `; - const customMetadata = {foo: this.test!.title}; - await Promise.all([ - checkHook('chatNewMessage', ({message}) => { - message.text = modifiedText; - message.customMetadata = customMetadata; - }), - (async () => { - const {message} = await listen('CHAT_MESSAGE'); - assert(message != null); - assert.equal(message.text, modifiedText); - assert.deepEqual(message.customMetadata, customMetadata); - })(), - sendChat(socket, {text: this.test!.title}), - ]); - // Simulate fetch of historical chat messages when a pad is first loaded. - await Promise.all([ - (async () => { - const {messages: [message]} = await listen('CHAT_MESSAGES'); - assert(message != null); - assert.equal(message.text, modifiedText); - assert.deepEqual(message.customMetadata, customMetadata); - })(), - sendMessage(socket, {type: 'GET_CHAT_MESSAGES', start: 0, end: 0}), - ]); - }); - }); + const modifiedText = `${this.test!.title} `; + const customMetadata = { foo: this.test!.title }; + await Promise.all([ + checkHook("chatNewMessage", ({ message }) => { + message.text = modifiedText; + message.customMetadata = customMetadata; + }), + (async () => { + const { message } = await listen("CHAT_MESSAGE"); + assert(message != null); + assert.equal(message.text, modifiedText); + assert.deepEqual(message.customMetadata, customMetadata); + })(), + sendChat(socket, { text: this.test!.title }), + ]); + // Simulate fetch of historical chat messages when a pad is first loaded. + await Promise.all([ + (async () => { + const { + messages: [message], + } = await listen("CHAT_MESSAGES"); + assert(message != null); + assert.equal(message.text, modifiedText); + assert.deepEqual(message.customMetadata, customMetadata); + })(), + sendMessage(socket, { type: "GET_CHAT_MESSAGES", start: 0, end: 0 }), + ]); + }); + }); }); diff --git a/src/tests/backend/specs/contentcollector.ts b/src/tests/backend/specs/contentcollector.ts index 51ae0002f..eaaf82ce8 100644 --- a/src/tests/backend/specs/contentcollector.ts +++ b/src/tests/backend/specs/contentcollector.ts @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /* * While importexport tests target the `setHTML` API endpoint, which is nearly identical to what @@ -9,278 +9,256 @@ * If you add tests here, please also add them to importexport.js */ -import {APool} from "../../../node/types/PadType"; +import { APool } from "../../../node/types/PadType"; -const AttributePool = require('../../../static/js/AttributePool'); -const Changeset = require('../../../static/js/Changeset'); -const assert = require('assert').strict; -const attributes = require('../../../static/js/attributes'); -const contentcollector = require('../../../static/js/contentcollector'); -const jsdom = require('jsdom'); +const AttributePool = require("../../../static/js/AttributePool"); +const Changeset = require("../../../static/js/Changeset"); +const assert = require("assert").strict; +const attributes = require("../../../static/js/attributes"); +const contentcollector = require("../../../static/js/contentcollector"); +const jsdom = require("jsdom"); // All test case `wantAlines` values must only refer to attributes in this list so that the // attribute numbers do not change due to changes in pool insertion order. const knownAttribs = [ - ['insertorder', 'first'], - ['italic', 'true'], - ['list', 'bullet1'], - ['list', 'bullet2'], - ['list', 'number1'], - ['list', 'number2'], - ['lmkr', '1'], - ['start', '1'], - ['start', '2'], + ["insertorder", "first"], + ["italic", "true"], + ["list", "bullet1"], + ["list", "bullet2"], + ["list", "number1"], + ["list", "number2"], + ["lmkr", "1"], + ["start", "1"], + ["start", "2"], ]; const testCases = [ - { - description: 'Simple', - html: '

              foo

              ', - wantAlines: ['+3'], - wantText: ['foo'], - }, - { - description: 'Line starts with asterisk', - html: '

              *foo

              ', - wantAlines: ['+4'], - wantText: ['*foo'], - }, - { - description: 'Complex nested Li', - html: '
              1. one
                1. 1.1
              2. two
              ', - wantAlines: [ - '*0*4*6*7+1+3', - '*0*5*6*8+1+3', - '*0*4*6*8+1+3', - ], - wantText: [ - '*one', '*1.1', '*two', - ], - }, - { - description: 'Complex list of different types', - html: '
              • one
              • two
              • 0
              • 1
              • 2
                • 3
                • 4
              1. item
                1. item1
                2. item2
              ', - wantAlines: [ - '*0*2*6+1+3', - '*0*2*6+1+3', - '*0*2*6+1+1', - '*0*2*6+1+1', - '*0*2*6+1+1', - '*0*3*6+1+1', - '*0*3*6+1+1', - '*0*4*6*7+1+4', - '*0*5*6*8+1+5', - '*0*5*6*8+1+5', - ], - wantText: [ - '*one', - '*two', - '*0', - '*1', - '*2', - '*3', - '*4', - '*item', - '*item1', - '*item2', - ], - }, - { - description: 'Tests if uls properly get attributes', - html: '
              • a
              • b
              div

              foo

              ', - wantAlines: [ - '*0*2*6+1+1', - '*0*2*6+1+1', - '+3', - '+3', - ], - wantText: ['*a', '*b', 'div', 'foo'], - }, - { - description: 'Tests if indented uls properly get attributes', - html: '
              • a
                • b
              • a

              foo

              ', - wantAlines: [ - '*0*2*6+1+1', - '*0*3*6+1+1', - '*0*2*6+1+1', - '+3', - ], - wantText: ['*a', '*b', '*a', 'foo'], - }, - { - description: 'Tests if ols properly get line numbers when in a normal OL', - html: '
              1. a
              2. b
              3. c

              test

              ', - wantAlines: [ - '*0*4*6*7+1+1', - '*0*4*6*7+1+1', - '*0*4*6*7+1+1', - '+4', - ], - wantText: ['*a', '*b', '*c', 'test'], - noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?', - }, - { - description: 'A single completely empty line break within an ol should reset count if OL is closed off..', - html: '
              1. should be 1

              hello

              1. should be 1
              2. should be 2

              ', - wantAlines: [ - '*0*4*6*7+1+b', - '+5', - '*0*4*6*8+1+b', - '*0*4*6*8+1+b', - '', - ], - wantText: ['*should be 1', 'hello', '*should be 1', '*should be 2', ''], - noteToSelf: "Shouldn't include attribute marker in the

              line", - }, - { - description: 'A single

              should create a new line', - html: '

              ', - wantAlines: ['', ''], - wantText: ['', ''], - noteToSelf: '

              should create a line break but not break numbering', - }, - { - description: 'Tests if ols properly get line numbers when in a normal OL #2', - html: 'a
              1. b
                1. c
              notlist

              foo

              ', - wantAlines: [ - '+1', - '*0*4*6*7+1+1', - '*0*5*6*8+1+1', - '+7', - '+3', - ], - wantText: ['a', '*b', '*c', 'notlist', 'foo'], - noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?', - }, - { - description: 'First item being an UL then subsequent being OL will fail', - html: '
              • a
                1. b
                2. c
              ', - wantAlines: ['+1', '*0*1*2*3+1+1', '*0*4*2*5+1+1'], - wantText: ['a', '*b', '*c'], - noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?', - disabled: true, - }, - { - description: 'A single completely empty line break within an ol should NOT reset count', - html: '
              1. should be 1
              2. should be 2
              3. should be 3

              ', - wantAlines: [], - wantText: ['*should be 1', '*should be 2', '*should be 3'], - noteToSelf: "

              should create a line break but not break numbering -- This is what I can't get working!", - disabled: true, - }, - { - description: 'Content outside body should be ignored', - html: 'titleempty
              ', - wantAlines: ['+5'], - wantText: ['empty'], - }, - { - description: 'Multiple spaces should be preserved', - html: 'Text with more than one space.
              ', - wantAlines: ['+10'], - wantText: ['Text with more than one space.'], - }, - { - description: 'non-breaking and normal space should be preserved', - html: 'Text with  more   than  one space.
              ', - wantAlines: ['+10'], - wantText: ['Text with more than one space.'], - }, - { - description: 'Multiple nbsp should be preserved', - html: '  
              ', - wantAlines: ['+2'], - wantText: [' '], - }, - { - description: 'Multiple nbsp between words ', - html: '  word1  word2   word3
              ', - wantAlines: ['+m'], - wantText: [' word1 word2 word3'], - }, - { - description: 'A non-breaking space preceded by a normal space', - html: '  word1  word2  word3
              ', - wantAlines: ['+l'], - wantText: [' word1 word2 word3'], - }, - { - description: 'A non-breaking space followed by a normal space', - html: '  word1  word2  word3
              ', - wantAlines: ['+l'], - wantText: [' word1 word2 word3'], - }, - { - description: 'Don\'t collapse spaces that follow a newline', - html: 'something
              something
              ', - wantAlines: ['+9', '+m'], - wantText: ['something', ' something'], - }, - { - description: 'Don\'t collapse spaces that follow a empty paragraph', - html: 'something

              something
              ', - wantAlines: ['+9', '', '+m'], - wantText: ['something', '', ' something'], - }, - { - description: 'Don\'t collapse spaces that preceed/follow a newline', - html: 'something
              something
              ', - wantAlines: ['+l', '+m'], - wantText: ['something ', ' something'], - }, - { - description: 'Don\'t collapse spaces that preceed/follow a empty paragraph', - html: 'something

              something
              ', - wantAlines: ['+l', '', '+m'], - wantText: ['something ', '', ' something'], - }, - { - description: 'Don\'t collapse non-breaking spaces that follow a newline', - html: 'something
                 something
              ', - wantAlines: ['+9', '+c'], - wantText: ['something', ' something'], - }, - { - description: 'Don\'t collapse non-breaking spaces that follow a paragraph', - html: 'something

                 something
              ', - wantAlines: ['+9', '', '+c'], - wantText: ['something', '', ' something'], - }, - { - description: 'Preserve all spaces when multiple are present', - html: 'Need more space s !
              ', - wantAlines: ['+h*1+4+2'], - wantText: ['Need more space s !'], - }, - { - description: 'Newlines and multiple spaces across newlines should be preserved', - html: ` + { + description: "Simple", + html: "

              foo

              ", + wantAlines: ["+3"], + wantText: ["foo"], + }, + { + description: "Line starts with asterisk", + html: "

              *foo

              ", + wantAlines: ["+4"], + wantText: ["*foo"], + }, + { + description: "Complex nested Li", + html: "
              1. one
                1. 1.1
              2. two
              ", + wantAlines: ["*0*4*6*7+1+3", "*0*5*6*8+1+3", "*0*4*6*8+1+3"], + wantText: ["*one", "*1.1", "*two"], + }, + { + description: "Complex list of different types", + html: '
              • one
              • two
              • 0
              • 1
              • 2
                • 3
                • 4
              1. item
                1. item1
                2. item2
              ', + wantAlines: [ + "*0*2*6+1+3", + "*0*2*6+1+3", + "*0*2*6+1+1", + "*0*2*6+1+1", + "*0*2*6+1+1", + "*0*3*6+1+1", + "*0*3*6+1+1", + "*0*4*6*7+1+4", + "*0*5*6*8+1+5", + "*0*5*6*8+1+5", + ], + wantText: [ + "*one", + "*two", + "*0", + "*1", + "*2", + "*3", + "*4", + "*item", + "*item1", + "*item2", + ], + }, + { + description: "Tests if uls properly get attributes", + html: "
              • a
              • b
              div

              foo

              ", + wantAlines: ["*0*2*6+1+1", "*0*2*6+1+1", "+3", "+3"], + wantText: ["*a", "*b", "div", "foo"], + }, + { + description: "Tests if indented uls properly get attributes", + html: "
              • a
                • b
              • a

              foo

              ", + wantAlines: ["*0*2*6+1+1", "*0*3*6+1+1", "*0*2*6+1+1", "+3"], + wantText: ["*a", "*b", "*a", "foo"], + }, + { + description: "Tests if ols properly get line numbers when in a normal OL", + html: "
              1. a
              2. b
              3. c

              test

              ", + wantAlines: ["*0*4*6*7+1+1", "*0*4*6*7+1+1", "*0*4*6*7+1+1", "+4"], + wantText: ["*a", "*b", "*c", "test"], + noteToSelf: + "Ensure empty P does not induce line attribute marker, wont this break the editor?", + }, + { + description: + "A single completely empty line break within an ol should reset count if OL is closed off..", + html: "
              1. should be 1

              hello

              1. should be 1
              2. should be 2

              ", + wantAlines: ["*0*4*6*7+1+b", "+5", "*0*4*6*8+1+b", "*0*4*6*8+1+b", ""], + wantText: ["*should be 1", "hello", "*should be 1", "*should be 2", ""], + noteToSelf: "Shouldn't include attribute marker in the

              line", + }, + { + description: "A single

              should create a new line", + html: "

              ", + wantAlines: ["", ""], + wantText: ["", ""], + noteToSelf: "

              should create a line break but not break numbering", + }, + { + description: + "Tests if ols properly get line numbers when in a normal OL #2", + html: "a
              1. b
                1. c
              notlist

              foo

              ", + wantAlines: ["+1", "*0*4*6*7+1+1", "*0*5*6*8+1+1", "+7", "+3"], + wantText: ["a", "*b", "*c", "notlist", "foo"], + noteToSelf: + "Ensure empty P does not induce line attribute marker, wont this break the editor?", + }, + { + description: "First item being an UL then subsequent being OL will fail", + html: "
              • a
                1. b
                2. c
              ", + wantAlines: ["+1", "*0*1*2*3+1+1", "*0*4*2*5+1+1"], + wantText: ["a", "*b", "*c"], + noteToSelf: + "Ensure empty P does not induce line attribute marker, wont this break the editor?", + disabled: true, + }, + { + description: + "A single completely empty line break within an ol should NOT reset count", + html: "
              1. should be 1
              2. should be 2
              3. should be 3

              ", + wantAlines: [], + wantText: ["*should be 1", "*should be 2", "*should be 3"], + noteToSelf: + "

              should create a line break but not break numbering -- This is what I can't get working!", + disabled: true, + }, + { + description: "Content outside body should be ignored", + html: "titleempty
              ", + wantAlines: ["+5"], + wantText: ["empty"], + }, + { + description: "Multiple spaces should be preserved", + html: "Text with more than one space.
              ", + wantAlines: ["+10"], + wantText: ["Text with more than one space."], + }, + { + description: "non-breaking and normal space should be preserved", + html: "Text with  more   than  one space.
              ", + wantAlines: ["+10"], + wantText: ["Text with more than one space."], + }, + { + description: "Multiple nbsp should be preserved", + html: "  
              ", + wantAlines: ["+2"], + wantText: [" "], + }, + { + description: "Multiple nbsp between words ", + html: "  word1  word2   word3
              ", + wantAlines: ["+m"], + wantText: [" word1 word2 word3"], + }, + { + description: "A non-breaking space preceded by a normal space", + html: "  word1  word2  word3
              ", + wantAlines: ["+l"], + wantText: [" word1 word2 word3"], + }, + { + description: "A non-breaking space followed by a normal space", + html: "  word1  word2  word3
              ", + wantAlines: ["+l"], + wantText: [" word1 word2 word3"], + }, + { + description: "Don't collapse spaces that follow a newline", + html: "something
              something
              ", + wantAlines: ["+9", "+m"], + wantText: ["something", " something"], + }, + { + description: "Don't collapse spaces that follow a empty paragraph", + html: "something

              something
              ", + wantAlines: ["+9", "", "+m"], + wantText: ["something", "", " something"], + }, + { + description: "Don't collapse spaces that preceed/follow a newline", + html: "something
              something
              ", + wantAlines: ["+l", "+m"], + wantText: ["something ", " something"], + }, + { + description: "Don't collapse spaces that preceed/follow a empty paragraph", + html: "something

              something
              ", + wantAlines: ["+l", "", "+m"], + wantText: ["something ", "", " something"], + }, + { + description: "Don't collapse non-breaking spaces that follow a newline", + html: "something
                 something
              ", + wantAlines: ["+9", "+c"], + wantText: ["something", " something"], + }, + { + description: "Don't collapse non-breaking spaces that follow a paragraph", + html: "something

                 something
              ", + wantAlines: ["+9", "", "+c"], + wantText: ["something", "", " something"], + }, + { + description: "Preserve all spaces when multiple are present", + html: "Need more space s !
              ", + wantAlines: ["+h*1+4+2"], + wantText: ["Need more space s !"], + }, + { + description: + "Newlines and multiple spaces across newlines should be preserved", + html: ` Need more space s !
              `, - wantAlines: ['+19*1+4+b'], - wantText: ['Need more space s !'], - }, - { - description: 'Multiple new lines at the beginning should be preserved', - html: '

              first line

              second line
              ', - wantAlines: ['', '', '', '', '+a', '', '+b'], - wantText: ['', '', '', '', 'first line', '', 'second line'], - }, - { - description: 'A paragraph with multiple lines should not loose spaces when lines are combined', - html: `

              + wantAlines: ["+19*1+4+b"], + wantText: ["Need more space s !"], + }, + { + description: "Multiple new lines at the beginning should be preserved", + html: "

              first line

              second line
              ", + wantAlines: ["", "", "", "", "+a", "", "+b"], + wantText: ["", "", "", "", "first line", "", "second line"], + }, + { + description: + "A paragraph with multiple lines should not loose spaces when lines are combined", + html: `

              а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь

              `, - wantAlines: ['+1t'], - wantText: ['а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь'], - }, - { - description: 'lines in preformatted text should be kept intact', - html: `

              + wantAlines: ["+1t"], + wantText: [ + "а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь", + ], + }, + { + description: "lines in preformatted text should be kept intact", + html: `

              а б в г ґ д е є ж з и і ї й к л м н о

              multiple
               lines
               in
              @@ -288,104 +266,108 @@ pre
               

              п р с т у ф х ц ч ш щ ю я ь

              `, - wantAlines: ['+11', '+8', '+5', '+2', '+3', '+r'], - wantText: [ - 'а б в г ґ д е є ж з и і ї й к л м н о', - 'multiple', - 'lines', - 'in', - 'pre', - 'п р с т у ф х ц ч ш щ ю я ь', - ], - }, - { - description: 'pre should be on a new line not preceded by a space', - html: `

              + wantAlines: ["+11", "+8", "+5", "+2", "+3", "+r"], + wantText: [ + "а б в г ґ д е є ж з и і ї й к л м н о", + "multiple", + "lines", + "in", + "pre", + "п р с т у ф х ц ч ш щ ю я ь", + ], + }, + { + description: "pre should be on a new line not preceded by a space", + html: `

              1

              preline
               
              `, - wantAlines: ['+6', '+7'], - wantText: [' 1 ', 'preline'], - }, - { - description: 'Preserve spaces on the beginning and end of a element', - html: 'Need more space s !
              ', - wantAlines: ['+f*1+3+1'], - wantText: ['Need more space s !'], - }, - { - description: 'Preserve spaces outside elements', - html: 'Need more space s !
              ', - wantAlines: ['+g*1+1+2'], - wantText: ['Need more space s !'], - }, - { - description: 'Preserve spaces at the end of an element', - html: 'Need more space s !
              ', - wantAlines: ['+g*1+2+1'], - wantText: ['Need more space s !'], - }, - { - description: 'Preserve spaces at the start of an element', - html: 'Need more space s !
              ', - wantAlines: ['+f*1+2+2'], - wantText: ['Need more space s !'], - }, + wantAlines: ["+6", "+7"], + wantText: [" 1 ", "preline"], + }, + { + description: "Preserve spaces on the beginning and end of a element", + html: "Need more space s !
              ", + wantAlines: ["+f*1+3+1"], + wantText: ["Need more space s !"], + }, + { + description: "Preserve spaces outside elements", + html: "Need more space s !
              ", + wantAlines: ["+g*1+1+2"], + wantText: ["Need more space s !"], + }, + { + description: "Preserve spaces at the end of an element", + html: "Need more space s !
              ", + wantAlines: ["+g*1+2+1"], + wantText: ["Need more space s !"], + }, + { + description: "Preserve spaces at the start of an element", + html: "Need more space s !
              ", + wantAlines: ["+f*1+2+2"], + wantText: ["Need more space s !"], + }, ]; describe(__filename, function () { - for (const tc of testCases) { - describe(tc.description, function () { - let apool: APool; - let result: { - lines: string[], - lineAttribs: string[], - }; + for (const tc of testCases) { + describe(tc.description, function () { + let apool: APool; + let result: { + lines: string[]; + lineAttribs: string[]; + }; - before(async function () { - if (tc.disabled) return this.skip(); - const {window: {document}} = new jsdom.JSDOM(tc.html); - apool = new AttributePool(); - // To reduce test fragility, the attribute pool is seeded with `knownAttribs`, and all - // attributes in `tc.wantAlines` must be in `knownAttribs`. (This guarantees that attribute - // numbers do not change if the attribute processing code changes.) - for (const attrib of knownAttribs) apool.putAttrib(attrib); - for (const aline of tc.wantAlines) { - for (const op of Changeset.deserializeOps(aline)) { - for (const n of attributes.decodeAttribString(op.attribs)) { - assert(n < knownAttribs.length); - } - } - } - const cc = contentcollector.makeContentCollector(true, null, apool); - cc.collectContent(document.body); - result = cc.finish(); - }); + before(async function () { + if (tc.disabled) return this.skip(); + const { + window: { document }, + } = new jsdom.JSDOM(tc.html); + apool = new AttributePool(); + // To reduce test fragility, the attribute pool is seeded with `knownAttribs`, and all + // attributes in `tc.wantAlines` must be in `knownAttribs`. (This guarantees that attribute + // numbers do not change if the attribute processing code changes.) + for (const attrib of knownAttribs) apool.putAttrib(attrib); + for (const aline of tc.wantAlines) { + for (const op of Changeset.deserializeOps(aline)) { + for (const n of attributes.decodeAttribString(op.attribs)) { + assert(n < knownAttribs.length); + } + } + } + const cc = contentcollector.makeContentCollector(true, null, apool); + cc.collectContent(document.body); + result = cc.finish(); + }); - it('text matches', async function () { - assert.deepEqual(result.lines, tc.wantText); - }); + it("text matches", async function () { + assert.deepEqual(result.lines, tc.wantText); + }); - it('alines match', async function () { - assert.deepEqual(result.lineAttribs, tc.wantAlines); - }); + it("alines match", async function () { + assert.deepEqual(result.lineAttribs, tc.wantAlines); + }); - it('attributes are sorted in canonical order', async function () { - const gotAttribs:string[][][] = []; - const wantAttribs = []; - for (const aline of result.lineAttribs) { - const gotAlineAttribs:string[][] = []; - gotAttribs.push(gotAlineAttribs); - const wantAlineAttribs:string[] = []; - wantAttribs.push(wantAlineAttribs); - for (const op of Changeset.deserializeOps(aline)) { - const gotOpAttribs:string[] = [...attributes.attribsFromString(op.attribs, apool)]; - gotAlineAttribs.push(gotOpAttribs); - wantAlineAttribs.push(attributes.sort([...gotOpAttribs])); - } - } - assert.deepEqual(gotAttribs, wantAttribs); - }); - }); - } + it("attributes are sorted in canonical order", async function () { + const gotAttribs: string[][][] = []; + const wantAttribs = []; + for (const aline of result.lineAttribs) { + const gotAlineAttribs: string[][] = []; + gotAttribs.push(gotAlineAttribs); + const wantAlineAttribs: string[] = []; + wantAttribs.push(wantAlineAttribs); + for (const op of Changeset.deserializeOps(aline)) { + const gotOpAttribs: string[] = [ + ...attributes.attribsFromString(op.attribs, apool), + ]; + gotAlineAttribs.push(gotOpAttribs); + wantAlineAttribs.push(attributes.sort([...gotOpAttribs])); + } + } + assert.deepEqual(gotAttribs, wantAttribs); + }); + }); + } }); diff --git a/src/tests/backend/specs/crypto.ts b/src/tests/backend/specs/crypto.ts index 62d79f1b3..9aed250d2 100644 --- a/src/tests/backend/specs/crypto.ts +++ b/src/tests/backend/specs/crypto.ts @@ -1,10 +1,9 @@ -'use strict'; +"use strict"; - -import {Buffer} from 'buffer'; -import nodeCrypto from 'crypto'; -import util from 'util'; +import { Buffer } from "buffer"; +import nodeCrypto from "crypto"; +import util from "util"; const nodeHkdf = nodeCrypto.hkdf ? util.promisify(nodeCrypto.hkdf) : null; -const ab2hex = (ab:string) => Buffer.from(ab).toString('hex'); +const ab2hex = (ab: string) => Buffer.from(ab).toString("hex"); diff --git a/src/tests/backend/specs/export.ts b/src/tests/backend/specs/export.ts index de436f88c..e878ce88a 100644 --- a/src/tests/backend/specs/export.ts +++ b/src/tests/backend/specs/export.ts @@ -1,28 +1,27 @@ -'use strict'; +"use strict"; -import {MapArrayType} from "../../../node/types/MapType"; +import { MapArrayType } from "../../../node/types/MapType"; -const common = require('../common'); -const padManager = require('../../../node/db/PadManager'); -const settings = require('../../../node/utils/Settings'); +const common = require("../common"); +const padManager = require("../../../node/db/PadManager"); +const settings = require("../../../node/utils/Settings"); describe(__filename, function () { - let agent:any; - const settingsBackup:MapArrayType = {}; + let agent: any; + const settingsBackup: MapArrayType = {}; - before(async function () { - agent = await common.init(); - settingsBackup.soffice = settings.soffice; - await padManager.getPad('testExportPad', 'test content'); - }); + before(async function () { + agent = await common.init(); + settingsBackup.soffice = settings.soffice; + await padManager.getPad("testExportPad", "test content"); + }); - after(async function () { - Object.assign(settings, settingsBackup); - }); + after(async function () { + Object.assign(settings, settingsBackup); + }); - it('returns 500 on export error', async function () { - settings.soffice = 'false'; // '/bin/false' doesn't work on Windows - await agent.get('/p/testExportPad/export/doc') - .expect(500); - }); + it("returns 500 on export error", async function () { + settings.soffice = "false"; // '/bin/false' doesn't work on Windows + await agent.get("/p/testExportPad/export/doc").expect(500); + }); }); diff --git a/src/tests/backend/specs/favicon.ts b/src/tests/backend/specs/favicon.ts index 6b6230b4b..8edff30d1 100644 --- a/src/tests/backend/specs/favicon.ts +++ b/src/tests/backend/specs/favicon.ts @@ -1,99 +1,125 @@ -'use strict'; +"use strict"; -import {MapArrayType} from "../../../node/types/MapType"; +import { MapArrayType } from "../../../node/types/MapType"; -const assert = require('assert').strict; -const common = require('../common'); -const fs = require('fs'); +const assert = require("assert").strict; +const common = require("../common"); +const fs = require("fs"); const fsp = fs.promises; -const path = require('path'); -const settings = require('../../../node/utils/Settings'); -const superagent = require('superagent'); +const path = require("path"); +const settings = require("../../../node/utils/Settings"); +const superagent = require("superagent"); describe(__filename, function () { - let agent:any; - let backupSettings:MapArrayType; - let skinDir: string; - let wantCustomIcon: boolean; - let wantDefaultIcon: boolean; - let wantSkinIcon: boolean; + let agent: any; + let backupSettings: MapArrayType; + let skinDir: string; + let wantCustomIcon: boolean; + let wantDefaultIcon: boolean; + let wantSkinIcon: boolean; - before(async function () { - agent = await common.init(); - wantCustomIcon = await fsp.readFile(path.join(__dirname, 'favicon-test-custom.png')); - wantDefaultIcon = await fsp.readFile(path.join(settings.root, 'src', 'static', 'favicon.ico')); - wantSkinIcon = await fsp.readFile(path.join(__dirname, 'favicon-test-skin.png')); - }); + before(async function () { + agent = await common.init(); + wantCustomIcon = await fsp.readFile( + path.join(__dirname, "favicon-test-custom.png"), + ); + wantDefaultIcon = await fsp.readFile( + path.join(settings.root, "src", "static", "favicon.ico"), + ); + wantSkinIcon = await fsp.readFile( + path.join(__dirname, "favicon-test-skin.png"), + ); + }); - beforeEach(async function () { - backupSettings = {...settings}; - skinDir = await fsp.mkdtemp(path.join(settings.root, 'src', 'static', 'skins', 'test-')); - settings.skinName = path.basename(skinDir); - }); + beforeEach(async function () { + backupSettings = { ...settings }; + skinDir = await fsp.mkdtemp( + path.join(settings.root, "src", "static", "skins", "test-"), + ); + settings.skinName = path.basename(skinDir); + }); - afterEach(async function () { - delete settings.favicon; - delete settings.skinName; - Object.assign(settings, backupSettings); - try { - // TODO: The {recursive: true} option wasn't added to fsp.rmdir() until Node.js v12.10.0 so we - // can't rely on it until support for Node.js v10 is dropped. - await fsp.unlink(path.join(skinDir, 'favicon.ico')); - await fsp.rmdir(skinDir, {recursive: true}); - } catch (err) { /* intentionally ignored */ } - }); + afterEach(async function () { + delete settings.favicon; + delete settings.skinName; + Object.assign(settings, backupSettings); + try { + // TODO: The {recursive: true} option wasn't added to fsp.rmdir() until Node.js v12.10.0 so we + // can't rely on it until support for Node.js v10 is dropped. + await fsp.unlink(path.join(skinDir, "favicon.ico")); + await fsp.rmdir(skinDir, { recursive: true }); + } catch (err) { + /* intentionally ignored */ + } + }); - it('uses custom favicon if set (relative pathname)', async function () { - settings.favicon = - path.relative(settings.root, path.join(__dirname, 'favicon-test-custom.png')); - assert(!path.isAbsolute(settings.favicon)); - const {body: gotIcon} = await agent.get('/favicon.ico') - .accept('png').buffer(true).parse(superagent.parse.image) - .expect(200); - assert(gotIcon.equals(wantCustomIcon)); - }); + it("uses custom favicon if set (relative pathname)", async function () { + settings.favicon = path.relative( + settings.root, + path.join(__dirname, "favicon-test-custom.png"), + ); + assert(!path.isAbsolute(settings.favicon)); + const { body: gotIcon } = await agent + .get("/favicon.ico") + .accept("png") + .buffer(true) + .parse(superagent.parse.image) + .expect(200); + assert(gotIcon.equals(wantCustomIcon)); + }); - it('uses custom favicon from url', async function () { - settings.favicon = 'https://etherpad.org/favicon.ico'; - await agent.get('/favicon.ico') - .expect(302); - }); + it("uses custom favicon from url", async function () { + settings.favicon = "https://etherpad.org/favicon.ico"; + await agent.get("/favicon.ico").expect(302); + }); - it('uses custom favicon if set (absolute pathname)', async function () { - settings.favicon = path.join(__dirname, 'favicon-test-custom.png'); - assert(path.isAbsolute(settings.favicon)); - const {body: gotIcon} = await agent.get('/favicon.ico') - .accept('png').buffer(true).parse(superagent.parse.image) - .expect(200); - assert(gotIcon.equals(wantCustomIcon)); - }); + it("uses custom favicon if set (absolute pathname)", async function () { + settings.favicon = path.join(__dirname, "favicon-test-custom.png"); + assert(path.isAbsolute(settings.favicon)); + const { body: gotIcon } = await agent + .get("/favicon.ico") + .accept("png") + .buffer(true) + .parse(superagent.parse.image) + .expect(200); + assert(gotIcon.equals(wantCustomIcon)); + }); - it('falls back if custom favicon is missing', async function () { - // The previous default for settings.favicon was 'favicon.ico', so many users will continue to - // have that in their settings.json for a long time. There is unlikely to be a favicon at - // path.resolve(settings.root, 'favicon.ico'), so this test ensures that 'favicon.ico' won't be - // a problem for those users. - settings.favicon = 'favicon.ico'; - const {body: gotIcon} = await agent.get('/favicon.ico') - .accept('png').buffer(true).parse(superagent.parse.image) - .expect(200); - assert(gotIcon.equals(wantDefaultIcon)); - }); + it("falls back if custom favicon is missing", async function () { + // The previous default for settings.favicon was 'favicon.ico', so many users will continue to + // have that in their settings.json for a long time. There is unlikely to be a favicon at + // path.resolve(settings.root, 'favicon.ico'), so this test ensures that 'favicon.ico' won't be + // a problem for those users. + settings.favicon = "favicon.ico"; + const { body: gotIcon } = await agent + .get("/favicon.ico") + .accept("png") + .buffer(true) + .parse(superagent.parse.image) + .expect(200); + assert(gotIcon.equals(wantDefaultIcon)); + }); - it('uses skin favicon if present', async function () { - await fsp.writeFile(path.join(skinDir, 'favicon.ico'), wantSkinIcon); - settings.favicon = null; - const {body: gotIcon} = await agent.get('/favicon.ico') - .accept('png').buffer(true).parse(superagent.parse.image) - .expect(200); - assert(gotIcon.equals(wantSkinIcon)); - }); + it("uses skin favicon if present", async function () { + await fsp.writeFile(path.join(skinDir, "favicon.ico"), wantSkinIcon); + settings.favicon = null; + const { body: gotIcon } = await agent + .get("/favicon.ico") + .accept("png") + .buffer(true) + .parse(superagent.parse.image) + .expect(200); + assert(gotIcon.equals(wantSkinIcon)); + }); - it('falls back to default favicon', async function () { - settings.favicon = null; - const {body: gotIcon} = await agent.get('/favicon.ico') - .accept('png').buffer(true).parse(superagent.parse.image) - .expect(200); - assert(gotIcon.equals(wantDefaultIcon)); - }); + it("falls back to default favicon", async function () { + settings.favicon = null; + const { body: gotIcon } = await agent + .get("/favicon.ico") + .accept("png") + .buffer(true) + .parse(superagent.parse.image) + .expect(200); + assert(gotIcon.equals(wantDefaultIcon)); + }); }); diff --git a/src/tests/backend/specs/health.ts b/src/tests/backend/specs/health.ts index 97364a7e5..3510087b3 100644 --- a/src/tests/backend/specs/health.ts +++ b/src/tests/backend/specs/health.ts @@ -1,58 +1,60 @@ -'use strict'; +"use strict"; -import {MapArrayType} from "../../../node/types/MapType"; +import { MapArrayType } from "../../../node/types/MapType"; -const assert = require('assert').strict; -const common = require('../common'); -const settings = require('../../../node/utils/Settings'); -const superagent = require('superagent'); +const assert = require("assert").strict; +const common = require("../common"); +const settings = require("../../../node/utils/Settings"); +const superagent = require("superagent"); describe(__filename, function () { - let agent:any; - const backup:MapArrayType = {}; + let agent: any; + const backup: MapArrayType = {}; - const getHealth = () => agent.get('/health') - .accept('application/health+json') - .buffer(true) - .parse(superagent.parse['application/json']) - .expect(200) - .expect((res:any) => assert.equal(res.type, 'application/health+json')); + const getHealth = () => + agent + .get("/health") + .accept("application/health+json") + .buffer(true) + .parse(superagent.parse["application/json"]) + .expect(200) + .expect((res: any) => assert.equal(res.type, "application/health+json")); - before(async function () { - agent = await common.init(); - }); + before(async function () { + agent = await common.init(); + }); - beforeEach(async function () { - backup.settings = {}; - for (const setting of ['requireAuthentication', 'requireAuthorization']) { - backup.settings[setting] = settings[setting]; - } - }); + beforeEach(async function () { + backup.settings = {}; + for (const setting of ["requireAuthentication", "requireAuthorization"]) { + backup.settings[setting] = settings[setting]; + } + }); - afterEach(async function () { - Object.assign(settings, backup.settings); - }); + afterEach(async function () { + Object.assign(settings, backup.settings); + }); - it('/health works', async function () { - const res = await getHealth(); - assert.equal(res.body.status, 'pass'); - assert.equal(res.body.releaseId, settings.getEpVersion()); - }); + it("/health works", async function () { + const res = await getHealth(); + assert.equal(res.body.status, "pass"); + assert.equal(res.body.releaseId, settings.getEpVersion()); + }); - it('auth is not required', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - const res = await getHealth(); - assert.equal(res.body.status, 'pass'); - }); + it("auth is not required", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + const res = await getHealth(); + assert.equal(res.body.status, "pass"); + }); - // We actually want to test that no express-session state is created, but that is difficult to do - // without intrusive changes or unpleasant ueberdb digging. Instead, we assume that the lack of a - // cookie means that no express-session state was created (how would express-session look up the - // session state if no ID was returned to the client?). - it('no cookie is returned', async function () { - const res = await getHealth(); - const cookie = res.headers['set-cookie']; - assert(cookie == null, `unexpected Set-Cookie: ${cookie}`); - }); + // We actually want to test that no express-session state is created, but that is difficult to do + // without intrusive changes or unpleasant ueberdb digging. Instead, we assume that the lack of a + // cookie means that no express-session state was created (how would express-session look up the + // session state if no ID was returned to the client?). + it("no cookie is returned", async function () { + const res = await getHealth(); + const cookie = res.headers["set-cookie"]; + assert(cookie == null, `unexpected Set-Cookie: ${cookie}`); + }); }); diff --git a/src/tests/backend/specs/hooks.ts b/src/tests/backend/specs/hooks.ts index 07c6e262e..dcc3518aa 100644 --- a/src/tests/backend/specs/hooks.ts +++ b/src/tests/backend/specs/hooks.ts @@ -1,1244 +1,1417 @@ -'use strict'; - -import {strict as assert} from 'assert'; -const hooks = require('../../../static/js/pluginfw/hooks'); -const plugins = require('../../../static/js/pluginfw/plugin_defs'); -import sinon from 'sinon'; -import {MapArrayType} from "../../../node/types/MapType"; +"use strict"; +import { strict as assert } from "assert"; +const hooks = require("../../../static/js/pluginfw/hooks"); +const plugins = require("../../../static/js/pluginfw/plugin_defs"); +import sinon from "sinon"; +import { MapArrayType } from "../../../node/types/MapType"; interface ExtendedConsole extends Console { - warn: { - (message?: any, ...optionalParams: any[]): void; - callCount: number; - getCall: (i: number) => {args: any[]}; - }; - error: { - (message?: any, ...optionalParams: any[]): void; - callCount: number; - getCall: (i: number) => {args: any[]}; - callsFake: (fn: Function) => void; - getCalls: () => {args: any[]}[]; - }; + warn: { + (message?: any, ...optionalParams: any[]): void; + callCount: number; + getCall: (i: number) => { args: any[] }; + }; + error: { + (message?: any, ...optionalParams: any[]): void; + callCount: number; + getCall: (i: number) => { args: any[] }; + callsFake: (fn: Function) => void; + getCalls: () => { args: any[] }[]; + }; } declare var console: ExtendedConsole; describe(__filename, function () { - - - - const hookName = 'testHook'; - const hookFnName = 'testPluginFileName:testHookFunctionName'; - let testHooks; // Convenience shorthand for plugins.hooks[hookName]. - let hook: any; // Convenience shorthand for plugins.hooks[hookName][0]. - - beforeEach(async function () { - // Make sure these are not already set so that we don't accidentally step on someone else's - // toes: - assert(plugins.hooks[hookName] == null); - assert(hooks.deprecationNotices[hookName] == null); - assert(hooks.exportedForTestingOnly.deprecationWarned[hookFnName] == null); - - // Many of the tests only need a single registered hook function. Set that up here to reduce - // boilerplate. - hook = makeHook(); - plugins.hooks[hookName] = [hook]; - testHooks = plugins.hooks[hookName]; - }); - - afterEach(async function () { - sinon.restore(); - delete plugins.hooks[hookName]; - delete hooks.deprecationNotices[hookName]; - delete hooks.exportedForTestingOnly.deprecationWarned[hookFnName]; - }); - - const makeHook = (ret?:any) => ({ - hook_name: hookName, - // Many tests will likely want to change this. Unfortunately, we can't use a convenience - // wrapper like `(...args) => hookFn(..args)` because the hooks look at Function.length and - // change behavior depending on the number of parameters. - hook_fn: (hn:Function, ctx:any, cb:Function) => cb(ret), - hook_fn_name: hookFnName, - part: {plugin: 'testPluginName'}, - }); - - // Hook functions that should work for both synchronous and asynchronous hooks. - const supportedSyncHookFunctions = [ - { - name: 'return non-Promise value, with callback parameter', - fn: (hn:Function, ctx:any, cb:Function) => 'val', - want: 'val', - syncOk: true, - }, - { - name: 'return non-Promise value, without callback parameter', - fn: (hn:Function, ctx:any) => 'val', - want: 'val', - syncOk: true, - }, - { - name: 'return undefined, without callback parameter', - fn: (hn:Function, ctx:any) => {}, - want: undefined, - syncOk: true, - }, - { - name: 'pass non-Promise value to callback', - fn: (hn:Function, ctx:any, cb:Function) => { cb('val'); }, - want: 'val', - syncOk: true, - }, - { - name: 'pass undefined to callback', - fn: (hn:Function, ctx:any, cb:Function) => { cb(); }, - want: undefined, - syncOk: true, - }, - { - name: 'return the value returned from the callback', - fn: (hn:Function, ctx:any, cb:Function) => cb('val'), - want: 'val', - syncOk: true, - }, - { - name: 'throw', - fn: (hn:Function, ctx:any, cb:Function) => { throw new Error('test exception'); }, - wantErr: 'test exception', - syncOk: true, - }, - ]; - - describe('callHookFnSync', function () { - const callHookFnSync = hooks.exportedForTestingOnly.callHookFnSync; // Convenience shorthand. - - describe('basic behavior', function () { - it('passes hook name', async function () { - hook.hook_fn = (hn: string) => { assert.equal(hn, hookName); }; - callHookFnSync(hook); - }); - - it('passes context', async function () { - for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn: string, ctx:string) => { assert.equal(ctx, val); }; - callHookFnSync(hook, val); - } - }); - - it('returns the value provided to the callback', async function () { - for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { cb(ctx); }; - assert.equal(callHookFnSync(hook, val), val); - } - }); - - it('returns the value returned by the hook function', async function () { - for (const val of ['value', null, undefined]) { - // Must not have the cb parameter otherwise returning undefined will error. - hook.hook_fn = (hn: string, ctx: any) => ctx; - assert.equal(callHookFnSync(hook, val), val); - } - }); - - it('does not catch exceptions', async function () { - hook.hook_fn = () => { throw new Error('test exception'); }; - assert.throws(() => callHookFnSync(hook), {message: 'test exception'}); - }); - - it('callback returns undefined', async function () { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { assert.equal(cb('foo'), undefined); }; - callHookFnSync(hook); - }); - - it('checks for deprecation', async function () { - sinon.stub(console, 'warn'); - hooks.deprecationNotices[hookName] = 'test deprecation'; - callHookFnSync(hook); - assert.equal(hooks.exportedForTestingOnly.deprecationWarned[hookFnName], true); - // @ts-ignore - assert.equal(console.warn.callCount, 1); - // @ts-ignore - assert.match(console.warn.getCall(0).args[0], /test deprecation/); - }); - }); - - describe('supported hook function styles', function () { - for (const tc of supportedSyncHookFunctions) { - it(tc.name, async function () { - sinon.stub(console, 'warn'); - sinon.stub(console, 'error'); - hook.hook_fn = tc.fn; - const call = () => callHookFnSync(hook); - if (tc.wantErr) { - assert.throws(call, {message: tc.wantErr}); - } else { - assert.equal(call(), tc.want); - } - assert.equal(console.warn.callCount, 0); - assert.equal(console.error.callCount, 0); - }); - } - }); - - describe('bad hook function behavior (other than double settle)', function () { - const promise1 = Promise.resolve('val1'); - const promise2 = Promise.resolve('val2'); - - const testCases = [ - { - name: 'never settles -> buggy hook detected', - // Note that returning undefined without calling the callback is permitted if the function - // has 2 or fewer parameters, so this test function must have 3 parameters. - fn: (hn:Function, ctx:any, cb:Function) => {}, - wantVal: undefined, - wantError: /UNSETTLED FUNCTION BUG/, - }, - { - name: 'returns a Promise -> buggy hook detected', - fn: () => promise1, - wantVal: promise1, - wantError: /PROHIBITED PROMISE BUG/, - }, - { - name: 'passes a Promise to cb -> buggy hook detected', - fn: (hn:Function, ctx:any, cb:Function) => cb(promise2), - wantVal: promise2, - wantError: /PROHIBITED PROMISE BUG/, - }, - ]; - - for (const tc of testCases) { - it(tc.name, async function () { - sinon.stub(console, 'error'); - hook.hook_fn = tc.fn; - assert.equal(callHookFnSync(hook), tc.wantVal); - assert.equal(console.error.callCount, tc.wantError ? 1 : 0); - if (tc.wantError) assert.match(console.error.getCall(0).args[0], tc.wantError); - }); - } - }); - - // Test various ways a hook might attempt to settle twice. (Examples: call the callback a second - // time, or call the callback and then return a value.) - describe('bad hook function behavior (double settle)', function () { - beforeEach(async function () { - sinon.stub(console, 'error'); - }); - - // Each item in this array codifies a way to settle a synchronous hook function. Each of the - // test cases below combines two of these behaviors in a single hook function and confirms - // that callHookFnSync both (1) returns the result of the first settle attempt, and - // (2) detects the second settle attempt. - const behaviors = [ - { - name: 'throw', - fn: (cb: Function, err:any, val: string) => { throw err; }, - rejects: true, - }, - { - name: 'return value', - fn: (cb: Function, err:any, val: string) => val, - }, - { - name: 'immediately call cb(value)', - fn: (cb: Function, err:any, val: string) => cb(val), - }, - { - name: 'defer call to cb(value)', - fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, val); }, - async: true, - }, - ]; - - for (const step1 of behaviors) { - // There can't be a second step if the first step is to return or throw. - if (step1.name.startsWith('return ') || step1.name === 'throw') continue; - for (const step2 of behaviors) { - // If step1 and step2 are both async then there would be three settle attempts (first an - // erroneous unsettled return, then async step 1, then async step 2). Handling triple - // settle would complicate the tests, and it is sufficient to test only double settles. - if (step1.async && step2.async) continue; - - it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { - step1.fn(cb, new Error(ctx.ret1), ctx.ret1); - return step2.fn(cb, new Error(ctx.ret2), ctx.ret2); - }; - - // Temporarily remove unhandled error listeners so that the errors we expect to see - // don't trigger a test failure (or terminate node). - const events = ['uncaughtException', 'unhandledRejection']; - const listenerBackups:MapArrayType = {}; - for (const event of events) { - listenerBackups[event] = process.rawListeners(event); - process.removeAllListeners(event); - } - - // We should see an asynchronous error (either an unhandled Promise rejection or an - // uncaught exception) if and only if one of the two steps was asynchronous or there was - // a throw (in which case the double settle is deferred so that the caller sees the - // original error). - const wantAsyncErr = step1.async || step2.async || step2.rejects; - let tempListener:Function; - let asyncErr:Error|undefined; - try { - const seenErrPromise = new Promise((resolve) => { - tempListener = (err:any) => { - assert.equal(asyncErr, undefined); - asyncErr = err; - resolve(); - }; - if (!wantAsyncErr) resolve(); - }); - // @ts-ignore - events.forEach((event) => process.on(event, tempListener)); - const call = () => callHookFnSync(hook, {ret1: 'val1', ret2: 'val2'}); - if (step2.rejects) { - assert.throws(call, {message: 'val2'}); - } else if (!step1.async && !step2.async) { - assert.throws(call, {message: /DOUBLE SETTLE BUG/}); - } else { - assert.equal(call(), step1.async ? 'val2' : 'val1'); - } - await seenErrPromise; - } finally { - // Restore the original listeners. - for (const event of events) { - // @ts-ignore - process.off(event, tempListener); - for (const listener of listenerBackups[event]) { - process.on(event, listener); - } - } - } - assert.equal(console.error.callCount, 1); - assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); - if (wantAsyncErr) { - assert(asyncErr instanceof Error); - assert.match(asyncErr.message, /DOUBLE SETTLE BUG/); - } - }); - - // This next test is the same as the above test, except the second settle attempt is for - // the same outcome. The two outcomes can't be the same if one step throws and the other - // doesn't, so skip those cases. - if (step1.rejects !== step2.rejects) continue; - - it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () { - const err = new Error('val'); - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { - step1.fn(cb, err, 'val'); - return step2.fn(cb, err, 'val'); - }; - - const errorLogged = new Promise((resolve) => console.error.callsFake(resolve)); - const call = () => callHookFnSync(hook); - if (step2.rejects) { - assert.throws(call, {message: 'val'}); - } else { - assert.equal(call(), 'val'); - } - await errorLogged; - assert.equal(console.error.callCount, 1); - assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); - }); - } - } - }); - }); - - describe('hooks.callAll', function () { - describe('basic behavior', function () { - it('calls all in order', async function () { - testHooks.length = 0; - testHooks.push(makeHook(1), makeHook(2), makeHook(3)); - assert.deepEqual(hooks.callAll(hookName), [1, 2, 3]); - }); - - it('passes hook name', async function () { - hook.hook_fn = (hn:string) => { assert.equal(hn, hookName); }; - hooks.callAll(hookName); - }); - - it('undefined context -> {}', async function () { - hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; - hooks.callAll(hookName); - }); - - it('null context -> {}', async function () { - hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; - hooks.callAll(hookName, null); - }); - - it('context unmodified', async function () { - const wantContext = {}; - hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, wantContext); }; - hooks.callAll(hookName, wantContext); - }); - }); - - describe('result processing', function () { - it('no registered hooks (undefined) -> []', async function () { - delete plugins.hooks.testHook; - assert.deepEqual(hooks.callAll(hookName), []); - }); - - it('no registered hooks (empty list) -> []', async function () { - testHooks.length = 0; - assert.deepEqual(hooks.callAll(hookName), []); - }); - - it('flattens one level', async function () { - testHooks.length = 0; - testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); - assert.deepEqual(hooks.callAll(hookName), [1, 2, [3]]); - }); - - it('filters out undefined', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook([2]), makeHook([[3]])); - assert.deepEqual(hooks.callAll(hookName), [2, [3]]); - }); - - it('preserves null', async function () { - testHooks.length = 0; - testHooks.push(makeHook(null), makeHook([2]), makeHook([[3]])); - assert.deepEqual(hooks.callAll(hookName), [null, 2, [3]]); - }); - - it('all undefined -> []', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook()); - assert.deepEqual(hooks.callAll(hookName), []); - }); - }); - }); - - describe('hooks.callFirst', function () { - it('no registered hooks (undefined) -> []', async function () { - delete plugins.hooks.testHook; - assert.deepEqual(hooks.callFirst(hookName), []); - }); - - it('no registered hooks (empty list) -> []', async function () { - testHooks.length = 0; - assert.deepEqual(hooks.callFirst(hookName), []); - }); - - it('passes hook name => {}', async function () { - hook.hook_fn = (hn: string) => { assert.equal(hn, hookName); }; - hooks.callFirst(hookName); - }); - - it('undefined context => {}', async function () { - hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; - hooks.callFirst(hookName); - }); - - it('null context => {}', async function () { - hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; - hooks.callFirst(hookName, null); - }); - - it('context unmodified', async function () { - const wantContext = {}; - hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, wantContext); }; - hooks.callFirst(hookName, wantContext); - }); - - it('predicate never satisfied -> calls all in order', async function () { - const gotCalls:MapArrayType = []; - testHooks.length = 0; - for (let i = 0; i < 3; i++) { - const hook = makeHook(); - hook.hook_fn = () => { gotCalls.push(i); }; - testHooks.push(hook); - } - assert.deepEqual(hooks.callFirst(hookName), []); - assert.deepEqual(gotCalls, [0, 1, 2]); - }); - - it('stops when predicate is satisfied', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook('val1'), makeHook('val2')); - assert.deepEqual(hooks.callFirst(hookName), ['val1']); - }); - - it('skips values that do not satisfy predicate (undefined)', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook('val1')); - assert.deepEqual(hooks.callFirst(hookName), ['val1']); - }); - - it('skips values that do not satisfy predicate (empty list)', async function () { - testHooks.length = 0; - testHooks.push(makeHook([]), makeHook('val1')); - assert.deepEqual(hooks.callFirst(hookName), ['val1']); - }); - - it('null satisifes the predicate', async function () { - testHooks.length = 0; - testHooks.push(makeHook(null), makeHook('val1')); - assert.deepEqual(hooks.callFirst(hookName), [null]); - }); - - it('non-empty arrays are returned unmodified', async function () { - const want = ['val1']; - testHooks.length = 0; - testHooks.push(makeHook(want), makeHook(['val2'])); - assert.equal(hooks.callFirst(hookName), want); // Note: *NOT* deepEqual! - }); - - it('value can be passed via callback', async function () { - const want = {}; - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { cb(want); }; - const got = hooks.callFirst(hookName); - assert.deepEqual(got, [want]); - assert.equal(got[0], want); // Note: *NOT* deepEqual! - }); - }); - - describe('callHookFnAsync', function () { - const callHookFnAsync = hooks.exportedForTestingOnly.callHookFnAsync; // Convenience shorthand. - - describe('basic behavior', function () { - it('passes hook name', async function () { - hook.hook_fn = (hn:string) => { assert.equal(hn, hookName); }; - await callHookFnAsync(hook); - }); - - it('passes context', async function () { - for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, val); }; - await callHookFnAsync(hook, val); - } - }); - - it('returns the value provided to the callback', async function () { - for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { cb(ctx); }; - assert.equal(await callHookFnAsync(hook, val), val); - assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val); - } - }); - - it('returns the value returned by the hook function', async function () { - for (const val of ['value', null, undefined]) { - // Must not have the cb parameter otherwise returning undefined will never resolve. - hook.hook_fn = (hn: string, ctx: any) => ctx; - assert.equal(await callHookFnAsync(hook, val), val); - assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val); - } - }); - - it('rejects if it throws an exception', async function () { - hook.hook_fn = () => { throw new Error('test exception'); }; - await assert.rejects(callHookFnAsync(hook), {message: 'test exception'}); - }); - - it('rejects if rejected Promise passed to callback', async function () { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => cb(Promise.reject(new Error('test exception'))); - await assert.rejects(callHookFnAsync(hook), {message: 'test exception'}); - }); - - it('rejects if rejected Promise returned', async function () { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => Promise.reject(new Error('test exception')); - await assert.rejects(callHookFnAsync(hook), {message: 'test exception'}); - }); - - it('callback returns undefined', async function () { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { assert.equal(cb('foo'), undefined); }; - await callHookFnAsync(hook); - }); - - it('checks for deprecation', async function () { - sinon.stub(console, 'warn'); - hooks.deprecationNotices[hookName] = 'test deprecation'; - await callHookFnAsync(hook); - assert.equal(hooks.exportedForTestingOnly.deprecationWarned[hookFnName], true); - assert.equal(console.warn.callCount, 1); - assert.match(console.warn.getCall(0).args[0], /test deprecation/); - }); - }); - - describe('supported hook function styles', function () { - // @ts-ignore - const supportedHookFunctions = supportedSyncHookFunctions.concat([ - { - name: 'legacy async cb', - fn: (hn:Function, ctx:any, cb:Function) => { process.nextTick(cb, 'val'); }, - want: 'val', - }, - // Already resolved Promises: - { - name: 'return resolved Promise, with callback parameter', - fn: (hn:Function, ctx:any, cb:Function) => Promise.resolve('val'), - want: 'val', - }, - { - name: 'return resolved Promise, without callback parameter', - fn: (hn: string, ctx: any) => Promise.resolve('val'), - want: 'val', - }, - { - name: 'pass resolved Promise to callback', - fn: (hn:Function, ctx:any, cb:Function) => { cb(Promise.resolve('val')); }, - want: 'val', - }, - // Not yet resolved Promises: - { - name: 'return unresolved Promise, with callback parameter', - fn: (hn:Function, ctx:any, cb:Function) => new Promise((resolve) => process.nextTick(resolve, 'val')), - want: 'val', - }, - { - name: 'return unresolved Promise, without callback parameter', - fn: (hn: string, ctx: any) => new Promise((resolve) => process.nextTick(resolve, 'val')), - want: 'val', - }, - { - name: 'pass unresolved Promise to callback', - fn: (hn:Function, ctx:any, cb:Function) => { cb(new Promise((resolve) => process.nextTick(resolve, 'val'))); }, - want: 'val', - }, - // Already rejected Promises: - { - name: 'return rejected Promise, with callback parameter', - fn: (hn:Function, ctx:any, cb:Function) => Promise.reject(new Error('test rejection')), - wantErr: 'test rejection', - }, - { - name: 'return rejected Promise, without callback parameter', - fn: (hn: string, ctx: any) => Promise.reject(new Error('test rejection')), - wantErr: 'test rejection', - }, - { - name: 'pass rejected Promise to callback', - fn: (hn:Function, ctx:any, cb:Function) => { cb(Promise.reject(new Error('test rejection'))); }, - wantErr: 'test rejection', - }, - // Not yet rejected Promises: - { - name: 'return unrejected Promise, with callback parameter', - fn: (hn:Function, ctx:any, cb:Function) => new Promise((resolve, reject) => { - process.nextTick(reject, new Error('test rejection')); - }), - wantErr: 'test rejection', - }, - { - name: 'return unrejected Promise, without callback parameter', - fn: (hn: string, ctx: any) => new Promise((resolve, reject) => { - process.nextTick(reject, new Error('test rejection')); - }), - wantErr: 'test rejection', - }, - { - name: 'pass unrejected Promise to callback', - fn: (hn:Function, ctx:any, cb:Function) => { - cb(new Promise((resolve, reject) => { - process.nextTick(reject, new Error('test rejection')); - })); - }, - wantErr: 'test rejection', - }, - ]); - - for (const tc of supportedSyncHookFunctions.concat(supportedHookFunctions)) { - it(tc.name, async function () { - sinon.stub(console, 'warn'); - sinon.stub(console, 'error'); - hook.hook_fn = tc.fn; - const p = callHookFnAsync(hook); - if (tc.wantErr) { - await assert.rejects(p, {message: tc.wantErr}); - } else { - assert.equal(await p, tc.want); - } - assert.equal(console.warn.callCount, 0); - assert.equal(console.error.callCount, 0); - }); - } - }); - - // Test various ways a hook might attempt to settle twice. (Examples: call the callback a second - // time, or call the callback and then return a value.) - describe('bad hook function behavior (double settle)', function () { - beforeEach(async function () { - sinon.stub(console, 'error'); - }); - - // Each item in this array codifies a way to settle an asynchronous hook function. Each of the - // test cases below combines two of these behaviors in a single hook function and confirms - // that callHookFnAsync both (1) resolves to the result of the first settle attempt, and (2) - // detects the second settle attempt. - // - // The 'when' property specifies the relative time that two behaviors will cause the hook - // function to settle: - // * If behavior1.when <= behavior2.when and behavior1 is called before behavior2 then - // behavior1 will settle the hook function before behavior2. - // * Otherwise, behavior2 will settle the hook function before behavior1. - const behaviors = [ - { - name: 'throw', - fn: (cb: Function, err:any, val: string) => { throw err; }, - rejects: true, - when: 0, - }, - { - name: 'return value', - fn: (cb: Function, err:any, val: string) => val, - // This behavior has a later relative settle time vs. the 'throw' behavior because 'throw' - // immediately settles the hook function, whereas the 'return value' case is settled by a - // .then() function attached to a Promise. EcmaScript guarantees that a .then() function - // attached to a Promise is enqueued on the event loop (not executed immediately) when the - // Promise settles. - when: 1, - }, - { - name: 'immediately call cb(value)', - fn: (cb: Function, err:any, val: string) => cb(val), - // This behavior has the same relative time as the 'return value' case because it too is - // settled by a .then() function attached to a Promise. - when: 1, - }, - { - name: 'return resolvedPromise', - fn: (cb: Function, err:any, val: string) => Promise.resolve(val), - // This behavior has the same relative time as the 'return value' case because the return - // value is wrapped in a Promise via Promise.resolve(). The EcmaScript standard guarantees - // that Promise.resolve(Promise.resolve(value)) is equivalent to Promise.resolve(value), - // so returning an already resolved Promise vs. returning a non-Promise value are - // equivalent. - when: 1, - }, - { - name: 'immediately call cb(resolvedPromise)', - fn: (cb: Function, err:any, val: string) => cb(Promise.resolve(val)), - when: 1, - }, - { - name: 'return rejectedPromise', - fn: (cb: Function, err:any, val: string) => Promise.reject(err), - rejects: true, - when: 1, - }, - { - name: 'immediately call cb(rejectedPromise)', - fn: (cb: Function, err:any, val: string) => cb(Promise.reject(err)), - rejects: true, - when: 1, - }, - { - name: 'return unresolvedPromise', - fn: (cb: Function, err:any, val: string) => new Promise((resolve) => process.nextTick(resolve, val)), - when: 2, - }, - { - name: 'immediately call cb(unresolvedPromise)', - fn: (cb: Function, err:any, val: string) => cb(new Promise((resolve) => process.nextTick(resolve, val))), - when: 2, - }, - { - name: 'return unrejectedPromise', - fn: (cb: Function, err:any, val: string) => new Promise((resolve, reject) => process.nextTick(reject, err)), - rejects: true, - when: 2, - }, - { - name: 'immediately call cb(unrejectedPromise)', - fn: (cb: Function, err:any, val: string) => cb(new Promise((resolve, reject) => process.nextTick(reject, err))), - rejects: true, - when: 2, - }, - { - name: 'defer call to cb(value)', - fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, val); }, - when: 2, - }, - { - name: 'defer call to cb(resolvedPromise)', - fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, Promise.resolve(val)); }, - when: 2, - }, - { - name: 'defer call to cb(rejectedPromise)', - fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, Promise.reject(err)); }, - rejects: true, - when: 2, - }, - { - name: 'defer call to cb(unresolvedPromise)', - fn: (cb: Function, err:any, val: string) => { - process.nextTick(() => { - cb(new Promise((resolve) => process.nextTick(resolve, val))); - }); - }, - when: 3, - }, - { - name: 'defer call cb(unrejectedPromise)', - fn: (cb: Function, err:any, val: string) => { - process.nextTick(() => { - cb(new Promise((resolve, reject) => process.nextTick(reject, err))); - }); - }, - rejects: true, - when: 3, - }, - ]; - - for (const step1 of behaviors) { - // There can't be a second step if the first step is to return or throw. - if (step1.name.startsWith('return ') || step1.name === 'throw') continue; - for (const step2 of behaviors) { - it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () { - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { - step1.fn(cb, new Error(ctx.ret1), ctx.ret1); - return step2.fn(cb, new Error(ctx.ret2), ctx.ret2); - }; - - // Temporarily remove unhandled Promise rejection listeners so that the unhandled - // rejections we expect to see don't trigger a test failure (or terminate node). - const event = 'unhandledRejection'; - const listenersBackup = process.rawListeners(event); - process.removeAllListeners(event); - - let tempListener; - let asyncErr: Error; - try { - const seenErrPromise = new Promise((resolve) => { - tempListener = (err:any) => { - assert.equal(asyncErr, undefined); - asyncErr = err; - resolve(); - }; - }); - process.on(event, tempListener!); - const step1Wins = step1.when <= step2.when; - const winningStep = step1Wins ? step1 : step2; - const winningVal = step1Wins ? 'val1' : 'val2'; - const p = callHookFnAsync(hook, {ret1: 'val1', ret2: 'val2'}); - if (winningStep.rejects) { - await assert.rejects(p, {message: winningVal}); - } else { - assert.equal(await p, winningVal); - } - await seenErrPromise; - } finally { - // Restore the original listeners. - process.off(event, tempListener!); - for (const listener of listenersBackup) { - process.on(event, listener as any); - } - } - assert.equal(console.error.callCount, 1, - `Got errors:\n${ - console.error.getCalls().map((call) => call.args[0]).join('\n')}`); - assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); - // @ts-ignore - assert(asyncErr instanceof Error); - assert.match(asyncErr.message, /DOUBLE SETTLE BUG/); - }); - - // This next test is the same as the above test, except the second settle attempt is for - // the same outcome. The two outcomes can't be the same if one step rejects and the other - // doesn't, so skip those cases. - if (step1.rejects !== step2.rejects) continue; - - it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () { - const err = new Error('val'); - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { - step1.fn(cb, err, 'val'); - return step2.fn(cb, err, 'val'); - }; - const winningStep = (step1.when <= step2.when) ? step1 : step2; - const errorLogged = new Promise((resolve) => console.error.callsFake(resolve)); - const p = callHookFnAsync(hook); - if (winningStep.rejects) { - await assert.rejects(p, {message: 'val'}); - } else { - assert.equal(await p, 'val'); - } - await errorLogged; - assert.equal(console.error.callCount, 1); - assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); - }); - } - } - }); - }); - - describe('hooks.aCallAll', function () { - describe('basic behavior', function () { - it('calls all asynchronously, returns values in order', async function () { - testHooks.length = 0; // Delete the boilerplate hook -- this test doesn't use it. - let nextIndex = 0; - const hookPromises: { - promise?: Promise, - resolve?: Function, - } [] - = []; - const hookStarted: boolean[] = []; - const hookFinished :boolean[]= []; - const makeHook = () => { - const i = nextIndex++; - const entry:{ - promise?: Promise, - resolve?: Function, - } = {}; - hookStarted[i] = false; - hookFinished[i] = false; - hookPromises[i] = entry; - entry.promise = new Promise((resolve) => { - entry.resolve = () => { - hookFinished[i] = true; - resolve(i); - }; - }); - return {hook_fn: () => { - hookStarted[i] = true; - return entry.promise; - }}; - }; - testHooks.push(makeHook(), makeHook()); - const p = hooks.aCallAll(hookName); - assert.deepEqual(hookStarted, [true, true]); - assert.deepEqual(hookFinished, [false, false]); - hookPromises[1].resolve!(); - await hookPromises[1].promise; - assert.deepEqual(hookFinished, [false, true]); - hookPromises[0].resolve!(); - assert.deepEqual(await p, [0, 1]); - }); - - it('passes hook name', async function () { - hook.hook_fn = async (hn:string) => { assert.equal(hn, hookName); }; - await hooks.aCallAll(hookName); - }); - - it('undefined context -> {}', async function () { - hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; - await hooks.aCallAll(hookName); - }); - - it('null context -> {}', async function () { - hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; - await hooks.aCallAll(hookName, null); - }); - - it('context unmodified', async function () { - const wantContext = {}; - hook.hook_fn = async (hn: string, ctx: any) => { assert.equal(ctx, wantContext); }; - await hooks.aCallAll(hookName, wantContext); - }); - }); - - describe('aCallAll callback', function () { - it('exception in callback rejects', async function () { - const p = hooks.aCallAll(hookName, {}, () => { throw new Error('test exception'); }); - await assert.rejects(p, {message: 'test exception'}); - }); - - it('propagates error on exception', async function () { - hook.hook_fn = () => { throw new Error('test exception'); }; - await hooks.aCallAll(hookName, {}, (err:any) => { - assert(err instanceof Error); - assert.equal(err.message, 'test exception'); - }); - }); - - it('propagages null error on success', async function () { - await hooks.aCallAll(hookName, {}, (err:any) => { - assert(err == null, `got non-null error: ${err}`); - }); - }); - - it('propagages results on success', async function () { - hook.hook_fn = () => 'val'; - await hooks.aCallAll(hookName, {}, (err:any, results:any) => { - assert.deepEqual(results, ['val']); - }); - }); - - it('returns callback return value', async function () { - assert.equal(await hooks.aCallAll(hookName, {}, () => 'val'), 'val'); - }); - }); - - describe('result processing', function () { - it('no registered hooks (undefined) -> []', async function () { - delete plugins.hooks[hookName]; - assert.deepEqual(await hooks.aCallAll(hookName), []); - }); - - it('no registered hooks (empty list) -> []', async function () { - testHooks.length = 0; - assert.deepEqual(await hooks.aCallAll(hookName), []); - }); - - it('flattens one level', async function () { - testHooks.length = 0; - testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); - assert.deepEqual(await hooks.aCallAll(hookName), [1, 2, [3]]); - }); - - it('filters out undefined', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve())); - assert.deepEqual(await hooks.aCallAll(hookName), [2, [3]]); - }); - - it('preserves null', async function () { - testHooks.length = 0; - testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null))); - assert.deepEqual(await hooks.aCallAll(hookName), [null, 2, null]); - }); - - it('all undefined -> []', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook(Promise.resolve())); - assert.deepEqual(await hooks.aCallAll(hookName), []); - }); - }); - }); - - describe('hooks.callAllSerial', function () { - describe('basic behavior', function () { - it('calls all asynchronously, serially, in order', async function () { - const gotCalls:number[] = []; - testHooks.length = 0; - for (let i = 0; i < 3; i++) { - const hook = makeHook(); - hook.hook_fn = async () => { - gotCalls.push(i); - // Check gotCalls asynchronously to ensure that the next hook function does not start - // executing before this hook function has resolved. - return await new Promise((resolve) => { - setImmediate(() => { - assert.deepEqual(gotCalls, [...Array(i + 1).keys()]); - resolve(i); - }); - }); - }; - testHooks.push(hook); - } - assert.deepEqual(await hooks.callAllSerial(hookName), [0, 1, 2]); - assert.deepEqual(gotCalls, [0, 1, 2]); - }); - - it('passes hook name', async function () { - hook.hook_fn = async (hn:string) => { assert.equal(hn, hookName); }; - await hooks.callAllSerial(hookName); - }); - - it('undefined context -> {}', async function () { - hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; - await hooks.callAllSerial(hookName); - }); - - it('null context -> {}', async function () { - hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; - await hooks.callAllSerial(hookName, null); - }); - - it('context unmodified', async function () { - const wantContext = {}; - hook.hook_fn = async (hn: string, ctx: any) => { assert.equal(ctx, wantContext); }; - await hooks.callAllSerial(hookName, wantContext); - }); - }); - - describe('result processing', function () { - it('no registered hooks (undefined) -> []', async function () { - delete plugins.hooks[hookName]; - assert.deepEqual(await hooks.callAllSerial(hookName), []); - }); - - it('no registered hooks (empty list) -> []', async function () { - testHooks.length = 0; - assert.deepEqual(await hooks.callAllSerial(hookName), []); - }); - - it('flattens one level', async function () { - testHooks.length = 0; - testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); - assert.deepEqual(await hooks.callAllSerial(hookName), [1, 2, [3]]); - }); - - it('filters out undefined', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve())); - assert.deepEqual(await hooks.callAllSerial(hookName), [2, [3]]); - }); - - it('preserves null', async function () { - testHooks.length = 0; - testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null))); - assert.deepEqual(await hooks.callAllSerial(hookName), [null, 2, null]); - }); - - it('all undefined -> []', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook(Promise.resolve())); - assert.deepEqual(await hooks.callAllSerial(hookName), []); - }); - }); - }); - - describe('hooks.aCallFirst', function () { - it('no registered hooks (undefined) -> []', async function () { - delete plugins.hooks.testHook; - assert.deepEqual(await hooks.aCallFirst(hookName), []); - }); - - it('no registered hooks (empty list) -> []', async function () { - testHooks.length = 0; - assert.deepEqual(await hooks.aCallFirst(hookName), []); - }); - - it('passes hook name => {}', async function () { - hook.hook_fn = (hn:string) => { assert.equal(hn, hookName); }; - await hooks.aCallFirst(hookName); - }); - - it('undefined context => {}', async function () { - hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; - await hooks.aCallFirst(hookName); - }); - - it('null context => {}', async function () { - hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); }; - await hooks.aCallFirst(hookName, null); - }); - - it('context unmodified', async function () { - const wantContext = {}; - hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, wantContext); }; - await hooks.aCallFirst(hookName, wantContext); - }); - - it('default predicate: predicate never satisfied -> calls all in order', async function () { - const gotCalls:number[] = []; - testHooks.length = 0; - for (let i = 0; i < 3; i++) { - const hook = makeHook(); - hook.hook_fn = () => { gotCalls.push(i); }; - testHooks.push(hook); - } - assert.deepEqual(await hooks.aCallFirst(hookName), []); - assert.deepEqual(gotCalls, [0, 1, 2]); - }); - - it('calls hook functions serially', async function () { - const gotCalls: number[] = []; - testHooks.length = 0; - for (let i = 0; i < 3; i++) { - const hook = makeHook(); - hook.hook_fn = async () => { - gotCalls.push(i); - // Check gotCalls asynchronously to ensure that the next hook function does not start - // executing before this hook function has resolved. - return await new Promise((resolve) => { - setImmediate(() => { - assert.deepEqual(gotCalls, [...Array(i + 1).keys()]); - resolve(); - }); - }); - }; - testHooks.push(hook); - } - assert.deepEqual(await hooks.aCallFirst(hookName), []); - assert.deepEqual(gotCalls, [0, 1, 2]); - }); - - it('default predicate: stops when satisfied', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook('val1'), makeHook('val2')); - assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); - }); - - it('default predicate: skips values that do not satisfy (undefined)', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook('val1')); - assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); - }); - - it('default predicate: skips values that do not satisfy (empty list)', async function () { - testHooks.length = 0; - testHooks.push(makeHook([]), makeHook('val1')); - assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); - }); - - it('default predicate: null satisifes', async function () { - testHooks.length = 0; - testHooks.push(makeHook(null), makeHook('val1')); - assert.deepEqual(await hooks.aCallFirst(hookName), [null]); - }); - - it('custom predicate: called for each hook function', async function () { - testHooks.length = 0; - testHooks.push(makeHook(0), makeHook(1), makeHook(2)); - let got = 0; - await hooks.aCallFirst(hookName, null, null, (val:string) => { ++got; return false; }); - assert.equal(got, 3); - }); - - it('custom predicate: boolean false/true continues/stops iteration', async function () { - testHooks.length = 0; - testHooks.push(makeHook(1), makeHook(2), makeHook(3)); - let nCall = 0; - const predicate = (val: number[]) => { - assert.deepEqual(val, [++nCall]); - return nCall === 2; - }; - assert.deepEqual(await hooks.aCallFirst(hookName, null, null, predicate), [2]); - assert.equal(nCall, 2); - }); - - it('custom predicate: non-boolean falsy/truthy continues/stops iteration', async function () { - testHooks.length = 0; - testHooks.push(makeHook(1), makeHook(2), makeHook(3)); - let nCall = 0; - const predicate = (val: number[]) => { - assert.deepEqual(val, [++nCall]); - return nCall === 2 ? {} : null; - }; - assert.deepEqual(await hooks.aCallFirst(hookName, null, null, predicate), [2]); - assert.equal(nCall, 2); - }); - - it('custom predicate: array value passed unmodified to predicate', async function () { - const want = [0]; - hook.hook_fn = () => want; - const predicate = (got: []) => { assert.equal(got, want); }; // Note: *NOT* deepEqual! - await hooks.aCallFirst(hookName, null, null, predicate); - }); - - it('custom predicate: normalized value passed to predicate (undefined)', async function () { - const predicate = (got: []) => { assert.deepEqual(got, []); }; - await hooks.aCallFirst(hookName, null, null, predicate); - }); - - it('custom predicate: normalized value passed to predicate (null)', async function () { - hook.hook_fn = () => null; - const predicate = (got: []) => { assert.deepEqual(got, [null]); }; - await hooks.aCallFirst(hookName, null, null, predicate); - }); - - it('non-empty arrays are returned unmodified', async function () { - const want = ['val1']; - testHooks.length = 0; - testHooks.push(makeHook(want), makeHook(['val2'])); - assert.equal(await hooks.aCallFirst(hookName), want); // Note: *NOT* deepEqual! - }); - - it('value can be passed via callback', async function () { - const want = {}; - hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { cb(want); }; - const got = await hooks.aCallFirst(hookName); - assert.deepEqual(got, [want]); - assert.equal(got[0], want); // Note: *NOT* deepEqual! - }); - }); + const hookName = "testHook"; + const hookFnName = "testPluginFileName:testHookFunctionName"; + let testHooks; // Convenience shorthand for plugins.hooks[hookName]. + let hook: any; // Convenience shorthand for plugins.hooks[hookName][0]. + + beforeEach(async function () { + // Make sure these are not already set so that we don't accidentally step on someone else's + // toes: + assert(plugins.hooks[hookName] == null); + assert(hooks.deprecationNotices[hookName] == null); + assert(hooks.exportedForTestingOnly.deprecationWarned[hookFnName] == null); + + // Many of the tests only need a single registered hook function. Set that up here to reduce + // boilerplate. + hook = makeHook(); + plugins.hooks[hookName] = [hook]; + testHooks = plugins.hooks[hookName]; + }); + + afterEach(async function () { + sinon.restore(); + delete plugins.hooks[hookName]; + delete hooks.deprecationNotices[hookName]; + delete hooks.exportedForTestingOnly.deprecationWarned[hookFnName]; + }); + + const makeHook = (ret?: any) => ({ + hook_name: hookName, + // Many tests will likely want to change this. Unfortunately, we can't use a convenience + // wrapper like `(...args) => hookFn(..args)` because the hooks look at Function.length and + // change behavior depending on the number of parameters. + hook_fn: (hn: Function, ctx: any, cb: Function) => cb(ret), + hook_fn_name: hookFnName, + part: { plugin: "testPluginName" }, + }); + + // Hook functions that should work for both synchronous and asynchronous hooks. + const supportedSyncHookFunctions = [ + { + name: "return non-Promise value, with callback parameter", + fn: (hn: Function, ctx: any, cb: Function) => "val", + want: "val", + syncOk: true, + }, + { + name: "return non-Promise value, without callback parameter", + fn: (hn: Function, ctx: any) => "val", + want: "val", + syncOk: true, + }, + { + name: "return undefined, without callback parameter", + fn: (hn: Function, ctx: any) => {}, + want: undefined, + syncOk: true, + }, + { + name: "pass non-Promise value to callback", + fn: (hn: Function, ctx: any, cb: Function) => { + cb("val"); + }, + want: "val", + syncOk: true, + }, + { + name: "pass undefined to callback", + fn: (hn: Function, ctx: any, cb: Function) => { + cb(); + }, + want: undefined, + syncOk: true, + }, + { + name: "return the value returned from the callback", + fn: (hn: Function, ctx: any, cb: Function) => cb("val"), + want: "val", + syncOk: true, + }, + { + name: "throw", + fn: (hn: Function, ctx: any, cb: Function) => { + throw new Error("test exception"); + }, + wantErr: "test exception", + syncOk: true, + }, + ]; + + describe("callHookFnSync", function () { + const callHookFnSync = hooks.exportedForTestingOnly.callHookFnSync; // Convenience shorthand. + + describe("basic behavior", function () { + it("passes hook name", async function () { + hook.hook_fn = (hn: string) => { + assert.equal(hn, hookName); + }; + callHookFnSync(hook); + }); + + it("passes context", async function () { + for (const val of ["value", null, undefined]) { + hook.hook_fn = (hn: string, ctx: string) => { + assert.equal(ctx, val); + }; + callHookFnSync(hook, val); + } + }); + + it("returns the value provided to the callback", async function () { + for (const val of ["value", null, undefined]) { + hook.hook_fn = (hn: Function, ctx: any, cb: Function) => { + cb(ctx); + }; + assert.equal(callHookFnSync(hook, val), val); + } + }); + + it("returns the value returned by the hook function", async function () { + for (const val of ["value", null, undefined]) { + // Must not have the cb parameter otherwise returning undefined will error. + hook.hook_fn = (hn: string, ctx: any) => ctx; + assert.equal(callHookFnSync(hook, val), val); + } + }); + + it("does not catch exceptions", async function () { + hook.hook_fn = () => { + throw new Error("test exception"); + }; + assert.throws(() => callHookFnSync(hook), { + message: "test exception", + }); + }); + + it("callback returns undefined", async function () { + hook.hook_fn = (hn: Function, ctx: any, cb: Function) => { + assert.equal(cb("foo"), undefined); + }; + callHookFnSync(hook); + }); + + it("checks for deprecation", async function () { + sinon.stub(console, "warn"); + hooks.deprecationNotices[hookName] = "test deprecation"; + callHookFnSync(hook); + assert.equal( + hooks.exportedForTestingOnly.deprecationWarned[hookFnName], + true, + ); + // @ts-ignore + assert.equal(console.warn.callCount, 1); + // @ts-ignore + assert.match(console.warn.getCall(0).args[0], /test deprecation/); + }); + }); + + describe("supported hook function styles", function () { + for (const tc of supportedSyncHookFunctions) { + it(tc.name, async function () { + sinon.stub(console, "warn"); + sinon.stub(console, "error"); + hook.hook_fn = tc.fn; + const call = () => callHookFnSync(hook); + if (tc.wantErr) { + assert.throws(call, { message: tc.wantErr }); + } else { + assert.equal(call(), tc.want); + } + assert.equal(console.warn.callCount, 0); + assert.equal(console.error.callCount, 0); + }); + } + }); + + describe("bad hook function behavior (other than double settle)", function () { + const promise1 = Promise.resolve("val1"); + const promise2 = Promise.resolve("val2"); + + const testCases = [ + { + name: "never settles -> buggy hook detected", + // Note that returning undefined without calling the callback is permitted if the function + // has 2 or fewer parameters, so this test function must have 3 parameters. + fn: (hn: Function, ctx: any, cb: Function) => {}, + wantVal: undefined, + wantError: /UNSETTLED FUNCTION BUG/, + }, + { + name: "returns a Promise -> buggy hook detected", + fn: () => promise1, + wantVal: promise1, + wantError: /PROHIBITED PROMISE BUG/, + }, + { + name: "passes a Promise to cb -> buggy hook detected", + fn: (hn: Function, ctx: any, cb: Function) => cb(promise2), + wantVal: promise2, + wantError: /PROHIBITED PROMISE BUG/, + }, + ]; + + for (const tc of testCases) { + it(tc.name, async function () { + sinon.stub(console, "error"); + hook.hook_fn = tc.fn; + assert.equal(callHookFnSync(hook), tc.wantVal); + assert.equal(console.error.callCount, tc.wantError ? 1 : 0); + if (tc.wantError) + assert.match(console.error.getCall(0).args[0], tc.wantError); + }); + } + }); + + // Test various ways a hook might attempt to settle twice. (Examples: call the callback a second + // time, or call the callback and then return a value.) + describe("bad hook function behavior (double settle)", function () { + beforeEach(async function () { + sinon.stub(console, "error"); + }); + + // Each item in this array codifies a way to settle a synchronous hook function. Each of the + // test cases below combines two of these behaviors in a single hook function and confirms + // that callHookFnSync both (1) returns the result of the first settle attempt, and + // (2) detects the second settle attempt. + const behaviors = [ + { + name: "throw", + fn: (cb: Function, err: any, val: string) => { + throw err; + }, + rejects: true, + }, + { + name: "return value", + fn: (cb: Function, err: any, val: string) => val, + }, + { + name: "immediately call cb(value)", + fn: (cb: Function, err: any, val: string) => cb(val), + }, + { + name: "defer call to cb(value)", + fn: (cb: Function, err: any, val: string) => { + process.nextTick(cb, val); + }, + async: true, + }, + ]; + + for (const step1 of behaviors) { + // There can't be a second step if the first step is to return or throw. + if (step1.name.startsWith("return ") || step1.name === "throw") + continue; + for (const step2 of behaviors) { + // If step1 and step2 are both async then there would be three settle attempts (first an + // erroneous unsettled return, then async step 1, then async step 2). Handling triple + // settle would complicate the tests, and it is sufficient to test only double settles. + if (step1.async && step2.async) continue; + + it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () { + hook.hook_fn = (hn: Function, ctx: any, cb: Function) => { + step1.fn(cb, new Error(ctx.ret1), ctx.ret1); + return step2.fn(cb, new Error(ctx.ret2), ctx.ret2); + }; + + // Temporarily remove unhandled error listeners so that the errors we expect to see + // don't trigger a test failure (or terminate node). + const events = ["uncaughtException", "unhandledRejection"]; + const listenerBackups: MapArrayType = {}; + for (const event of events) { + listenerBackups[event] = process.rawListeners(event); + process.removeAllListeners(event); + } + + // We should see an asynchronous error (either an unhandled Promise rejection or an + // uncaught exception) if and only if one of the two steps was asynchronous or there was + // a throw (in which case the double settle is deferred so that the caller sees the + // original error). + const wantAsyncErr = step1.async || step2.async || step2.rejects; + let tempListener: Function; + let asyncErr: Error | undefined; + try { + const seenErrPromise = new Promise((resolve) => { + tempListener = (err: any) => { + assert.equal(asyncErr, undefined); + asyncErr = err; + resolve(); + }; + if (!wantAsyncErr) resolve(); + }); + // @ts-ignore + events.forEach((event) => process.on(event, tempListener)); + const call = () => + callHookFnSync(hook, { ret1: "val1", ret2: "val2" }); + if (step2.rejects) { + assert.throws(call, { message: "val2" }); + } else if (!step1.async && !step2.async) { + assert.throws(call, { message: /DOUBLE SETTLE BUG/ }); + } else { + assert.equal(call(), step1.async ? "val2" : "val1"); + } + await seenErrPromise; + } finally { + // Restore the original listeners. + for (const event of events) { + // @ts-ignore + process.off(event, tempListener); + for (const listener of listenerBackups[event]) { + process.on(event, listener); + } + } + } + assert.equal(console.error.callCount, 1); + assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); + if (wantAsyncErr) { + assert(asyncErr instanceof Error); + assert.match(asyncErr.message, /DOUBLE SETTLE BUG/); + } + }); + + // This next test is the same as the above test, except the second settle attempt is for + // the same outcome. The two outcomes can't be the same if one step throws and the other + // doesn't, so skip those cases. + if (step1.rejects !== step2.rejects) continue; + + it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () { + const err = new Error("val"); + hook.hook_fn = (hn: Function, ctx: any, cb: Function) => { + step1.fn(cb, err, "val"); + return step2.fn(cb, err, "val"); + }; + + const errorLogged = new Promise((resolve) => + console.error.callsFake(resolve), + ); + const call = () => callHookFnSync(hook); + if (step2.rejects) { + assert.throws(call, { message: "val" }); + } else { + assert.equal(call(), "val"); + } + await errorLogged; + assert.equal(console.error.callCount, 1); + assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); + }); + } + } + }); + }); + + describe("hooks.callAll", function () { + describe("basic behavior", function () { + it("calls all in order", async function () { + testHooks.length = 0; + testHooks.push(makeHook(1), makeHook(2), makeHook(3)); + assert.deepEqual(hooks.callAll(hookName), [1, 2, 3]); + }); + + it("passes hook name", async function () { + hook.hook_fn = (hn: string) => { + assert.equal(hn, hookName); + }; + hooks.callAll(hookName); + }); + + it("undefined context -> {}", async function () { + hook.hook_fn = (hn: string, ctx: any) => { + assert.deepEqual(ctx, {}); + }; + hooks.callAll(hookName); + }); + + it("null context -> {}", async function () { + hook.hook_fn = (hn: string, ctx: any) => { + assert.deepEqual(ctx, {}); + }; + hooks.callAll(hookName, null); + }); + + it("context unmodified", async function () { + const wantContext = {}; + hook.hook_fn = (hn: string, ctx: any) => { + assert.equal(ctx, wantContext); + }; + hooks.callAll(hookName, wantContext); + }); + }); + + describe("result processing", function () { + it("no registered hooks (undefined) -> []", async function () { + delete plugins.hooks.testHook; + assert.deepEqual(hooks.callAll(hookName), []); + }); + + it("no registered hooks (empty list) -> []", async function () { + testHooks.length = 0; + assert.deepEqual(hooks.callAll(hookName), []); + }); + + it("flattens one level", async function () { + testHooks.length = 0; + testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); + assert.deepEqual(hooks.callAll(hookName), [1, 2, [3]]); + }); + + it("filters out undefined", async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook([2]), makeHook([[3]])); + assert.deepEqual(hooks.callAll(hookName), [2, [3]]); + }); + + it("preserves null", async function () { + testHooks.length = 0; + testHooks.push(makeHook(null), makeHook([2]), makeHook([[3]])); + assert.deepEqual(hooks.callAll(hookName), [null, 2, [3]]); + }); + + it("all undefined -> []", async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook()); + assert.deepEqual(hooks.callAll(hookName), []); + }); + }); + }); + + describe("hooks.callFirst", function () { + it("no registered hooks (undefined) -> []", async function () { + delete plugins.hooks.testHook; + assert.deepEqual(hooks.callFirst(hookName), []); + }); + + it("no registered hooks (empty list) -> []", async function () { + testHooks.length = 0; + assert.deepEqual(hooks.callFirst(hookName), []); + }); + + it("passes hook name => {}", async function () { + hook.hook_fn = (hn: string) => { + assert.equal(hn, hookName); + }; + hooks.callFirst(hookName); + }); + + it("undefined context => {}", async function () { + hook.hook_fn = (hn: string, ctx: any) => { + assert.deepEqual(ctx, {}); + }; + hooks.callFirst(hookName); + }); + + it("null context => {}", async function () { + hook.hook_fn = (hn: string, ctx: any) => { + assert.deepEqual(ctx, {}); + }; + hooks.callFirst(hookName, null); + }); + + it("context unmodified", async function () { + const wantContext = {}; + hook.hook_fn = (hn: string, ctx: any) => { + assert.equal(ctx, wantContext); + }; + hooks.callFirst(hookName, wantContext); + }); + + it("predicate never satisfied -> calls all in order", async function () { + const gotCalls: MapArrayType = []; + testHooks.length = 0; + for (let i = 0; i < 3; i++) { + const hook = makeHook(); + hook.hook_fn = () => { + gotCalls.push(i); + }; + testHooks.push(hook); + } + assert.deepEqual(hooks.callFirst(hookName), []); + assert.deepEqual(gotCalls, [0, 1, 2]); + }); + + it("stops when predicate is satisfied", async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook("val1"), makeHook("val2")); + assert.deepEqual(hooks.callFirst(hookName), ["val1"]); + }); + + it("skips values that do not satisfy predicate (undefined)", async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook("val1")); + assert.deepEqual(hooks.callFirst(hookName), ["val1"]); + }); + + it("skips values that do not satisfy predicate (empty list)", async function () { + testHooks.length = 0; + testHooks.push(makeHook([]), makeHook("val1")); + assert.deepEqual(hooks.callFirst(hookName), ["val1"]); + }); + + it("null satisifes the predicate", async function () { + testHooks.length = 0; + testHooks.push(makeHook(null), makeHook("val1")); + assert.deepEqual(hooks.callFirst(hookName), [null]); + }); + + it("non-empty arrays are returned unmodified", async function () { + const want = ["val1"]; + testHooks.length = 0; + testHooks.push(makeHook(want), makeHook(["val2"])); + assert.equal(hooks.callFirst(hookName), want); // Note: *NOT* deepEqual! + }); + + it("value can be passed via callback", async function () { + const want = {}; + hook.hook_fn = (hn: Function, ctx: any, cb: Function) => { + cb(want); + }; + const got = hooks.callFirst(hookName); + assert.deepEqual(got, [want]); + assert.equal(got[0], want); // Note: *NOT* deepEqual! + }); + }); + + describe("callHookFnAsync", function () { + const callHookFnAsync = hooks.exportedForTestingOnly.callHookFnAsync; // Convenience shorthand. + + describe("basic behavior", function () { + it("passes hook name", async function () { + hook.hook_fn = (hn: string) => { + assert.equal(hn, hookName); + }; + await callHookFnAsync(hook); + }); + + it("passes context", async function () { + for (const val of ["value", null, undefined]) { + hook.hook_fn = (hn: string, ctx: any) => { + assert.equal(ctx, val); + }; + await callHookFnAsync(hook, val); + } + }); + + it("returns the value provided to the callback", async function () { + for (const val of ["value", null, undefined]) { + hook.hook_fn = (hn: Function, ctx: any, cb: Function) => { + cb(ctx); + }; + assert.equal(await callHookFnAsync(hook, val), val); + assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val); + } + }); + + it("returns the value returned by the hook function", async function () { + for (const val of ["value", null, undefined]) { + // Must not have the cb parameter otherwise returning undefined will never resolve. + hook.hook_fn = (hn: string, ctx: any) => ctx; + assert.equal(await callHookFnAsync(hook, val), val); + assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val); + } + }); + + it("rejects if it throws an exception", async function () { + hook.hook_fn = () => { + throw new Error("test exception"); + }; + await assert.rejects(callHookFnAsync(hook), { + message: "test exception", + }); + }); + + it("rejects if rejected Promise passed to callback", async function () { + hook.hook_fn = (hn: Function, ctx: any, cb: Function) => + cb(Promise.reject(new Error("test exception"))); + await assert.rejects(callHookFnAsync(hook), { + message: "test exception", + }); + }); + + it("rejects if rejected Promise returned", async function () { + hook.hook_fn = (hn: Function, ctx: any, cb: Function) => + Promise.reject(new Error("test exception")); + await assert.rejects(callHookFnAsync(hook), { + message: "test exception", + }); + }); + + it("callback returns undefined", async function () { + hook.hook_fn = (hn: Function, ctx: any, cb: Function) => { + assert.equal(cb("foo"), undefined); + }; + await callHookFnAsync(hook); + }); + + it("checks for deprecation", async function () { + sinon.stub(console, "warn"); + hooks.deprecationNotices[hookName] = "test deprecation"; + await callHookFnAsync(hook); + assert.equal( + hooks.exportedForTestingOnly.deprecationWarned[hookFnName], + true, + ); + assert.equal(console.warn.callCount, 1); + assert.match(console.warn.getCall(0).args[0], /test deprecation/); + }); + }); + + describe("supported hook function styles", function () { + // @ts-ignore + const supportedHookFunctions = supportedSyncHookFunctions.concat([ + { + name: "legacy async cb", + fn: (hn: Function, ctx: any, cb: Function) => { + process.nextTick(cb, "val"); + }, + want: "val", + }, + // Already resolved Promises: + { + name: "return resolved Promise, with callback parameter", + fn: (hn: Function, ctx: any, cb: Function) => Promise.resolve("val"), + want: "val", + }, + { + name: "return resolved Promise, without callback parameter", + fn: (hn: string, ctx: any) => Promise.resolve("val"), + want: "val", + }, + { + name: "pass resolved Promise to callback", + fn: (hn: Function, ctx: any, cb: Function) => { + cb(Promise.resolve("val")); + }, + want: "val", + }, + // Not yet resolved Promises: + { + name: "return unresolved Promise, with callback parameter", + fn: (hn: Function, ctx: any, cb: Function) => + new Promise((resolve) => process.nextTick(resolve, "val")), + want: "val", + }, + { + name: "return unresolved Promise, without callback parameter", + fn: (hn: string, ctx: any) => + new Promise((resolve) => process.nextTick(resolve, "val")), + want: "val", + }, + { + name: "pass unresolved Promise to callback", + fn: (hn: Function, ctx: any, cb: Function) => { + cb(new Promise((resolve) => process.nextTick(resolve, "val"))); + }, + want: "val", + }, + // Already rejected Promises: + { + name: "return rejected Promise, with callback parameter", + fn: (hn: Function, ctx: any, cb: Function) => + Promise.reject(new Error("test rejection")), + wantErr: "test rejection", + }, + { + name: "return rejected Promise, without callback parameter", + fn: (hn: string, ctx: any) => + Promise.reject(new Error("test rejection")), + wantErr: "test rejection", + }, + { + name: "pass rejected Promise to callback", + fn: (hn: Function, ctx: any, cb: Function) => { + cb(Promise.reject(new Error("test rejection"))); + }, + wantErr: "test rejection", + }, + // Not yet rejected Promises: + { + name: "return unrejected Promise, with callback parameter", + fn: (hn: Function, ctx: any, cb: Function) => + new Promise((resolve, reject) => { + process.nextTick(reject, new Error("test rejection")); + }), + wantErr: "test rejection", + }, + { + name: "return unrejected Promise, without callback parameter", + fn: (hn: string, ctx: any) => + new Promise((resolve, reject) => { + process.nextTick(reject, new Error("test rejection")); + }), + wantErr: "test rejection", + }, + { + name: "pass unrejected Promise to callback", + fn: (hn: Function, ctx: any, cb: Function) => { + cb( + new Promise((resolve, reject) => { + process.nextTick(reject, new Error("test rejection")); + }), + ); + }, + wantErr: "test rejection", + }, + ]); + + for (const tc of supportedSyncHookFunctions.concat( + supportedHookFunctions, + )) { + it(tc.name, async function () { + sinon.stub(console, "warn"); + sinon.stub(console, "error"); + hook.hook_fn = tc.fn; + const p = callHookFnAsync(hook); + if (tc.wantErr) { + await assert.rejects(p, { message: tc.wantErr }); + } else { + assert.equal(await p, tc.want); + } + assert.equal(console.warn.callCount, 0); + assert.equal(console.error.callCount, 0); + }); + } + }); + + // Test various ways a hook might attempt to settle twice. (Examples: call the callback a second + // time, or call the callback and then return a value.) + describe("bad hook function behavior (double settle)", function () { + beforeEach(async function () { + sinon.stub(console, "error"); + }); + + // Each item in this array codifies a way to settle an asynchronous hook function. Each of the + // test cases below combines two of these behaviors in a single hook function and confirms + // that callHookFnAsync both (1) resolves to the result of the first settle attempt, and (2) + // detects the second settle attempt. + // + // The 'when' property specifies the relative time that two behaviors will cause the hook + // function to settle: + // * If behavior1.when <= behavior2.when and behavior1 is called before behavior2 then + // behavior1 will settle the hook function before behavior2. + // * Otherwise, behavior2 will settle the hook function before behavior1. + const behaviors = [ + { + name: "throw", + fn: (cb: Function, err: any, val: string) => { + throw err; + }, + rejects: true, + when: 0, + }, + { + name: "return value", + fn: (cb: Function, err: any, val: string) => val, + // This behavior has a later relative settle time vs. the 'throw' behavior because 'throw' + // immediately settles the hook function, whereas the 'return value' case is settled by a + // .then() function attached to a Promise. EcmaScript guarantees that a .then() function + // attached to a Promise is enqueued on the event loop (not executed immediately) when the + // Promise settles. + when: 1, + }, + { + name: "immediately call cb(value)", + fn: (cb: Function, err: any, val: string) => cb(val), + // This behavior has the same relative time as the 'return value' case because it too is + // settled by a .then() function attached to a Promise. + when: 1, + }, + { + name: "return resolvedPromise", + fn: (cb: Function, err: any, val: string) => Promise.resolve(val), + // This behavior has the same relative time as the 'return value' case because the return + // value is wrapped in a Promise via Promise.resolve(). The EcmaScript standard guarantees + // that Promise.resolve(Promise.resolve(value)) is equivalent to Promise.resolve(value), + // so returning an already resolved Promise vs. returning a non-Promise value are + // equivalent. + when: 1, + }, + { + name: "immediately call cb(resolvedPromise)", + fn: (cb: Function, err: any, val: string) => cb(Promise.resolve(val)), + when: 1, + }, + { + name: "return rejectedPromise", + fn: (cb: Function, err: any, val: string) => Promise.reject(err), + rejects: true, + when: 1, + }, + { + name: "immediately call cb(rejectedPromise)", + fn: (cb: Function, err: any, val: string) => cb(Promise.reject(err)), + rejects: true, + when: 1, + }, + { + name: "return unresolvedPromise", + fn: (cb: Function, err: any, val: string) => + new Promise((resolve) => process.nextTick(resolve, val)), + when: 2, + }, + { + name: "immediately call cb(unresolvedPromise)", + fn: (cb: Function, err: any, val: string) => + cb(new Promise((resolve) => process.nextTick(resolve, val))), + when: 2, + }, + { + name: "return unrejectedPromise", + fn: (cb: Function, err: any, val: string) => + new Promise((resolve, reject) => process.nextTick(reject, err)), + rejects: true, + when: 2, + }, + { + name: "immediately call cb(unrejectedPromise)", + fn: (cb: Function, err: any, val: string) => + cb(new Promise((resolve, reject) => process.nextTick(reject, err))), + rejects: true, + when: 2, + }, + { + name: "defer call to cb(value)", + fn: (cb: Function, err: any, val: string) => { + process.nextTick(cb, val); + }, + when: 2, + }, + { + name: "defer call to cb(resolvedPromise)", + fn: (cb: Function, err: any, val: string) => { + process.nextTick(cb, Promise.resolve(val)); + }, + when: 2, + }, + { + name: "defer call to cb(rejectedPromise)", + fn: (cb: Function, err: any, val: string) => { + process.nextTick(cb, Promise.reject(err)); + }, + rejects: true, + when: 2, + }, + { + name: "defer call to cb(unresolvedPromise)", + fn: (cb: Function, err: any, val: string) => { + process.nextTick(() => { + cb(new Promise((resolve) => process.nextTick(resolve, val))); + }); + }, + when: 3, + }, + { + name: "defer call cb(unrejectedPromise)", + fn: (cb: Function, err: any, val: string) => { + process.nextTick(() => { + cb( + new Promise((resolve, reject) => process.nextTick(reject, err)), + ); + }); + }, + rejects: true, + when: 3, + }, + ]; + + for (const step1 of behaviors) { + // There can't be a second step if the first step is to return or throw. + if (step1.name.startsWith("return ") || step1.name === "throw") + continue; + for (const step2 of behaviors) { + it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () { + hook.hook_fn = (hn: Function, ctx: any, cb: Function) => { + step1.fn(cb, new Error(ctx.ret1), ctx.ret1); + return step2.fn(cb, new Error(ctx.ret2), ctx.ret2); + }; + + // Temporarily remove unhandled Promise rejection listeners so that the unhandled + // rejections we expect to see don't trigger a test failure (or terminate node). + const event = "unhandledRejection"; + const listenersBackup = process.rawListeners(event); + process.removeAllListeners(event); + + let tempListener; + let asyncErr: Error; + try { + const seenErrPromise = new Promise((resolve) => { + tempListener = (err: any) => { + assert.equal(asyncErr, undefined); + asyncErr = err; + resolve(); + }; + }); + process.on(event, tempListener!); + const step1Wins = step1.when <= step2.when; + const winningStep = step1Wins ? step1 : step2; + const winningVal = step1Wins ? "val1" : "val2"; + const p = callHookFnAsync(hook, { ret1: "val1", ret2: "val2" }); + if (winningStep.rejects) { + await assert.rejects(p, { message: winningVal }); + } else { + assert.equal(await p, winningVal); + } + await seenErrPromise; + } finally { + // Restore the original listeners. + process.off(event, tempListener!); + for (const listener of listenersBackup) { + process.on(event, listener as any); + } + } + assert.equal( + console.error.callCount, + 1, + `Got errors:\n${console.error + .getCalls() + .map((call) => call.args[0]) + .join("\n")}`, + ); + assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); + // @ts-ignore + assert(asyncErr instanceof Error); + assert.match(asyncErr.message, /DOUBLE SETTLE BUG/); + }); + + // This next test is the same as the above test, except the second settle attempt is for + // the same outcome. The two outcomes can't be the same if one step rejects and the other + // doesn't, so skip those cases. + if (step1.rejects !== step2.rejects) continue; + + it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () { + const err = new Error("val"); + hook.hook_fn = (hn: Function, ctx: any, cb: Function) => { + step1.fn(cb, err, "val"); + return step2.fn(cb, err, "val"); + }; + const winningStep = step1.when <= step2.when ? step1 : step2; + const errorLogged = new Promise((resolve) => + console.error.callsFake(resolve), + ); + const p = callHookFnAsync(hook); + if (winningStep.rejects) { + await assert.rejects(p, { message: "val" }); + } else { + assert.equal(await p, "val"); + } + await errorLogged; + assert.equal(console.error.callCount, 1); + assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); + }); + } + } + }); + }); + + describe("hooks.aCallAll", function () { + describe("basic behavior", function () { + it("calls all asynchronously, returns values in order", async function () { + testHooks.length = 0; // Delete the boilerplate hook -- this test doesn't use it. + let nextIndex = 0; + const hookPromises: { + promise?: Promise; + resolve?: Function; + }[] = []; + const hookStarted: boolean[] = []; + const hookFinished: boolean[] = []; + const makeHook = () => { + const i = nextIndex++; + const entry: { + promise?: Promise; + resolve?: Function; + } = {}; + hookStarted[i] = false; + hookFinished[i] = false; + hookPromises[i] = entry; + entry.promise = new Promise((resolve) => { + entry.resolve = () => { + hookFinished[i] = true; + resolve(i); + }; + }); + return { + hook_fn: () => { + hookStarted[i] = true; + return entry.promise; + }, + }; + }; + testHooks.push(makeHook(), makeHook()); + const p = hooks.aCallAll(hookName); + assert.deepEqual(hookStarted, [true, true]); + assert.deepEqual(hookFinished, [false, false]); + hookPromises[1].resolve!(); + await hookPromises[1].promise; + assert.deepEqual(hookFinished, [false, true]); + hookPromises[0].resolve!(); + assert.deepEqual(await p, [0, 1]); + }); + + it("passes hook name", async function () { + hook.hook_fn = async (hn: string) => { + assert.equal(hn, hookName); + }; + await hooks.aCallAll(hookName); + }); + + it("undefined context -> {}", async function () { + hook.hook_fn = async (hn: string, ctx: any) => { + assert.deepEqual(ctx, {}); + }; + await hooks.aCallAll(hookName); + }); + + it("null context -> {}", async function () { + hook.hook_fn = async (hn: string, ctx: any) => { + assert.deepEqual(ctx, {}); + }; + await hooks.aCallAll(hookName, null); + }); + + it("context unmodified", async function () { + const wantContext = {}; + hook.hook_fn = async (hn: string, ctx: any) => { + assert.equal(ctx, wantContext); + }; + await hooks.aCallAll(hookName, wantContext); + }); + }); + + describe("aCallAll callback", function () { + it("exception in callback rejects", async function () { + const p = hooks.aCallAll(hookName, {}, () => { + throw new Error("test exception"); + }); + await assert.rejects(p, { message: "test exception" }); + }); + + it("propagates error on exception", async function () { + hook.hook_fn = () => { + throw new Error("test exception"); + }; + await hooks.aCallAll(hookName, {}, (err: any) => { + assert(err instanceof Error); + assert.equal(err.message, "test exception"); + }); + }); + + it("propagages null error on success", async function () { + await hooks.aCallAll(hookName, {}, (err: any) => { + assert(err == null, `got non-null error: ${err}`); + }); + }); + + it("propagages results on success", async function () { + hook.hook_fn = () => "val"; + await hooks.aCallAll(hookName, {}, (err: any, results: any) => { + assert.deepEqual(results, ["val"]); + }); + }); + + it("returns callback return value", async function () { + assert.equal(await hooks.aCallAll(hookName, {}, () => "val"), "val"); + }); + }); + + describe("result processing", function () { + it("no registered hooks (undefined) -> []", async function () { + delete plugins.hooks[hookName]; + assert.deepEqual(await hooks.aCallAll(hookName), []); + }); + + it("no registered hooks (empty list) -> []", async function () { + testHooks.length = 0; + assert.deepEqual(await hooks.aCallAll(hookName), []); + }); + + it("flattens one level", async function () { + testHooks.length = 0; + testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); + assert.deepEqual(await hooks.aCallAll(hookName), [1, 2, [3]]); + }); + + it("filters out undefined", async function () { + testHooks.length = 0; + testHooks.push( + makeHook(), + makeHook([2]), + makeHook([[3]]), + makeHook(Promise.resolve()), + ); + assert.deepEqual(await hooks.aCallAll(hookName), [2, [3]]); + }); + + it("preserves null", async function () { + testHooks.length = 0; + testHooks.push( + makeHook(null), + makeHook([2]), + makeHook(Promise.resolve(null)), + ); + assert.deepEqual(await hooks.aCallAll(hookName), [null, 2, null]); + }); + + it("all undefined -> []", async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook(Promise.resolve())); + assert.deepEqual(await hooks.aCallAll(hookName), []); + }); + }); + }); + + describe("hooks.callAllSerial", function () { + describe("basic behavior", function () { + it("calls all asynchronously, serially, in order", async function () { + const gotCalls: number[] = []; + testHooks.length = 0; + for (let i = 0; i < 3; i++) { + const hook = makeHook(); + hook.hook_fn = async () => { + gotCalls.push(i); + // Check gotCalls asynchronously to ensure that the next hook function does not start + // executing before this hook function has resolved. + return await new Promise((resolve) => { + setImmediate(() => { + assert.deepEqual(gotCalls, [...Array(i + 1).keys()]); + resolve(i); + }); + }); + }; + testHooks.push(hook); + } + assert.deepEqual(await hooks.callAllSerial(hookName), [0, 1, 2]); + assert.deepEqual(gotCalls, [0, 1, 2]); + }); + + it("passes hook name", async function () { + hook.hook_fn = async (hn: string) => { + assert.equal(hn, hookName); + }; + await hooks.callAllSerial(hookName); + }); + + it("undefined context -> {}", async function () { + hook.hook_fn = async (hn: string, ctx: any) => { + assert.deepEqual(ctx, {}); + }; + await hooks.callAllSerial(hookName); + }); + + it("null context -> {}", async function () { + hook.hook_fn = async (hn: string, ctx: any) => { + assert.deepEqual(ctx, {}); + }; + await hooks.callAllSerial(hookName, null); + }); + + it("context unmodified", async function () { + const wantContext = {}; + hook.hook_fn = async (hn: string, ctx: any) => { + assert.equal(ctx, wantContext); + }; + await hooks.callAllSerial(hookName, wantContext); + }); + }); + + describe("result processing", function () { + it("no registered hooks (undefined) -> []", async function () { + delete plugins.hooks[hookName]; + assert.deepEqual(await hooks.callAllSerial(hookName), []); + }); + + it("no registered hooks (empty list) -> []", async function () { + testHooks.length = 0; + assert.deepEqual(await hooks.callAllSerial(hookName), []); + }); + + it("flattens one level", async function () { + testHooks.length = 0; + testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); + assert.deepEqual(await hooks.callAllSerial(hookName), [1, 2, [3]]); + }); + + it("filters out undefined", async function () { + testHooks.length = 0; + testHooks.push( + makeHook(), + makeHook([2]), + makeHook([[3]]), + makeHook(Promise.resolve()), + ); + assert.deepEqual(await hooks.callAllSerial(hookName), [2, [3]]); + }); + + it("preserves null", async function () { + testHooks.length = 0; + testHooks.push( + makeHook(null), + makeHook([2]), + makeHook(Promise.resolve(null)), + ); + assert.deepEqual(await hooks.callAllSerial(hookName), [null, 2, null]); + }); + + it("all undefined -> []", async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook(Promise.resolve())); + assert.deepEqual(await hooks.callAllSerial(hookName), []); + }); + }); + }); + + describe("hooks.aCallFirst", function () { + it("no registered hooks (undefined) -> []", async function () { + delete plugins.hooks.testHook; + assert.deepEqual(await hooks.aCallFirst(hookName), []); + }); + + it("no registered hooks (empty list) -> []", async function () { + testHooks.length = 0; + assert.deepEqual(await hooks.aCallFirst(hookName), []); + }); + + it("passes hook name => {}", async function () { + hook.hook_fn = (hn: string) => { + assert.equal(hn, hookName); + }; + await hooks.aCallFirst(hookName); + }); + + it("undefined context => {}", async function () { + hook.hook_fn = (hn: string, ctx: any) => { + assert.deepEqual(ctx, {}); + }; + await hooks.aCallFirst(hookName); + }); + + it("null context => {}", async function () { + hook.hook_fn = (hn: string, ctx: any) => { + assert.deepEqual(ctx, {}); + }; + await hooks.aCallFirst(hookName, null); + }); + + it("context unmodified", async function () { + const wantContext = {}; + hook.hook_fn = (hn: string, ctx: any) => { + assert.equal(ctx, wantContext); + }; + await hooks.aCallFirst(hookName, wantContext); + }); + + it("default predicate: predicate never satisfied -> calls all in order", async function () { + const gotCalls: number[] = []; + testHooks.length = 0; + for (let i = 0; i < 3; i++) { + const hook = makeHook(); + hook.hook_fn = () => { + gotCalls.push(i); + }; + testHooks.push(hook); + } + assert.deepEqual(await hooks.aCallFirst(hookName), []); + assert.deepEqual(gotCalls, [0, 1, 2]); + }); + + it("calls hook functions serially", async function () { + const gotCalls: number[] = []; + testHooks.length = 0; + for (let i = 0; i < 3; i++) { + const hook = makeHook(); + hook.hook_fn = async () => { + gotCalls.push(i); + // Check gotCalls asynchronously to ensure that the next hook function does not start + // executing before this hook function has resolved. + return await new Promise((resolve) => { + setImmediate(() => { + assert.deepEqual(gotCalls, [...Array(i + 1).keys()]); + resolve(); + }); + }); + }; + testHooks.push(hook); + } + assert.deepEqual(await hooks.aCallFirst(hookName), []); + assert.deepEqual(gotCalls, [0, 1, 2]); + }); + + it("default predicate: stops when satisfied", async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook("val1"), makeHook("val2")); + assert.deepEqual(await hooks.aCallFirst(hookName), ["val1"]); + }); + + it("default predicate: skips values that do not satisfy (undefined)", async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook("val1")); + assert.deepEqual(await hooks.aCallFirst(hookName), ["val1"]); + }); + + it("default predicate: skips values that do not satisfy (empty list)", async function () { + testHooks.length = 0; + testHooks.push(makeHook([]), makeHook("val1")); + assert.deepEqual(await hooks.aCallFirst(hookName), ["val1"]); + }); + + it("default predicate: null satisifes", async function () { + testHooks.length = 0; + testHooks.push(makeHook(null), makeHook("val1")); + assert.deepEqual(await hooks.aCallFirst(hookName), [null]); + }); + + it("custom predicate: called for each hook function", async function () { + testHooks.length = 0; + testHooks.push(makeHook(0), makeHook(1), makeHook(2)); + let got = 0; + await hooks.aCallFirst(hookName, null, null, (val: string) => { + ++got; + return false; + }); + assert.equal(got, 3); + }); + + it("custom predicate: boolean false/true continues/stops iteration", async function () { + testHooks.length = 0; + testHooks.push(makeHook(1), makeHook(2), makeHook(3)); + let nCall = 0; + const predicate = (val: number[]) => { + assert.deepEqual(val, [++nCall]); + return nCall === 2; + }; + assert.deepEqual( + await hooks.aCallFirst(hookName, null, null, predicate), + [2], + ); + assert.equal(nCall, 2); + }); + + it("custom predicate: non-boolean falsy/truthy continues/stops iteration", async function () { + testHooks.length = 0; + testHooks.push(makeHook(1), makeHook(2), makeHook(3)); + let nCall = 0; + const predicate = (val: number[]) => { + assert.deepEqual(val, [++nCall]); + return nCall === 2 ? {} : null; + }; + assert.deepEqual( + await hooks.aCallFirst(hookName, null, null, predicate), + [2], + ); + assert.equal(nCall, 2); + }); + + it("custom predicate: array value passed unmodified to predicate", async function () { + const want = [0]; + hook.hook_fn = () => want; + const predicate = (got: []) => { + assert.equal(got, want); + }; // Note: *NOT* deepEqual! + await hooks.aCallFirst(hookName, null, null, predicate); + }); + + it("custom predicate: normalized value passed to predicate (undefined)", async function () { + const predicate = (got: []) => { + assert.deepEqual(got, []); + }; + await hooks.aCallFirst(hookName, null, null, predicate); + }); + + it("custom predicate: normalized value passed to predicate (null)", async function () { + hook.hook_fn = () => null; + const predicate = (got: []) => { + assert.deepEqual(got, [null]); + }; + await hooks.aCallFirst(hookName, null, null, predicate); + }); + + it("non-empty arrays are returned unmodified", async function () { + const want = ["val1"]; + testHooks.length = 0; + testHooks.push(makeHook(want), makeHook(["val2"])); + assert.equal(await hooks.aCallFirst(hookName), want); // Note: *NOT* deepEqual! + }); + + it("value can be passed via callback", async function () { + const want = {}; + hook.hook_fn = (hn: Function, ctx: any, cb: Function) => { + cb(want); + }; + const got = await hooks.aCallFirst(hookName); + assert.deepEqual(got, [want]); + assert.equal(got[0], want); // Note: *NOT* deepEqual! + }); + }); }); diff --git a/src/tests/backend/specs/lowerCasePadIds.ts b/src/tests/backend/specs/lowerCasePadIds.ts index 359f85d2c..2c474c742 100644 --- a/src/tests/backend/specs/lowerCasePadIds.ts +++ b/src/tests/backend/specs/lowerCasePadIds.ts @@ -1,90 +1,109 @@ -'use strict'; +"use strict"; -const assert = require('assert').strict; -const common = require('../common'); -const padManager = require('../../../node/db/PadManager'); -const settings = require('../../../node/utils/Settings'); +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:any; - const cleanUpPads = async () => { - const {padIDs} = await padManager.listAllPads(); - await Promise.all(padIDs.map(async (padId: string) => { - if (await padManager.doesPadExist(padId)) { - const pad = await padManager.getPad(padId); - await pad.remove(); - } - })); - }; - let backup:any; + let agent: any; + const cleanUpPads = async () => { + const { padIDs } = await padManager.listAllPads(); + await Promise.all( + padIDs.map(async (padId: string) => { + if (await padManager.doesPadExist(padId)) { + const pad = await padManager.getPad(padId); + await pad.remove(); + } + }), + ); + }; + let backup: any; - 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; - }); + 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; - }); + describe("not activated", function () { + beforeEach(async function () { + settings.lowerCasePadIds = false; + }); + it("do nothing", async function () { + await agent.get("/p/UPPERCASEpad").expect(200); + }); + }); - 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"); + }); - 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, + }); - 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 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", + ); + }); - 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"); - 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", + ); - 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'); - }); - }); + assert.equal( + newPadHandshake.data.collab_client_vars.initialAttributedText.text, + "attempt\n", + ); + }); + }); }); diff --git a/src/tests/backend/specs/messages.ts b/src/tests/backend/specs/messages.ts index 9d91b2342..104a63e55 100644 --- a/src/tests/backend/specs/messages.ts +++ b/src/tests/backend/specs/messages.ts @@ -1,258 +1,259 @@ -'use strict'; +"use strict"; -import {PadType} from "../../../node/types/PadType"; -import {MapArrayType} from "../../../node/types/MapType"; +import { PadType } from "../../../node/types/PadType"; +import { MapArrayType } from "../../../node/types/MapType"; -const assert = require('assert').strict; -const common = require('../common'); -const padManager = require('../../../node/db/PadManager'); -const plugins = require('../../../static/js/pluginfw/plugin_defs'); -const readOnlyManager = require('../../../node/db/ReadOnlyManager'); +const assert = require("assert").strict; +const common = require("../common"); +const padManager = require("../../../node/db/PadManager"); +const plugins = require("../../../static/js/pluginfw/plugin_defs"); +const readOnlyManager = require("../../../node/db/ReadOnlyManager"); describe(__filename, function () { - let agent:any; - let pad:PadType|null; - let padId: string; - let roPadId: string; - let rev: number; - let socket: any; - let roSocket: any; - const backups:MapArrayType = {}; + let agent: any; + let pad: PadType | null; + let padId: string; + let roPadId: string; + let rev: number; + let socket: any; + let roSocket: any; + const backups: MapArrayType = {}; - before(async function () { - agent = await common.init(); - }); + before(async function () { + agent = await common.init(); + }); - beforeEach(async function () { - backups.hooks = {handleMessageSecurity: plugins.hooks.handleMessageSecurity}; - plugins.hooks.handleMessageSecurity = []; - padId = common.randomString(); - assert(!await padManager.doesPadExist(padId)); - pad = await padManager.getPad(padId, 'dummy text\n'); - await pad!.setText('\n'); // Make sure the pad is created. - assert.equal(pad!.text(), '\n'); - let res = await agent.get(`/p/${padId}`).expect(200); - socket = await common.connect(res); - const {type, data: clientVars} = await common.handshake(socket, padId); - assert.equal(type, 'CLIENT_VARS'); - rev = clientVars.collab_client_vars.rev; + beforeEach(async function () { + backups.hooks = { + handleMessageSecurity: plugins.hooks.handleMessageSecurity, + }; + plugins.hooks.handleMessageSecurity = []; + padId = common.randomString(); + assert(!(await padManager.doesPadExist(padId))); + pad = await padManager.getPad(padId, "dummy text\n"); + await pad!.setText("\n"); // Make sure the pad is created. + assert.equal(pad!.text(), "\n"); + let res = await agent.get(`/p/${padId}`).expect(200); + socket = await common.connect(res); + const { type, data: clientVars } = await common.handshake(socket, padId); + assert.equal(type, "CLIENT_VARS"); + rev = clientVars.collab_client_vars.rev; - roPadId = await readOnlyManager.getReadOnlyId(padId); - res = await agent.get(`/p/${roPadId}`).expect(200); - roSocket = await common.connect(res); - await common.handshake(roSocket, roPadId); - await new Promise(resolve => setTimeout(resolve, 1000)); - }); + roPadId = await readOnlyManager.getReadOnlyId(padId); + res = await agent.get(`/p/${roPadId}`).expect(200); + roSocket = await common.connect(res); + await common.handshake(roSocket, roPadId); + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); - afterEach(async function () { - Object.assign(plugins.hooks, backups.hooks); - if (socket != null) socket.close(); - socket = null; - if (roSocket != null) roSocket.close(); - roSocket = null; - if (pad != null) await pad.remove(); - pad = null; - }); + afterEach(async function () { + Object.assign(plugins.hooks, backups.hooks); + if (socket != null) socket.close(); + socket = null; + if (roSocket != null) roSocket.close(); + roSocket = null; + if (pad != null) await pad.remove(); + pad = null; + }); - describe('CHANGESET_REQ', function () { - it('users are unable to read changesets from other pads', async function () { - const otherPadId = `${padId}other`; - assert(!await padManager.doesPadExist(otherPadId)); - const otherPad = await padManager.getPad(otherPadId, 'other text\n'); - try { - await otherPad.setText('other text\n'); - const resP = common.waitForSocketEvent(roSocket, 'message'); - await common.sendMessage(roSocket, { - component: 'pad', - padId: otherPadId, // The server should ignore this. - type: 'CHANGESET_REQ', - data: { - granularity: 1, - start: 0, - requestID: 'requestId', - }, - }); - const res = await resP; - assert.equal(res.type, 'CHANGESET_REQ'); - assert.equal(res.data.requestID, 'requestId'); - // Should match padId's text, not otherPadId's text. - assert.match(res.data.forwardsChangesets[0], /^[^$]*\$dummy text\n/); - } finally { - await otherPad.remove(); - } - }); + describe("CHANGESET_REQ", function () { + it("users are unable to read changesets from other pads", async function () { + const otherPadId = `${padId}other`; + assert(!(await padManager.doesPadExist(otherPadId))); + const otherPad = await padManager.getPad(otherPadId, "other text\n"); + try { + await otherPad.setText("other text\n"); + const resP = common.waitForSocketEvent(roSocket, "message"); + await common.sendMessage(roSocket, { + component: "pad", + padId: otherPadId, // The server should ignore this. + type: "CHANGESET_REQ", + data: { + granularity: 1, + start: 0, + requestID: "requestId", + }, + }); + const res = await resP; + assert.equal(res.type, "CHANGESET_REQ"); + assert.equal(res.data.requestID, "requestId"); + // Should match padId's text, not otherPadId's text. + assert.match(res.data.forwardsChangesets[0], /^[^$]*\$dummy text\n/); + } finally { + await otherPad.remove(); + } + }); - it('CHANGESET_REQ: verify revNum is a number (regression)', async function () { - const otherPadId = `${padId}other`; - assert(!await padManager.doesPadExist(otherPadId)); - const otherPad = await padManager.getPad(otherPadId, 'other text\n'); - let errorCatched = 0; - try { - await otherPad.setText('other text\n'); - await common.sendMessage(roSocket, { - component: 'pad', - padId: otherPadId, // The server should ignore this. - type: 'CHANGESET_REQ', - data: { - granularity: 1, - start: 'test123', - requestID: 'requestId', - }, - }); - assert.equal('This code should never run', 1); - } - catch(e:any) { - assert.match(e.message, /rev is not a number/); - errorCatched = 1; - } - finally { - await otherPad.remove(); - assert.equal(errorCatched, 1); - } - }); + it("CHANGESET_REQ: verify revNum is a number (regression)", async function () { + const otherPadId = `${padId}other`; + assert(!(await padManager.doesPadExist(otherPadId))); + const otherPad = await padManager.getPad(otherPadId, "other text\n"); + let errorCatched = 0; + try { + await otherPad.setText("other text\n"); + await common.sendMessage(roSocket, { + component: "pad", + padId: otherPadId, // The server should ignore this. + type: "CHANGESET_REQ", + data: { + granularity: 1, + start: "test123", + requestID: "requestId", + }, + }); + assert.equal("This code should never run", 1); + } catch (e: any) { + assert.match(e.message, /rev is not a number/); + errorCatched = 1; + } finally { + await otherPad.remove(); + assert.equal(errorCatched, 1); + } + }); - it('CHANGESET_REQ: revNum is converted to number if possible (regression)', async function () { - const otherPadId = `${padId}other`; - assert(!await padManager.doesPadExist(otherPadId)); - const otherPad = await padManager.getPad(otherPadId, 'other text\n'); - try { - await otherPad.setText('other text\n'); - const resP = common.waitForSocketEvent(roSocket, 'message'); - await common.sendMessage(roSocket, { - component: 'pad', - padId: otherPadId, // The server should ignore this. - type: 'CHANGESET_REQ', - data: { - granularity: 1, - start: '1test123', - requestID: 'requestId', - }, - }); - const res = await resP; - assert.equal(res.type, 'CHANGESET_REQ'); - assert.equal(res.data.requestID, 'requestId'); - assert.equal(res.data.start, 1); - } - finally { - await otherPad.remove(); - } - }); + it("CHANGESET_REQ: revNum is converted to number if possible (regression)", async function () { + const otherPadId = `${padId}other`; + assert(!(await padManager.doesPadExist(otherPadId))); + const otherPad = await padManager.getPad(otherPadId, "other text\n"); + try { + await otherPad.setText("other text\n"); + const resP = common.waitForSocketEvent(roSocket, "message"); + await common.sendMessage(roSocket, { + component: "pad", + padId: otherPadId, // The server should ignore this. + type: "CHANGESET_REQ", + data: { + granularity: 1, + start: "1test123", + requestID: "requestId", + }, + }); + const res = await resP; + assert.equal(res.type, "CHANGESET_REQ"); + assert.equal(res.data.requestID, "requestId"); + assert.equal(res.data.start, 1); + } finally { + await otherPad.remove(); + } + }); - it('CHANGESET_REQ: revNum 2 is converted to head rev 1 (regression)', async function () { - const otherPadId = `${padId}other`; - assert(!await padManager.doesPadExist(otherPadId)); - const otherPad = await padManager.getPad(otherPadId, 'other text\n'); - try { - await otherPad.setText('other text\n'); - const resP = common.waitForSocketEvent(roSocket, 'message'); - await common.sendMessage(roSocket, { - component: 'pad', - padId: otherPadId, // The server should ignore this. - type: 'CHANGESET_REQ', - data: { - granularity: 1, - start: '2', - requestID: 'requestId', - }, - }); - const res = await resP; - assert.equal(res.type, 'CHANGESET_REQ'); - assert.equal(res.data.requestID, 'requestId'); - assert.equal(res.data.start, 1); - } - finally { - await otherPad.remove(); - } - }); - }); + it("CHANGESET_REQ: revNum 2 is converted to head rev 1 (regression)", async function () { + const otherPadId = `${padId}other`; + assert(!(await padManager.doesPadExist(otherPadId))); + const otherPad = await padManager.getPad(otherPadId, "other text\n"); + try { + await otherPad.setText("other text\n"); + const resP = common.waitForSocketEvent(roSocket, "message"); + await common.sendMessage(roSocket, { + component: "pad", + padId: otherPadId, // The server should ignore this. + type: "CHANGESET_REQ", + data: { + granularity: 1, + start: "2", + requestID: "requestId", + }, + }); + const res = await resP; + assert.equal(res.type, "CHANGESET_REQ"); + assert.equal(res.data.requestID, "requestId"); + assert.equal(res.data.start, 1); + } finally { + await otherPad.remove(); + } + }); + }); - describe('USER_CHANGES', function () { - const sendUserChanges = - async (socket:any, cs:any) => await common.sendUserChanges(socket, {baseRev: rev, changeset: cs}); - const assertAccepted = async (socket:any, wantRev: number) => { - await common.waitForAcceptCommit(socket, wantRev); - rev = wantRev; - }; - const assertRejected = async (socket:any) => { - const msg = await common.waitForSocketEvent(socket, 'message'); - assert.deepEqual(msg, {disconnect: 'badChangeset'}); - }; + describe("USER_CHANGES", function () { + const sendUserChanges = async (socket: any, cs: any) => + await common.sendUserChanges(socket, { baseRev: rev, changeset: cs }); + const assertAccepted = async (socket: any, wantRev: number) => { + await common.waitForAcceptCommit(socket, wantRev); + rev = wantRev; + }; + const assertRejected = async (socket: any) => { + const msg = await common.waitForSocketEvent(socket, "message"); + assert.deepEqual(msg, { disconnect: "badChangeset" }); + }; - it('changes are applied', async function () { - await Promise.all([ - assertAccepted(socket, rev + 1), - sendUserChanges(socket, 'Z:1>5+5$hello'), - ]); - assert.equal(pad!.text(), 'hello\n'); - }); + it("changes are applied", async function () { + await Promise.all([ + assertAccepted(socket, rev + 1), + sendUserChanges(socket, "Z:1>5+5$hello"), + ]); + assert.equal(pad!.text(), "hello\n"); + }); - it('bad changeset is rejected', async function () { - await Promise.all([ - assertRejected(socket), - sendUserChanges(socket, 'this is not a valid changeset'), - ]); - }); + it("bad changeset is rejected", async function () { + await Promise.all([ + assertRejected(socket), + sendUserChanges(socket, "this is not a valid changeset"), + ]); + }); - it('retransmission is accepted, has no effect', async function () { - const cs = 'Z:1>5+5$hello'; - await Promise.all([ - assertAccepted(socket, rev + 1), - sendUserChanges(socket, cs), - ]); - --rev; - await Promise.all([ - assertAccepted(socket, rev + 1), - sendUserChanges(socket, cs), - ]); - assert.equal(pad!.text(), 'hello\n'); - }); + it("retransmission is accepted, has no effect", async function () { + const cs = "Z:1>5+5$hello"; + await Promise.all([ + assertAccepted(socket, rev + 1), + sendUserChanges(socket, cs), + ]); + --rev; + await Promise.all([ + assertAccepted(socket, rev + 1), + sendUserChanges(socket, cs), + ]); + assert.equal(pad!.text(), "hello\n"); + }); - it('identity changeset is accepted, has no effect', async function () { - await Promise.all([ - assertAccepted(socket, rev + 1), - sendUserChanges(socket, 'Z:1>5+5$hello'), - ]); - await Promise.all([ - assertAccepted(socket, rev), - sendUserChanges(socket, 'Z:6>0$'), - ]); - assert.equal(pad!.text(), 'hello\n'); - }); + it("identity changeset is accepted, has no effect", async function () { + await Promise.all([ + assertAccepted(socket, rev + 1), + sendUserChanges(socket, "Z:1>5+5$hello"), + ]); + await Promise.all([ + assertAccepted(socket, rev), + sendUserChanges(socket, "Z:6>0$"), + ]); + assert.equal(pad!.text(), "hello\n"); + }); - it('non-identity changeset with no net change is accepted, has no effect', async function () { - await Promise.all([ - assertAccepted(socket, rev + 1), - sendUserChanges(socket, 'Z:1>5+5$hello'), - ]); - await Promise.all([ - assertAccepted(socket, rev), - sendUserChanges(socket, 'Z:6>0-5+5$hello'), - ]); - assert.equal(pad!.text(), 'hello\n'); - }); + it("non-identity changeset with no net change is accepted, has no effect", async function () { + await Promise.all([ + assertAccepted(socket, rev + 1), + sendUserChanges(socket, "Z:1>5+5$hello"), + ]); + await Promise.all([ + assertAccepted(socket, rev), + sendUserChanges(socket, "Z:6>0-5+5$hello"), + ]); + assert.equal(pad!.text(), "hello\n"); + }); - it('handleMessageSecurity can grant one-time write access', async function () { - const cs = 'Z:1>5+5$hello'; - const errRegEx = /write attempt on read-only pad/; - // First try to send a change and verify that it was dropped. - await assert.rejects(sendUserChanges(roSocket, cs), errRegEx); - // sendUserChanges() waits for message ack, so if the message was accepted then head should - // have already incremented by the time we get here. - assert.equal(pad!.head, rev); // Not incremented. + it("handleMessageSecurity can grant one-time write access", async function () { + const cs = "Z:1>5+5$hello"; + const errRegEx = /write attempt on read-only pad/; + // First try to send a change and verify that it was dropped. + await assert.rejects(sendUserChanges(roSocket, cs), errRegEx); + // sendUserChanges() waits for message ack, so if the message was accepted then head should + // have already incremented by the time we get here. + assert.equal(pad!.head, rev); // Not incremented. - // Now allow the change. - plugins.hooks.handleMessageSecurity.push({hook_fn: () => 'permitOnce'}); - await Promise.all([ - assertAccepted(roSocket, rev + 1), - sendUserChanges(roSocket, cs), - ]); - assert.equal(pad!.text(), 'hello\n'); + // Now allow the change. + plugins.hooks.handleMessageSecurity.push({ hook_fn: () => "permitOnce" }); + await Promise.all([ + assertAccepted(roSocket, rev + 1), + sendUserChanges(roSocket, cs), + ]); + assert.equal(pad!.text(), "hello\n"); - // The next change should be dropped. - plugins.hooks.handleMessageSecurity = []; - await assert.rejects(sendUserChanges(roSocket, 'Z:6>6=5+6$ world'), errRegEx); - assert.equal(pad!.head, rev); // Not incremented. - assert.equal(pad!.text(), 'hello\n'); - }); - }); + // The next change should be dropped. + plugins.hooks.handleMessageSecurity = []; + await assert.rejects( + sendUserChanges(roSocket, "Z:6>6=5+6$ world"), + errRegEx, + ); + assert.equal(pad!.head, rev); // Not incremented. + assert.equal(pad!.text(), "hello\n"); + }); + }); }); diff --git a/src/tests/backend/specs/pad_utils.ts b/src/tests/backend/specs/pad_utils.ts index 3ca3c0858..57b025d0e 100644 --- a/src/tests/backend/specs/pad_utils.ts +++ b/src/tests/backend/specs/pad_utils.ts @@ -1,45 +1,51 @@ -'use strict'; +"use strict"; -import {MapArrayType} from "../../../node/types/MapType"; +import { MapArrayType } from "../../../node/types/MapType"; -import {strict as assert} from "assert"; -const {padutils} = require('../../../static/js/pad_utils'); +import { strict as assert } from "assert"; +const { padutils } = require("../../../static/js/pad_utils"); describe(__filename, function () { - describe('warnDeprecated', function () { - const {warnDeprecated} = padutils; - const backups:MapArrayType = {}; + describe("warnDeprecated", function () { + const { warnDeprecated } = padutils; + const backups: MapArrayType = {}; - before(async function () { - backups.logger = warnDeprecated.logger; - }); + before(async function () { + backups.logger = warnDeprecated.logger; + }); - afterEach(async function () { - warnDeprecated.logger = backups.logger; - delete warnDeprecated._rl; // Reset internal rate limiter state. - }); + afterEach(async function () { + warnDeprecated.logger = backups.logger; + delete warnDeprecated._rl; // Reset internal rate limiter state. + }); - /*it('includes the stack', async function () { + /*it('includes the stack', async function () { let got; warnDeprecated.logger = {warn: (stack: any) => got = stack}; warnDeprecated(); assert(got!.includes(__filename)); });*/ - it('rate limited', async function () { - let got = 0; - warnDeprecated.logger = {warn: () => ++got}; - warnDeprecated(); // Initialize internal rate limiter state. - const {period} = warnDeprecated._rl; - got = 0; - const testCases = [[0, 1], [0, 1], [period - 1, 1], [period, 2]]; - for (const [now, want] of testCases) { // In a loop so that the stack trace is the same. - warnDeprecated._rl.now = () => now; - warnDeprecated(); - assert.equal(got, want); - } - warnDeprecated(); // Should have a different stack trace. - assert.equal(got, testCases[testCases.length - 1][1] + 1); - }); - }); + it("rate limited", async function () { + let got = 0; + warnDeprecated.logger = { warn: () => ++got }; + warnDeprecated(); // Initialize internal rate limiter state. + const { period } = warnDeprecated._rl; + got = 0; + const testCases = [ + [0, 1], + [0, 1], + [period - 1, 1], + [period, 2], + ]; + for (const [now, want] of testCases) { + // In a loop so that the stack trace is the same. + warnDeprecated._rl.now = () => now; + warnDeprecated(); + assert.equal(got, want); + } + warnDeprecated(); // Should have a different stack trace. + assert.equal(got, testCases[testCases.length - 1][1] + 1); + }); + }); }); diff --git a/src/tests/backend/specs/pads-with-spaces.ts b/src/tests/backend/specs/pads-with-spaces.ts index cfadca1b9..e6bbbb64c 100644 --- a/src/tests/backend/specs/pads-with-spaces.ts +++ b/src/tests/backend/specs/pads-with-spaces.ts @@ -1,23 +1,25 @@ -'use strict'; +"use strict"; -const common = require('../common'); +const common = require("../common"); -let agent:any; +let agent: any; describe(__filename, function () { - before(async function () { - agent = await common.init(); - }); + before(async function () { + agent = await common.init(); + }); - it('supports pads with spaces, regression test for #4883', async function () { - await agent.get('/p/pads with spaces') - .expect(302) - .expect('location', 'pads_with_spaces'); - }); + it("supports pads with spaces, regression test for #4883", async function () { + await agent + .get("/p/pads with spaces") + .expect(302) + .expect("location", "pads_with_spaces"); + }); - it('supports pads with spaces and query, regression test for #4883', async function () { - await agent.get('/p/pads with spaces?showChat=true&noColors=false') - .expect(302) - .expect('location', 'pads_with_spaces?showChat=true&noColors=false'); - }); + it("supports pads with spaces and query, regression test for #4883", async function () { + await agent + .get("/p/pads with spaces?showChat=true&noColors=false") + .expect(302) + .expect("location", "pads_with_spaces?showChat=true&noColors=false"); + }); }); diff --git a/src/tests/backend/specs/promises.ts b/src/tests/backend/specs/promises.ts index 66be23562..a5d1635b1 100644 --- a/src/tests/backend/specs/promises.ts +++ b/src/tests/backend/specs/promises.ts @@ -1,92 +1,103 @@ -import {MapArrayType} from "../../../node/types/MapType"; +import { MapArrayType } from "../../../node/types/MapType"; -const assert = require('assert').strict; -const promises = require('../../../node/utils/promises'); +const assert = require("assert").strict; +const promises = require("../../../node/utils/promises"); describe(__filename, function () { - describe('promises.timesLimit', function () { - let wantIndex = 0; + describe("promises.timesLimit", function () { + let wantIndex = 0; - type TestPromise = { - promise?: Promise, - resolve?: () => void, - } + type TestPromise = { + promise?: Promise; + resolve?: () => void; + }; - const testPromises: TestPromise[] = []; - const makePromise = (index: number) => { - // Make sure index increases by one each time. - assert.equal(index, wantIndex++); - // Save the resolve callback (so the test can trigger resolution) - // and the promise itself (to wait for resolve to take effect). - const p:TestPromise = {}; - p.promise = new Promise((resolve) => { - p.resolve = resolve; - }); - testPromises.push(p); - return p.promise; - }; + const testPromises: TestPromise[] = []; + const makePromise = (index: number) => { + // Make sure index increases by one each time. + assert.equal(index, wantIndex++); + // Save the resolve callback (so the test can trigger resolution) + // and the promise itself (to wait for resolve to take effect). + const p: TestPromise = {}; + p.promise = new Promise((resolve) => { + p.resolve = resolve; + }); + testPromises.push(p); + return p.promise; + }; - const total = 11; - const concurrency = 7; - const timesLimitPromise = promises.timesLimit(total, concurrency, makePromise); + const total = 11; + const concurrency = 7; + const timesLimitPromise = promises.timesLimit( + total, + concurrency, + makePromise, + ); - it('honors concurrency', async function () { - assert.equal(wantIndex, concurrency); - }); + it("honors concurrency", async function () { + assert.equal(wantIndex, concurrency); + }); - it('creates another when one completes', async function () { - const {promise, resolve} = testPromises.shift()!; - resolve!(); - await promise; - assert.equal(wantIndex, concurrency + 1); - }); + it("creates another when one completes", async function () { + const { promise, resolve } = testPromises.shift()!; + resolve!(); + await promise; + assert.equal(wantIndex, concurrency + 1); + }); - it('creates the expected total number of promises', async function () { - while (testPromises.length > 0) { - // Resolve them in random order to ensure that the resolution order doesn't matter. - const i = Math.floor(Math.random() * Math.floor(testPromises.length)); - const {promise, resolve} = testPromises.splice(i, 1)[0]; - resolve!(); - await promise; - } - assert.equal(wantIndex, total); - }); + it("creates the expected total number of promises", async function () { + while (testPromises.length > 0) { + // Resolve them in random order to ensure that the resolution order doesn't matter. + const i = Math.floor(Math.random() * Math.floor(testPromises.length)); + const { promise, resolve } = testPromises.splice(i, 1)[0]; + resolve!(); + await promise; + } + assert.equal(wantIndex, total); + }); - it('resolves', async function () { - await timesLimitPromise; - }); + it("resolves", async function () { + await timesLimitPromise; + }); - it('does not create too many promises if total < concurrency', async function () { - wantIndex = 0; - assert.equal(testPromises.length, 0); - const total = 7; - const concurrency = 11; - const timesLimitPromise = promises.timesLimit(total, concurrency, makePromise); - while (testPromises.length > 0) { - const {promise, resolve} = testPromises.pop()!; - resolve!(); - await promise; - } - await timesLimitPromise; - assert.equal(wantIndex, total); - }); + it("does not create too many promises if total < concurrency", async function () { + wantIndex = 0; + assert.equal(testPromises.length, 0); + const total = 7; + const concurrency = 11; + const timesLimitPromise = promises.timesLimit( + total, + concurrency, + makePromise, + ); + while (testPromises.length > 0) { + const { promise, resolve } = testPromises.pop()!; + resolve!(); + await promise; + } + await timesLimitPromise; + assert.equal(wantIndex, total); + }); - it('accepts total === 0, concurrency > 0', async function () { - wantIndex = 0; - assert.equal(testPromises.length, 0); - await promises.timesLimit(0, concurrency, makePromise); - assert.equal(wantIndex, 0); - }); + it("accepts total === 0, concurrency > 0", async function () { + wantIndex = 0; + assert.equal(testPromises.length, 0); + await promises.timesLimit(0, concurrency, makePromise); + assert.equal(wantIndex, 0); + }); - it('accepts total === 0, concurrency === 0', async function () { - wantIndex = 0; - assert.equal(testPromises.length, 0); - await promises.timesLimit(0, 0, makePromise); - assert.equal(wantIndex, 0); - }); + it("accepts total === 0, concurrency === 0", async function () { + wantIndex = 0; + assert.equal(testPromises.length, 0); + await promises.timesLimit(0, 0, makePromise); + assert.equal(wantIndex, 0); + }); - it('rejects total > 0, concurrency === 0', async function () { - await assert.rejects(promises.timesLimit(total, 0, makePromise), RangeError); - }); - }); + it("rejects total > 0, concurrency === 0", async function () { + await assert.rejects( + promises.timesLimit(total, 0, makePromise), + RangeError, + ); + }); + }); }); diff --git a/src/tests/backend/specs/regression-db.ts b/src/tests/backend/specs/regression-db.ts index ba50e5240..eff6f25c2 100644 --- a/src/tests/backend/specs/regression-db.ts +++ b/src/tests/backend/specs/regression-db.ts @@ -1,30 +1,32 @@ -'use strict'; +"use strict"; -const AuthorManager = require('../../../node/db/AuthorManager'); -import {strict as assert} from "assert"; -const common = require('../common'); -const db = require('../../../node/db/DB'); +const AuthorManager = require("../../../node/db/AuthorManager"); +import { strict as assert } from "assert"; +const common = require("../common"); +const db = require("../../../node/db/DB"); describe(__filename, function () { - let setBackup: Function; + let setBackup: Function; - before(async function () { - await common.init(); - setBackup = db.set; + before(async function () { + await common.init(); + setBackup = db.set; - db.set = async (...args:any) => { - // delay db.set - await new Promise((resolve) => { setTimeout(() => resolve(), 500); }); - return await setBackup.call(db, ...args); - }; - }); + db.set = async (...args: any) => { + // delay db.set + await new Promise((resolve) => { + setTimeout(() => resolve(), 500); + }); + return await setBackup.call(db, ...args); + }; + }); - after(async function () { - db.set = setBackup; - }); + after(async function () { + db.set = setBackup; + }); - it('regression test for missing await in createAuthor (#5000)', async function () { - const {authorID} = await AuthorManager.createAuthor(); // Should block until db.set() finishes. - assert(await AuthorManager.doesAuthorExist(authorID)); - }); + it("regression test for missing await in createAuthor (#5000)", async function () { + const { authorID } = await AuthorManager.createAuthor(); // Should block until db.set() finishes. + assert(await AuthorManager.doesAuthorExist(authorID)); + }); }); diff --git a/src/tests/backend/specs/sanitizePathname.ts b/src/tests/backend/specs/sanitizePathname.ts index fd3cbb2e7..5e4a98ce4 100644 --- a/src/tests/backend/specs/sanitizePathname.ts +++ b/src/tests/backend/specs/sanitizePathname.ts @@ -1,99 +1,103 @@ -'use strict'; +"use strict"; -import {strict as assert} from "assert"; -import path from 'path'; -const sanitizePathname = require('../../../node/utils/sanitizePathname'); +import { strict as assert } from "assert"; +import path from "path"; +const sanitizePathname = require("../../../node/utils/sanitizePathname"); describe(__filename, function () { - describe('absolute paths rejected', function () { - const testCases = [ - ['posix', '/'], - ['posix', '/foo'], - ['win32', '/'], - ['win32', '\\'], - ['win32', 'C:/foo'], - ['win32', 'C:\\foo'], - ['win32', 'c:/foo'], - ['win32', 'c:\\foo'], - ['win32', '/foo'], - ['win32', '\\foo'], - ]; - for (const [platform, p] of testCases) { - it(`${platform} ${p}`, async function () { - // @ts-ignore - assert.throws(() => sanitizePathname(p, path[platform]), {message: /absolute path/}); - }); - } - }); - describe('directory traversal rejected', function () { - const testCases = [ - ['posix', '..'], - ['posix', '../'], - ['posix', '../foo'], - ['posix', 'foo/../..'], - ['win32', '..'], - ['win32', '../'], - ['win32', '..\\'], - ['win32', '../foo'], - ['win32', '..\\foo'], - ['win32', 'foo/../..'], - ['win32', 'foo\\..\\..'], - ]; - for (const [platform, p] of testCases) { - it(`${platform} ${p}`, async function () { - // @ts-ignore - assert.throws(() => sanitizePathname(p, path[platform]), {message: /travers/}); - }); - } - }); + describe("absolute paths rejected", function () { + const testCases = [ + ["posix", "/"], + ["posix", "/foo"], + ["win32", "/"], + ["win32", "\\"], + ["win32", "C:/foo"], + ["win32", "C:\\foo"], + ["win32", "c:/foo"], + ["win32", "c:\\foo"], + ["win32", "/foo"], + ["win32", "\\foo"], + ]; + for (const [platform, p] of testCases) { + it(`${platform} ${p}`, async function () { + // @ts-ignore + assert.throws(() => sanitizePathname(p, path[platform]), { + message: /absolute path/, + }); + }); + } + }); + describe("directory traversal rejected", function () { + const testCases = [ + ["posix", ".."], + ["posix", "../"], + ["posix", "../foo"], + ["posix", "foo/../.."], + ["win32", ".."], + ["win32", "../"], + ["win32", "..\\"], + ["win32", "../foo"], + ["win32", "..\\foo"], + ["win32", "foo/../.."], + ["win32", "foo\\..\\.."], + ]; + for (const [platform, p] of testCases) { + it(`${platform} ${p}`, async function () { + // @ts-ignore + assert.throws(() => sanitizePathname(p, path[platform]), { + message: /travers/, + }); + }); + } + }); - describe('accepted paths', function () { - const testCases = [ - ['posix', '', '.'], - ['posix', '.'], - ['posix', './'], - ['posix', 'foo'], - ['posix', 'foo/'], - ['posix', 'foo/bar/..', 'foo'], - ['posix', 'foo/bar/../', 'foo/'], - ['posix', './foo', 'foo'], - ['posix', 'foo/bar'], - ['posix', 'foo\\bar'], - ['posix', '\\foo'], - ['posix', '..\\foo'], - ['posix', 'foo/../bar', 'bar'], - ['posix', 'C:/foo'], - ['posix', 'C:\\foo'], - ['win32', '', '.'], - ['win32', '.'], - ['win32', './'], - ['win32', '.\\', './'], - ['win32', 'foo'], - ['win32', 'foo/'], - ['win32', 'foo\\', 'foo/'], - ['win32', 'foo/bar/..', 'foo'], - ['win32', 'foo\\bar\\..', 'foo'], - ['win32', 'foo/bar/../', 'foo/'], - ['win32', 'foo\\bar\\..\\', 'foo/'], - ['win32', './foo', 'foo'], - ['win32', '.\\foo', 'foo'], - ['win32', 'foo/bar'], - ['win32', 'foo\\bar', 'foo/bar'], - ['win32', 'foo/../bar', 'bar'], - ['win32', 'foo\\..\\bar', 'bar'], - ['win32', 'foo/..\\bar', 'bar'], - ['win32', 'foo\\../bar', 'bar'], - ]; - for (const [platform, p, tcWant] of testCases) { - const want = tcWant == null ? p : tcWant; - it(`${platform} ${p || ''} -> ${want}`, async function () { - // @ts-ignore - assert.equal(sanitizePathname(p, path[platform]), want); - }); - } - }); + describe("accepted paths", function () { + const testCases = [ + ["posix", "", "."], + ["posix", "."], + ["posix", "./"], + ["posix", "foo"], + ["posix", "foo/"], + ["posix", "foo/bar/..", "foo"], + ["posix", "foo/bar/../", "foo/"], + ["posix", "./foo", "foo"], + ["posix", "foo/bar"], + ["posix", "foo\\bar"], + ["posix", "\\foo"], + ["posix", "..\\foo"], + ["posix", "foo/../bar", "bar"], + ["posix", "C:/foo"], + ["posix", "C:\\foo"], + ["win32", "", "."], + ["win32", "."], + ["win32", "./"], + ["win32", ".\\", "./"], + ["win32", "foo"], + ["win32", "foo/"], + ["win32", "foo\\", "foo/"], + ["win32", "foo/bar/..", "foo"], + ["win32", "foo\\bar\\..", "foo"], + ["win32", "foo/bar/../", "foo/"], + ["win32", "foo\\bar\\..\\", "foo/"], + ["win32", "./foo", "foo"], + ["win32", ".\\foo", "foo"], + ["win32", "foo/bar"], + ["win32", "foo\\bar", "foo/bar"], + ["win32", "foo/../bar", "bar"], + ["win32", "foo\\..\\bar", "bar"], + ["win32", "foo/..\\bar", "bar"], + ["win32", "foo\\../bar", "bar"], + ]; + for (const [platform, p, tcWant] of testCases) { + const want = tcWant == null ? p : tcWant; + it(`${platform} ${p || ""} -> ${want}`, async function () { + // @ts-ignore + assert.equal(sanitizePathname(p, path[platform]), want); + }); + } + }); - it('default path API', async function () { - assert.equal(sanitizePathname('foo'), 'foo'); - }); + it("default path API", async function () { + assert.equal(sanitizePathname("foo"), "foo"); + }); }); diff --git a/src/tests/backend/specs/settings.ts b/src/tests/backend/specs/settings.ts index d9bfe4f6d..68a7ab519 100644 --- a/src/tests/backend/specs/settings.ts +++ b/src/tests/backend/specs/settings.ts @@ -1,92 +1,96 @@ -'use strict'; +"use strict"; -const assert = require('assert').strict; -const {parseSettings} = require('../../../node/utils/Settings').exportedForTestingOnly; -import path from 'path'; -import process from 'process'; +const assert = require("assert").strict; +const { parseSettings } = + require("../../../node/utils/Settings").exportedForTestingOnly; +import path from "path"; +import process from "process"; describe(__filename, function () { - describe('parseSettings', function () { - let settings: any; - const envVarSubstTestCases = [ - {name: 'true', val: 'true', var: 'SET_VAR_TRUE', want: true}, - {name: 'false', val: 'false', var: 'SET_VAR_FALSE', want: false}, - {name: 'null', val: 'null', var: 'SET_VAR_NULL', want: null}, - {name: 'undefined', val: 'undefined', var: 'SET_VAR_UNDEFINED', want: undefined}, - {name: 'number', val: '123', var: 'SET_VAR_NUMBER', want: 123}, - {name: 'string', val: 'foo', var: 'SET_VAR_STRING', want: 'foo'}, - {name: 'empty string', val: '', var: 'SET_VAR_EMPTY_STRING', want: ''}, - ]; + describe("parseSettings", function () { + let settings: any; + const envVarSubstTestCases = [ + { name: "true", val: "true", var: "SET_VAR_TRUE", want: true }, + { name: "false", val: "false", var: "SET_VAR_FALSE", want: false }, + { name: "null", val: "null", var: "SET_VAR_NULL", want: null }, + { + name: "undefined", + val: "undefined", + var: "SET_VAR_UNDEFINED", + want: undefined, + }, + { name: "number", val: "123", var: "SET_VAR_NUMBER", want: 123 }, + { name: "string", val: "foo", var: "SET_VAR_STRING", want: "foo" }, + { name: "empty string", val: "", var: "SET_VAR_EMPTY_STRING", want: "" }, + ]; - before(async function () { - for (const tc of envVarSubstTestCases) process.env[tc.var] = tc.val; - delete process.env.UNSET_VAR; - settings = parseSettings(path.join(__dirname, 'settings.json'), true); - assert(settings != null); - }); + before(async function () { + for (const tc of envVarSubstTestCases) process.env[tc.var] = tc.val; + delete process.env.UNSET_VAR; + settings = parseSettings(path.join(__dirname, "settings.json"), true); + assert(settings != null); + }); - describe('environment variable substitution', function () { - describe('set', function () { - for (const tc of envVarSubstTestCases) { - it(tc.name, async function () { - const obj = settings['environment variable substitution'].set; - if (tc.name === 'undefined') { - assert(!(tc.name in obj)); - } else { - assert.equal(obj[tc.name], tc.want); - } - }); - } - }); + describe("environment variable substitution", function () { + describe("set", function () { + for (const tc of envVarSubstTestCases) { + it(tc.name, async function () { + const obj = settings["environment variable substitution"].set; + if (tc.name === "undefined") { + assert(!(tc.name in obj)); + } else { + assert.equal(obj[tc.name], tc.want); + } + }); + } + }); - describe('unset', function () { - it('no default', async function () { - const obj = settings['environment variable substitution'].unset; - assert.equal(obj['no default'], null); - }); + describe("unset", function () { + it("no default", async function () { + const obj = settings["environment variable substitution"].unset; + assert.equal(obj["no default"], null); + }); - for (const tc of envVarSubstTestCases) { - it(tc.name, async function () { - const obj = settings['environment variable substitution'].unset; - if (tc.name === 'undefined') { - assert(!(tc.name in obj)); - } else { - assert.equal(obj[tc.name], tc.want); - } - }); - } - }); - }); - }); + for (const tc of envVarSubstTestCases) { + it(tc.name, async function () { + const obj = settings["environment variable substitution"].unset; + if (tc.name === "undefined") { + assert(!(tc.name in obj)); + } else { + assert.equal(obj[tc.name], tc.want); + } + }); + } + }); + }); + }); + describe("Parse plugin settings", function () { + before(async function () { + process.env["EP__ADMIN__PASSWORD"] = "test"; + }); - describe("Parse plugin settings", function () { + it("should parse plugin settings", async function () { + let settings = parseSettings(path.join(__dirname, "settings.json"), true); + assert.equal(settings.ADMIN.PASSWORD, "test"); + }); - before(async function () { - process.env["EP__ADMIN__PASSWORD"] = "test" - }) + it("should bundle settings with same path", async function () { + process.env["EP__ADMIN__USERNAME"] = "test"; + let settings = parseSettings(path.join(__dirname, "settings.json"), true); + assert.deepEqual(settings.ADMIN, { PASSWORD: "test", USERNAME: "test" }); + }); - it('should parse plugin settings', async function () { - let settings = parseSettings(path.join(__dirname, 'settings.json'), true); - assert.equal(settings.ADMIN.PASSWORD, "test"); - }) + it("Can set the ep themes", async function () { + process.env["EP__ep_themes__default_theme"] = "hacker"; + let settings = parseSettings(path.join(__dirname, "settings.json"), true); + assert.deepEqual(settings.ep_themes, { default_theme: "hacker" }); + }); - it('should bundle settings with same path', async function () { - process.env["EP__ADMIN__USERNAME"] = "test" - let settings = parseSettings(path.join(__dirname, 'settings.json'), true); - assert.deepEqual(settings.ADMIN, {PASSWORD: "test", USERNAME: "test"}); - }) - - it("Can set the ep themes", async function () { - process.env["EP__ep_themes__default_theme"] = "hacker" - let settings = parseSettings(path.join(__dirname, 'settings.json'), true); - assert.deepEqual(settings.ep_themes, {"default_theme": "hacker"}); - }) - - it("can set the ep_webrtc settings", async function () { - process.env["EP__ep_webrtc__enabled"] = "true" - let settings = parseSettings(path.join(__dirname, 'settings.json'), true); - assert.deepEqual(settings.ep_webrtc, {"enabled": true}); - }) - }) + it("can set the ep_webrtc settings", async function () { + process.env["EP__ep_webrtc__enabled"] = "true"; + let settings = parseSettings(path.join(__dirname, "settings.json"), true); + assert.deepEqual(settings.ep_webrtc, { enabled: true }); + }); + }); }); diff --git a/src/tests/backend/specs/socketio.ts b/src/tests/backend/specs/socketio.ts index cde554e5e..b4b8c47f9 100644 --- a/src/tests/backend/specs/socketio.ts +++ b/src/tests/backend/specs/socketio.ts @@ -1,434 +1,570 @@ -'use strict'; +"use strict"; -import {MapArrayType} from "../../../node/types/MapType"; +import { MapArrayType } from "../../../node/types/MapType"; -const assert = require('assert').strict; -const common = require('../common'); -const padManager = require('../../../node/db/PadManager'); -const plugins = require('../../../static/js/pluginfw/plugin_defs'); -const readOnlyManager = require('../../../node/db/ReadOnlyManager'); -const settings = require('../../../node/utils/Settings'); -const socketIoRouter = require('../../../node/handler/SocketIORouter'); +const assert = require("assert").strict; +const common = require("../common"); +const padManager = require("../../../node/db/PadManager"); +const plugins = require("../../../static/js/pluginfw/plugin_defs"); +const readOnlyManager = require("../../../node/db/ReadOnlyManager"); +const settings = require("../../../node/utils/Settings"); +const socketIoRouter = require("../../../node/handler/SocketIORouter"); describe(__filename, function () { - this.timeout(30000); - let agent: any; - let authorize:Function; - const backups:MapArrayType = {}; - const cleanUpPads = async () => { - const padIds = ['pad', 'other-pad', 'päd']; - await Promise.all(padIds.map(async (padId) => { - if (await padManager.doesPadExist(padId)) { - const pad = await padManager.getPad(padId); - await pad.remove(); - } - })); - }; - let socket:any; + this.timeout(30000); + let agent: any; + let authorize: Function; + const backups: MapArrayType = {}; + const cleanUpPads = async () => { + const padIds = ["pad", "other-pad", "päd"]; + await Promise.all( + padIds.map(async (padId) => { + if (await padManager.doesPadExist(padId)) { + const pad = await padManager.getPad(padId); + await pad.remove(); + } + }), + ); + }; + let socket: any; - before(async function () { agent = await common.init(); }); - beforeEach(async function () { - backups.hooks = {}; - for (const hookName of ['preAuthorize', 'authenticate', 'authorize']) { - backups.hooks[hookName] = plugins.hooks[hookName]; - plugins.hooks[hookName] = []; - } - backups.settings = {}; - for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users']) { - backups.settings[setting] = settings[setting]; - } - settings.editOnly = false; - settings.requireAuthentication = false; - settings.requireAuthorization = false; - settings.users = { - admin: {password: 'admin-password', is_admin: true}, - user: {password: 'user-password'}, - }; - assert(socket == null); - authorize = () => true; - plugins.hooks.authorize = [{hook_fn: (hookName: string, {req}:any, cb:Function) => cb([authorize(req)])}]; - await cleanUpPads(); - }); - afterEach(async function () { - if (socket) socket.close(); - socket = null; - await cleanUpPads(); - Object.assign(plugins.hooks, backups.hooks); - Object.assign(settings, backups.settings); - }); + before(async function () { + agent = await common.init(); + }); + beforeEach(async function () { + backups.hooks = {}; + for (const hookName of ["preAuthorize", "authenticate", "authorize"]) { + backups.hooks[hookName] = plugins.hooks[hookName]; + plugins.hooks[hookName] = []; + } + backups.settings = {}; + for (const setting of [ + "editOnly", + "requireAuthentication", + "requireAuthorization", + "users", + ]) { + backups.settings[setting] = settings[setting]; + } + settings.editOnly = false; + settings.requireAuthentication = false; + settings.requireAuthorization = false; + settings.users = { + admin: { password: "admin-password", is_admin: true }, + user: { password: "user-password" }, + }; + assert(socket == null); + authorize = () => true; + plugins.hooks.authorize = [ + { + hook_fn: (hookName: string, { req }: any, cb: Function) => + cb([authorize(req)]), + }, + ]; + await cleanUpPads(); + }); + afterEach(async function () { + if (socket) socket.close(); + socket = null; + await cleanUpPads(); + Object.assign(plugins.hooks, backups.hooks); + Object.assign(settings, backups.settings); + }); - describe('Normal accesses', function () { - it('!authn anonymous cookie /p/pad -> 200, ok', async function () { - const res = await agent.get('/p/pad').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - }); - it('!authn !cookie -> ok', async function () { - socket = await common.connect(null); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - }); - it('!authn user /p/pad -> 200, ok', async function () { - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - }); - it('authn user /p/pad -> 200, ok', async function () { - settings.requireAuthentication = true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - }); + describe("Normal accesses", function () { + it("!authn anonymous cookie /p/pad -> 200, ok", async function () { + const res = await agent.get("/p/pad").expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, "pad"); + assert.equal(clientVars.type, "CLIENT_VARS"); + }); + it("!authn !cookie -> ok", async function () { + socket = await common.connect(null); + const clientVars = await common.handshake(socket, "pad"); + assert.equal(clientVars.type, "CLIENT_VARS"); + }); + it("!authn user /p/pad -> 200, ok", async function () { + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, "pad"); + assert.equal(clientVars.type, "CLIENT_VARS"); + }); + it("authn user /p/pad -> 200, ok", async function () { + settings.requireAuthentication = true; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, "pad"); + assert.equal(clientVars.type, "CLIENT_VARS"); + }); - for (const authn of [false, true]) { - const desc = authn ? 'authn user' : '!authn anonymous'; - it(`${desc} read-only /p/pad -> 200, ok`, async function () { - const get = (ep: string) => { - let res = agent.get(ep); - if (authn) res = res.auth('user', 'user-password'); - return res.expect(200); - }; - settings.requireAuthentication = authn; - let res = await get('/p/pad'); - socket = await common.connect(res); - let clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, false); - const readOnlyId = clientVars.data.readOnlyId; - assert(readOnlyManager.isReadOnlyId(readOnlyId)); - socket.close(); - res = await get(`/p/${readOnlyId}`); - socket = await common.connect(res); - clientVars = await common.handshake(socket, readOnlyId); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, true); - }); - } + for (const authn of [false, true]) { + const desc = authn ? "authn user" : "!authn anonymous"; + it(`${desc} read-only /p/pad -> 200, ok`, async function () { + const get = (ep: string) => { + let res = agent.get(ep); + if (authn) res = res.auth("user", "user-password"); + return res.expect(200); + }; + settings.requireAuthentication = authn; + let res = await get("/p/pad"); + socket = await common.connect(res); + let clientVars = await common.handshake(socket, "pad"); + assert.equal(clientVars.type, "CLIENT_VARS"); + assert.equal(clientVars.data.readonly, false); + const readOnlyId = clientVars.data.readOnlyId; + assert(readOnlyManager.isReadOnlyId(readOnlyId)); + socket.close(); + res = await get(`/p/${readOnlyId}`); + socket = await common.connect(res); + clientVars = await common.handshake(socket, readOnlyId); + assert.equal(clientVars.type, "CLIENT_VARS"); + assert.equal(clientVars.data.readonly, true); + }); + } - it('authz user /p/pad -> 200, ok', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - }); - it('supports pad names with characters that must be percent-encoded', async function () { - settings.requireAuthentication = true; - // requireAuthorization is set to true here to guarantee that the user's padAuthorizations - // object is populated. Technically this isn't necessary because the user's padAuthorizations - // is currently populated even if requireAuthorization is false, but setting this to true - // ensures the test remains useful if the implementation ever changes. - settings.requireAuthorization = true; - const encodedPadId = encodeURIComponent('päd'); - const res = await agent.get(`/p/${encodedPadId}`).auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'päd'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - }); - }); + it("authz user /p/pad -> 200, ok", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, "pad"); + assert.equal(clientVars.type, "CLIENT_VARS"); + }); + it("supports pad names with characters that must be percent-encoded", async function () { + settings.requireAuthentication = true; + // requireAuthorization is set to true here to guarantee that the user's padAuthorizations + // object is populated. Technically this isn't necessary because the user's padAuthorizations + // is currently populated even if requireAuthorization is false, but setting this to true + // ensures the test remains useful if the implementation ever changes. + settings.requireAuthorization = true; + const encodedPadId = encodeURIComponent("päd"); + const res = await agent + .get(`/p/${encodedPadId}`) + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, "päd"); + assert.equal(clientVars.type, "CLIENT_VARS"); + }); + }); - describe('Abnormal access attempts', function () { - it('authn anonymous /p/pad -> 401, error', async function () { - settings.requireAuthentication = true; - const res = await agent.get('/p/pad').expect(401); - // Despite the 401, try to create the pad via a socket.io connection anyway. - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); + describe("Abnormal access attempts", function () { + it("authn anonymous /p/pad -> 401, error", async function () { + settings.requireAuthentication = true; + const res = await agent.get("/p/pad").expect(401); + // Despite the 401, try to create the pad via a socket.io connection anyway. + socket = await common.connect(res); + const message = await common.handshake(socket, "pad"); + assert.equal(message.accessStatus, "deny"); + }); - it('authn anonymous read-only /p/pad -> 401, error', async function () { - settings.requireAuthentication = true; - let res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - const readOnlyId = clientVars.data.readOnlyId; - assert(readOnlyManager.isReadOnlyId(readOnlyId)); - socket.close(); - res = await agent.get(`/p/${readOnlyId}`).expect(401); - // Despite the 401, try to read the pad via a socket.io connection anyway. - socket = await common.connect(res); - const message = await common.handshake(socket, readOnlyId); - assert.equal(message.accessStatus, 'deny'); - }); + it("authn anonymous read-only /p/pad -> 401, error", async function () { + settings.requireAuthentication = true; + let res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, "pad"); + assert.equal(clientVars.type, "CLIENT_VARS"); + const readOnlyId = clientVars.data.readOnlyId; + assert(readOnlyManager.isReadOnlyId(readOnlyId)); + socket.close(); + res = await agent.get(`/p/${readOnlyId}`).expect(401); + // Despite the 401, try to read the pad via a socket.io connection anyway. + socket = await common.connect(res); + const message = await common.handshake(socket, readOnlyId); + assert.equal(message.accessStatus, "deny"); + }); - it('authn !cookie -> error', async function () { - settings.requireAuthentication = true; - socket = await common.connect(null); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - it('authorization bypass attempt -> error', async function () { - // Only allowed to access /p/pad. - authorize = (req:{ - path: string, - }) => req.path === '/p/pad'; - settings.requireAuthentication = true; - settings.requireAuthorization = true; - // First authenticate and establish a session. - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - // Accessing /p/other-pad should fail, despite the successful fetch of /p/pad. - const message = await common.handshake(socket, 'other-pad'); - assert.equal(message.accessStatus, 'deny'); - }); - }); + it("authn !cookie -> error", async function () { + settings.requireAuthentication = true; + socket = await common.connect(null); + const message = await common.handshake(socket, "pad"); + assert.equal(message.accessStatus, "deny"); + }); + it("authorization bypass attempt -> error", async function () { + // Only allowed to access /p/pad. + authorize = (req: { + path: string; + }) => req.path === "/p/pad"; + settings.requireAuthentication = true; + settings.requireAuthorization = true; + // First authenticate and establish a session. + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + // Accessing /p/other-pad should fail, despite the successful fetch of /p/pad. + const message = await common.handshake(socket, "other-pad"); + assert.equal(message.accessStatus, "deny"); + }); + }); - describe('Authorization levels via authorize hook', function () { - beforeEach(async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - }); + describe("Authorization levels via authorize hook", function () { + beforeEach(async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + }); - it("level='create' -> can create", async function () { - authorize = () => 'create'; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, false); - }); - it('level=true -> can create', async function () { - authorize = () => true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, false); - }); - it("level='modify' -> can modify", async function () { - await padManager.getPad('pad'); // Create the pad. - authorize = () => 'modify'; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, false); - }); - it("level='create' settings.editOnly=true -> unable to create", async function () { - authorize = () => 'create'; - settings.editOnly = true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - it("level='modify' settings.editOnly=false -> unable to create", async function () { - authorize = () => 'modify'; - settings.editOnly = false; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - it("level='readOnly' -> unable to create", async function () { - authorize = () => 'readOnly'; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - it("level='readOnly' -> unable to modify", async function () { - await padManager.getPad('pad'); // Create the pad. - authorize = () => 'readOnly'; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, true); - }); - }); + it("level='create' -> can create", async function () { + authorize = () => "create"; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, "pad"); + assert.equal(clientVars.type, "CLIENT_VARS"); + assert.equal(clientVars.data.readonly, false); + }); + it("level=true -> can create", async function () { + authorize = () => true; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, "pad"); + assert.equal(clientVars.type, "CLIENT_VARS"); + assert.equal(clientVars.data.readonly, false); + }); + it("level='modify' -> can modify", async function () { + await padManager.getPad("pad"); // Create the pad. + authorize = () => "modify"; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, "pad"); + assert.equal(clientVars.type, "CLIENT_VARS"); + assert.equal(clientVars.data.readonly, false); + }); + it("level='create' settings.editOnly=true -> unable to create", async function () { + authorize = () => "create"; + settings.editOnly = true; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, "pad"); + assert.equal(message.accessStatus, "deny"); + }); + it("level='modify' settings.editOnly=false -> unable to create", async function () { + authorize = () => "modify"; + settings.editOnly = false; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, "pad"); + assert.equal(message.accessStatus, "deny"); + }); + it("level='readOnly' -> unable to create", async function () { + authorize = () => "readOnly"; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, "pad"); + assert.equal(message.accessStatus, "deny"); + }); + it("level='readOnly' -> unable to modify", async function () { + await padManager.getPad("pad"); // Create the pad. + authorize = () => "readOnly"; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, "pad"); + assert.equal(clientVars.type, "CLIENT_VARS"); + assert.equal(clientVars.data.readonly, true); + }); + }); - describe('Authorization levels via user settings', function () { - beforeEach(async function () { - settings.requireAuthentication = true; - }); + describe("Authorization levels via user settings", function () { + beforeEach(async function () { + settings.requireAuthentication = true; + }); - it('user.canCreate = true -> can create and modify', async function () { - settings.users.user.canCreate = true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, false); - }); - it('user.canCreate = false -> unable to create', async function () { - settings.users.user.canCreate = false; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - it('user.readOnly = true -> unable to create', async function () { - settings.users.user.readOnly = true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - it('user.readOnly = true -> unable to modify', async function () { - await padManager.getPad('pad'); // Create the pad. - settings.users.user.readOnly = true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, true); - }); - it('user.readOnly = false -> can create and modify', async function () { - settings.users.user.readOnly = false; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, false); - }); - it('user.readOnly = true, user.canCreate = true -> unable to create', async function () { - settings.users.user.canCreate = true; - settings.users.user.readOnly = true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - }); + it("user.canCreate = true -> can create and modify", async function () { + settings.users.user.canCreate = true; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, "pad"); + assert.equal(clientVars.type, "CLIENT_VARS"); + assert.equal(clientVars.data.readonly, false); + }); + it("user.canCreate = false -> unable to create", async function () { + settings.users.user.canCreate = false; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, "pad"); + assert.equal(message.accessStatus, "deny"); + }); + it("user.readOnly = true -> unable to create", async function () { + settings.users.user.readOnly = true; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, "pad"); + assert.equal(message.accessStatus, "deny"); + }); + it("user.readOnly = true -> unable to modify", async function () { + await padManager.getPad("pad"); // Create the pad. + settings.users.user.readOnly = true; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, "pad"); + assert.equal(clientVars.type, "CLIENT_VARS"); + assert.equal(clientVars.data.readonly, true); + }); + it("user.readOnly = false -> can create and modify", async function () { + settings.users.user.readOnly = false; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, "pad"); + assert.equal(clientVars.type, "CLIENT_VARS"); + assert.equal(clientVars.data.readonly, false); + }); + it("user.readOnly = true, user.canCreate = true -> unable to create", async function () { + settings.users.user.canCreate = true; + settings.users.user.readOnly = true; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, "pad"); + assert.equal(message.accessStatus, "deny"); + }); + }); - describe('Authorization level interaction between authorize hook and user settings', function () { - beforeEach(async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - }); + describe("Authorization level interaction between authorize hook and user settings", function () { + beforeEach(async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + }); - it('authorize hook does not elevate level from user settings', async function () { - settings.users.user.readOnly = true; - authorize = () => 'create'; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - it('user settings does not elevate level from authorize hook', async function () { - settings.users.user.readOnly = false; - settings.users.user.canCreate = true; - authorize = () => 'readOnly'; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - }); + it("authorize hook does not elevate level from user settings", async function () { + settings.users.user.readOnly = true; + authorize = () => "create"; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, "pad"); + assert.equal(message.accessStatus, "deny"); + }); + it("user settings does not elevate level from authorize hook", async function () { + settings.users.user.readOnly = false; + settings.users.user.canCreate = true; + authorize = () => "readOnly"; + const res = await agent + .get("/p/pad") + .auth("user", "user-password") + .expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, "pad"); + assert.equal(message.accessStatus, "deny"); + }); + }); - describe('SocketIORouter.js', function () { - const Module = class { - setSocketIO(io:any) {} - handleConnect(socket:any) {} - handleDisconnect(socket:any) {} - handleMessage(socket:any, message:string) {} - }; + describe("SocketIORouter.js", function () { + const Module = class { + setSocketIO(io: any) {} + handleConnect(socket: any) {} + handleDisconnect(socket: any) {} + handleMessage(socket: any, message: string) {} + }; - afterEach(async function () { - socketIoRouter.deleteComponent(this.test!.fullTitle()); - socketIoRouter.deleteComponent(`${this.test!.fullTitle()} #2`); - }); + afterEach(async function () { + socketIoRouter.deleteComponent(this.test!.fullTitle()); + socketIoRouter.deleteComponent(`${this.test!.fullTitle()} #2`); + }); - it('setSocketIO', async function () { - let ioServer; - socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module { - setSocketIO(io:any) { ioServer = io; } - }()); - assert(ioServer != null); - }); + it("setSocketIO", async function () { + let ioServer; + socketIoRouter.addComponent( + this.test!.fullTitle(), + new (class extends Module { + setSocketIO(io: any) { + ioServer = io; + } + })(), + ); + assert(ioServer != null); + }); - it('handleConnect', async function () { - let serverSocket; - socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module { - handleConnect(socket:any) { serverSocket = socket; } - }()); - socket = await common.connect(); - assert(serverSocket != null); - }); + it("handleConnect", async function () { + let serverSocket; + socketIoRouter.addComponent( + this.test!.fullTitle(), + new (class extends Module { + handleConnect(socket: any) { + serverSocket = socket; + } + })(), + ); + socket = await common.connect(); + assert(serverSocket != null); + }); - it('handleDisconnect', async function () { - let resolveConnected: (value: void | PromiseLike) => void ; - const connected = new Promise((resolve) => resolveConnected = resolve); - let resolveDisconnected: (value: void | PromiseLike) => void ; - const disconnected = new Promise((resolve) => resolveDisconnected = resolve); - socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module { - private _socket: any; - handleConnect(socket:any) { - this._socket = socket; - resolveConnected(); - } - handleDisconnect(socket:any) { - assert(socket != null); - // There might be lingering disconnect events from sockets created by other tests. - if (this._socket == null || socket.id !== this._socket.id) return; - assert.equal(socket, this._socket); - resolveDisconnected(); - } - }()); - socket = await common.connect(); - await connected; - socket.close(); - socket = null; - await disconnected; - }); + it("handleDisconnect", async function () { + let resolveConnected: (value: void | PromiseLike) => void; + const connected = new Promise((resolve) => (resolveConnected = resolve)); + let resolveDisconnected: (value: void | PromiseLike) => void; + const disconnected = new Promise( + (resolve) => (resolveDisconnected = resolve), + ); + socketIoRouter.addComponent( + this.test!.fullTitle(), + new (class extends Module { + private _socket: any; + handleConnect(socket: any) { + this._socket = socket; + resolveConnected(); + } + handleDisconnect(socket: any) { + assert(socket != null); + // There might be lingering disconnect events from sockets created by other tests. + if (this._socket == null || socket.id !== this._socket.id) return; + assert.equal(socket, this._socket); + resolveDisconnected(); + } + })(), + ); + socket = await common.connect(); + await connected; + socket.close(); + socket = null; + await disconnected; + }); - it('handleMessage (success)', async function () { - let serverSocket:any; - const want = { - component: this.test!.fullTitle(), - foo: {bar: 'asdf'}, - }; - let rx:Function; - const got = new Promise((resolve) => { rx = resolve; }); - socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module { - handleConnect(socket:any) { serverSocket = socket; } - handleMessage(socket:any, message:string) { assert.equal(socket, serverSocket); rx(message); } - }()); - socketIoRouter.addComponent(`${this.test!.fullTitle()} #2`, new class extends Module { - handleMessage(socket:any, message:any) { assert.fail('wrong handler called'); } - }()); - socket = await common.connect(); - socket.emit('message', want); - assert.deepEqual(await got, want); - }); + it("handleMessage (success)", async function () { + let serverSocket: any; + const want = { + component: this.test!.fullTitle(), + foo: { bar: "asdf" }, + }; + let rx: Function; + const got = new Promise((resolve) => { + rx = resolve; + }); + socketIoRouter.addComponent( + this.test!.fullTitle(), + new (class extends Module { + handleConnect(socket: any) { + serverSocket = socket; + } + handleMessage(socket: any, message: string) { + assert.equal(socket, serverSocket); + rx(message); + } + })(), + ); + socketIoRouter.addComponent( + `${this.test!.fullTitle()} #2`, + new (class extends Module { + handleMessage(socket: any, message: any) { + assert.fail("wrong handler called"); + } + })(), + ); + socket = await common.connect(); + socket.emit("message", want); + assert.deepEqual(await got, want); + }); - const tx = async (socket:any, message = {}) => await new Promise((resolve, reject) => { - const AckErr = class extends Error { - constructor(name: string, ...args:any) { super(...args); this.name = name; } - }; - socket.emit('message', message, - (errj: { - message: string, - name: string, - }, val: any) => errj != null ? reject(new AckErr(errj.name, errj.message)) : resolve(val)); - }); + const tx = async (socket: any, message = {}) => + await new Promise((resolve, reject) => { + const AckErr = class extends Error { + constructor(name: string, ...args: any) { + super(...args); + this.name = name; + } + }; + socket.emit( + "message", + message, + ( + errj: { + message: string; + name: string; + }, + val: any, + ) => + errj != null + ? reject(new AckErr(errj.name, errj.message)) + : resolve(val), + ); + }); - it('handleMessage with ack (success)', async function () { - const want = 'value'; - socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module { - handleMessage(socket:any, msg:any) { return want; } - }()); - socket = await common.connect(); - const got = await tx(socket, {component: this.test!.fullTitle()}); - assert.equal(got, want); - }); + it("handleMessage with ack (success)", async function () { + const want = "value"; + socketIoRouter.addComponent( + this.test!.fullTitle(), + new (class extends Module { + handleMessage(socket: any, msg: any) { + return want; + } + })(), + ); + socket = await common.connect(); + const got = await tx(socket, { component: this.test!.fullTitle() }); + assert.equal(got, want); + }); - it('handleMessage with ack (error)', async function () { - const InjectedError = class extends Error { - constructor() { super('injected test error'); this.name = 'InjectedError'; } - }; - socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module { - handleMessage(socket:any, msg:any) { throw new InjectedError(); } - }()); - socket = await common.connect(); - await assert.rejects(tx(socket, {component: this.test!.fullTitle()}), new InjectedError()); - }); - }); + it("handleMessage with ack (error)", async function () { + const InjectedError = class extends Error { + constructor() { + super("injected test error"); + this.name = "InjectedError"; + } + }; + socketIoRouter.addComponent( + this.test!.fullTitle(), + new (class extends Module { + handleMessage(socket: any, msg: any) { + throw new InjectedError(); + } + })(), + ); + socket = await common.connect(); + await assert.rejects( + tx(socket, { component: this.test!.fullTitle() }), + new InjectedError(), + ); + }); + }); }); diff --git a/src/tests/backend/specs/specialpages.ts b/src/tests/backend/specs/specialpages.ts index fbb446c49..69bafaa49 100644 --- a/src/tests/backend/specs/specialpages.ts +++ b/src/tests/backend/specs/specialpages.ts @@ -1,32 +1,32 @@ -'use strict'; - -import {MapArrayType} from "../../../node/types/MapType"; - -const common = require('../common'); -const settings = require('../../../node/utils/Settings'); +"use strict"; +import { MapArrayType } from "../../../node/types/MapType"; +const common = require("../common"); +const settings = require("../../../node/utils/Settings"); describe(__filename, function () { - this.timeout(30000); - let agent:any; - const backups:MapArrayType = {}; - before(async function () { agent = await common.init(); }); - beforeEach(async function () { - backups.settings = {}; - for (const setting of ['requireAuthentication', 'requireAuthorization']) { - backups.settings[setting] = settings[setting]; - } - settings.requireAuthentication = false; - settings.requireAuthorization = false; - }); - afterEach(async function () { - Object.assign(settings, backups.settings); - }); + this.timeout(30000); + let agent: any; + const backups: MapArrayType = {}; + before(async function () { + agent = await common.init(); + }); + beforeEach(async function () { + backups.settings = {}; + for (const setting of ["requireAuthentication", "requireAuthorization"]) { + backups.settings[setting] = settings[setting]; + } + settings.requireAuthentication = false; + settings.requireAuthorization = false; + }); + afterEach(async function () { + Object.assign(settings, backups.settings); + }); - describe('/javascript', function () { - it('/javascript -> 200', async function () { - await agent.get('/javascript').expect(200); - }); - }); + describe("/javascript", function () { + it("/javascript -> 200", async function () { + await agent.get("/javascript").expect(200); + }); + }); }); diff --git a/src/tests/backend/specs/webaccess.ts b/src/tests/backend/specs/webaccess.ts index 96c2265fc..95c5ab003 100644 --- a/src/tests/backend/specs/webaccess.ts +++ b/src/tests/backend/specs/webaccess.ts @@ -1,562 +1,677 @@ -'use strict'; +"use strict"; -import {MapArrayType} from "../../../node/types/MapType"; -import {Func} from "mocha"; -import {SettingsUser} from "../../../node/types/SettingsUser"; +import { MapArrayType } from "../../../node/types/MapType"; +import { Func } from "mocha"; +import { SettingsUser } from "../../../node/types/SettingsUser"; -const assert = require('assert').strict; -const common = require('../common'); -const plugins = require('../../../static/js/pluginfw/plugin_defs'); -const settings = require('../../../node/utils/Settings'); +const assert = require("assert").strict; +const common = require("../common"); +const plugins = require("../../../static/js/pluginfw/plugin_defs"); +const settings = require("../../../node/utils/Settings"); describe(__filename, function () { - this.timeout(30000); - let agent:any; - const backups:MapArrayType = {}; - const authHookNames = ['preAuthorize', 'authenticate', 'authorize']; - const failHookNames = ['preAuthzFailure', 'authnFailure', 'authzFailure', 'authFailure']; - const makeHook = (hookName: string, hookFn:Function) => ({ - hook_fn: hookFn, - hook_fn_name: `fake_plugin/${hookName}`, - hook_name: hookName, - part: {plugin: 'fake_plugin'}, - }); + this.timeout(30000); + let agent: any; + const backups: MapArrayType = {}; + const authHookNames = ["preAuthorize", "authenticate", "authorize"]; + const failHookNames = [ + "preAuthzFailure", + "authnFailure", + "authzFailure", + "authFailure", + ]; + const makeHook = (hookName: string, hookFn: Function) => ({ + hook_fn: hookFn, + hook_fn_name: `fake_plugin/${hookName}`, + hook_name: hookName, + part: { plugin: "fake_plugin" }, + }); - before(async function () { agent = await common.init(); }); + before(async function () { + agent = await common.init(); + }); - beforeEach(async function () { - backups.hooks = {}; - for (const hookName of authHookNames.concat(failHookNames)) { - backups.hooks[hookName] = plugins.hooks[hookName]; - plugins.hooks[hookName] = []; - } - backups.settings = {}; - for (const setting of ['requireAuthentication', 'requireAuthorization', 'users']) { - backups.settings[setting] = settings[setting]; - } - settings.requireAuthentication = false; - settings.requireAuthorization = false; - settings.users = { - admin: {password: 'admin-password', is_admin: true}, - user: {password: 'user-password'}, - } satisfies SettingsUser; - }); + beforeEach(async function () { + backups.hooks = {}; + for (const hookName of authHookNames.concat(failHookNames)) { + backups.hooks[hookName] = plugins.hooks[hookName]; + plugins.hooks[hookName] = []; + } + backups.settings = {}; + for (const setting of [ + "requireAuthentication", + "requireAuthorization", + "users", + ]) { + backups.settings[setting] = settings[setting]; + } + settings.requireAuthentication = false; + settings.requireAuthorization = false; + settings.users = { + admin: { password: "admin-password", is_admin: true }, + user: { password: "user-password" }, + } satisfies SettingsUser; + }); - afterEach(async function () { - Object.assign(plugins.hooks, backups.hooks); - Object.assign(settings, backups.settings); - }); + afterEach(async function () { + Object.assign(plugins.hooks, backups.hooks); + Object.assign(settings, backups.settings); + }); - describe('webaccess: without plugins', function () { - it('!authn !authz anonymous / -> 200', async function () { - settings.requireAuthentication = false; - settings.requireAuthorization = false; - await agent.get('/').expect(200); - }); + describe("webaccess: without plugins", function () { + it("!authn !authz anonymous / -> 200", async function () { + settings.requireAuthentication = false; + settings.requireAuthorization = false; + await agent.get("/").expect(200); + }); - it('!authn !authz anonymous /admin-auth// -> 401', async function () { - settings.requireAuthentication = false; - settings.requireAuthorization = false; - await agent.get('/admin-auth/').expect(401); - }); + it("!authn !authz anonymous /admin-auth// -> 401", async function () { + settings.requireAuthentication = false; + settings.requireAuthorization = false; + await agent.get("/admin-auth/").expect(401); + }); - it('authn !authz anonymous / -> 401', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = false; - await agent.get('/').expect(401); - }); + it("authn !authz anonymous / -> 401", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = false; + await agent.get("/").expect(401); + }); - it('authn !authz user / -> 200', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = false; - await agent.get('/').auth('user', 'user-password').expect(200); - }); + it("authn !authz user / -> 200", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = false; + await agent.get("/").auth("user", "user-password").expect(200); + }); - it('authn !authz user //admin-auth// -> 403', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = false; - await agent.get('/admin-auth//').auth('user', 'user-password').expect(403); - }); + it("authn !authz user //admin-auth// -> 403", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = false; + await agent + .get("/admin-auth//") + .auth("user", "user-password") + .expect(403); + }); - it('authn !authz admin / -> 200', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = false; - await agent.get('/').auth('admin', 'admin-password').expect(200); - }); + it("authn !authz admin / -> 200", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = false; + await agent.get("/").auth("admin", "admin-password").expect(200); + }); - it('authn !authz admin /admin-auth/ -> 200', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = false; - await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(200); - }); + it("authn !authz admin /admin-auth/ -> 200", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = false; + await agent + .get("/admin-auth/") + .auth("admin", "admin-password") + .expect(200); + }); - it('authn authz anonymous /robots.txt -> 200', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - await agent.get('/robots.txt').expect(200); - }); + it("authn authz anonymous /robots.txt -> 200", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + await agent.get("/robots.txt").expect(200); + }); - it('authn authz user / -> 403', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - await agent.get('/').auth('user', 'user-password').expect(403); - }); + it("authn authz user / -> 403", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + await agent.get("/").auth("user", "user-password").expect(403); + }); - it('authn authz user //admin-auth// -> 403', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - await agent.get('/admin-auth//').auth('user', 'user-password').expect(403); - }); + it("authn authz user //admin-auth// -> 403", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + await agent + .get("/admin-auth//") + .auth("user", "user-password") + .expect(403); + }); - it('authn authz admin / -> 200', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - await agent.get('/').auth('admin', 'admin-password').expect(200); - }); + it("authn authz admin / -> 200", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + await agent.get("/").auth("admin", "admin-password").expect(200); + }); - it('authn authz admin /admin-auth/ -> 200', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(200); - }); + it("authn authz admin /admin-auth/ -> 200", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + await agent + .get("/admin-auth/") + .auth("admin", "admin-password") + .expect(200); + }); - describe('login fails if password is nullish', function () { - for (const adminPassword of [undefined, null]) { - // https://tools.ietf.org/html/rfc7617 says that the username and password are sent as - // base64(username + ':' + password), but there's nothing stopping a malicious user from - // sending just base64(username) (no colon). The lack of colon could throw off credential - // parsing, resulting in successful comparisons against a null or undefined password. - for (const creds of ['admin', 'admin:']) { - it(`admin password: ${adminPassword} credentials: ${creds}`, async function () { - settings.users.admin.password = adminPassword; - const encCreds = Buffer.from(creds).toString('base64'); - await agent.get('/admin-auth/').set('Authorization', `Basic ${encCreds}`).expect(401); - }); - } - } - }); - }); + describe("login fails if password is nullish", function () { + for (const adminPassword of [undefined, null]) { + // https://tools.ietf.org/html/rfc7617 says that the username and password are sent as + // base64(username + ':' + password), but there's nothing stopping a malicious user from + // sending just base64(username) (no colon). The lack of colon could throw off credential + // parsing, resulting in successful comparisons against a null or undefined password. + for (const creds of ["admin", "admin:"]) { + it(`admin password: ${adminPassword} credentials: ${creds}`, async function () { + settings.users.admin.password = adminPassword; + const encCreds = Buffer.from(creds).toString("base64"); + await agent + .get("/admin-auth/") + .set("Authorization", `Basic ${encCreds}`) + .expect(401); + }); + } + } + }); + }); - describe('webaccess: preAuthorize, authenticate, and authorize hooks', function () { - let callOrder:string[]; - const Handler = class { - private called: boolean; - private readonly hookName: string; - private readonly innerHandle: Function; - private readonly id: string; - private readonly checkContext: Function; - constructor(hookName:string, suffix: string) { - this.called = false; - this.hookName = hookName; - this.innerHandle = () => []; - this.id = hookName + suffix; - this.checkContext = () => {}; - } - handle(hookName: string, context: any, cb:Function) { - assert.equal(hookName, this.hookName); - assert(context != null); - assert(context.req != null); - assert(context.res != null); - assert(context.next != null); - this.checkContext(context); - assert(!this.called); - this.called = true; - callOrder.push(this.id); - return cb(this.innerHandle(context)); - } - }; - const handlers:MapArrayType = {}; + describe("webaccess: preAuthorize, authenticate, and authorize hooks", function () { + let callOrder: string[]; + const Handler = class { + private called: boolean; + private readonly hookName: string; + private readonly innerHandle: Function; + private readonly id: string; + private readonly checkContext: Function; + constructor(hookName: string, suffix: string) { + this.called = false; + this.hookName = hookName; + this.innerHandle = () => []; + this.id = hookName + suffix; + this.checkContext = () => {}; + } + handle(hookName: string, context: any, cb: Function) { + assert.equal(hookName, this.hookName); + assert(context != null); + assert(context.req != null); + assert(context.res != null); + assert(context.next != null); + this.checkContext(context); + assert(!this.called); + this.called = true; + callOrder.push(this.id); + return cb(this.innerHandle(context)); + } + }; + const handlers: MapArrayType = {}; - beforeEach(async function () { - callOrder = []; - for (const hookName of authHookNames) { - // Create two handlers for each hook to test deferral to the next function. - const h0 = new Handler(hookName, '_0'); - const h1 = new Handler(hookName, '_1'); - handlers[hookName] = [h0, h1]; - plugins.hooks[hookName] = [ - makeHook(hookName, h0.handle.bind(h0)), - makeHook(hookName, h1.handle.bind(h1)), - ]; - } - }); + beforeEach(async function () { + callOrder = []; + for (const hookName of authHookNames) { + // Create two handlers for each hook to test deferral to the next function. + const h0 = new Handler(hookName, "_0"); + const h1 = new Handler(hookName, "_1"); + handlers[hookName] = [h0, h1]; + plugins.hooks[hookName] = [ + makeHook(hookName, h0.handle.bind(h0)), + makeHook(hookName, h1.handle.bind(h1)), + ]; + } + }); - describe('preAuthorize', function () { - beforeEach(async function () { - settings.requireAuthentication = false; - settings.requireAuthorization = false; - }); + describe("preAuthorize", function () { + beforeEach(async function () { + settings.requireAuthentication = false; + settings.requireAuthorization = false; + }); - it('defers if it returns []', async function () { - await agent.get('/').expect(200); - // Note: The preAuthorize hook always runs even if requireAuthorization is false. - assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']); - }); + it("defers if it returns []", async function () { + await agent.get("/").expect(200); + // Note: The preAuthorize hook always runs even if requireAuthorization is false. + assert.deepEqual(callOrder, ["preAuthorize_0", "preAuthorize_1"]); + }); - it('bypasses authenticate and authorize hooks when true is returned', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - handlers.preAuthorize[0].innerHandle = () => [true]; - await agent.get('/').expect(200); - assert.deepEqual(callOrder, ['preAuthorize_0']); - }); + it("bypasses authenticate and authorize hooks when true is returned", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + handlers.preAuthorize[0].innerHandle = () => [true]; + await agent.get("/").expect(200); + assert.deepEqual(callOrder, ["preAuthorize_0"]); + }); - it('bypasses authenticate and authorize hooks when false is returned', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - handlers.preAuthorize[0].innerHandle = () => [false]; - await agent.get('/').expect(403); - assert.deepEqual(callOrder, ['preAuthorize_0']); - }); + it("bypasses authenticate and authorize hooks when false is returned", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + handlers.preAuthorize[0].innerHandle = () => [false]; + await agent.get("/").expect(403); + assert.deepEqual(callOrder, ["preAuthorize_0"]); + }); - it('bypasses authenticate and authorize hooks when next is called', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - handlers.preAuthorize[0].innerHandle = ({next}:{ - next: Function - }) => next(); - await agent.get('/').expect(200); - assert.deepEqual(callOrder, ['preAuthorize_0']); - }); + it("bypasses authenticate and authorize hooks when next is called", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + handlers.preAuthorize[0].innerHandle = ({ + next, + }: { + next: Function; + }) => next(); + await agent.get("/").expect(200); + assert.deepEqual(callOrder, ["preAuthorize_0"]); + }); - it('static content (expressPreSession) bypasses all auth checks', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - await agent.get('/static/robots.txt').expect(200); - assert.deepEqual(callOrder, []); - }); + it("static content (expressPreSession) bypasses all auth checks", async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + await agent.get("/static/robots.txt").expect(200); + assert.deepEqual(callOrder, []); + }); - it('cannot grant access to /admin', async function () { - handlers.preAuthorize[0].innerHandle = () => [true]; - await agent.get('/admin-auth/').expect(401); - // Notes: - // * preAuthorize[1] is called despite preAuthorize[0] returning a non-empty list because - // 'true' entries are ignored for /admin-auth//* requests. - // * The authenticate hook always runs for /admin-auth//* requests even if - // settings.requireAuthentication is false. - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); + it("cannot grant access to /admin", async function () { + handlers.preAuthorize[0].innerHandle = () => [true]; + await agent.get("/admin-auth/").expect(401); + // Notes: + // * preAuthorize[1] is called despite preAuthorize[0] returning a non-empty list because + // 'true' entries are ignored for /admin-auth//* requests. + // * The authenticate hook always runs for /admin-auth//* requests even if + // settings.requireAuthentication is false. + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + "authenticate_1", + ]); + }); - it('can deny access to /admin-auth/', async function () { - handlers.preAuthorize[0].innerHandle = () => [false]; - await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(403); - assert.deepEqual(callOrder, ['preAuthorize_0']); - }); + it("can deny access to /admin-auth/", async function () { + handlers.preAuthorize[0].innerHandle = () => [false]; + await agent + .get("/admin-auth/") + .auth("admin", "admin-password") + .expect(403); + assert.deepEqual(callOrder, ["preAuthorize_0"]); + }); - it('runs preAuthzFailure hook when access is denied', async function () { - handlers.preAuthorize[0].innerHandle = () => [false]; - let called = false; - plugins.hooks.preAuthzFailure = [makeHook('preAuthzFailure', (hookName: string, {req, res}:any, cb:Function) => { - assert.equal(hookName, 'preAuthzFailure'); - assert(req != null); - assert(res != null); - assert(!called); - called = true; - res.status(200).send('injected'); - return cb([true]); - })]; - await agent.get('/admin-auth//').auth('admin', 'admin-password').expect(200, 'injected'); - assert(called); - }); + it("runs preAuthzFailure hook when access is denied", async function () { + handlers.preAuthorize[0].innerHandle = () => [false]; + let called = false; + plugins.hooks.preAuthzFailure = [ + makeHook( + "preAuthzFailure", + (hookName: string, { req, res }: any, cb: Function) => { + assert.equal(hookName, "preAuthzFailure"); + assert(req != null); + assert(res != null); + assert(!called); + called = true; + res.status(200).send("injected"); + return cb([true]); + }, + ), + ]; + await agent + .get("/admin-auth//") + .auth("admin", "admin-password") + .expect(200, "injected"); + assert(called); + }); - it('returns 500 if an exception is thrown', async function () { - handlers.preAuthorize[0].innerHandle = () => { throw new Error('exception test'); }; - await agent.get('/').expect(500); - }); - }); + it("returns 500 if an exception is thrown", async function () { + handlers.preAuthorize[0].innerHandle = () => { + throw new Error("exception test"); + }; + await agent.get("/").expect(500); + }); + }); - describe('authenticate', function () { - beforeEach(async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = false; - }); + describe("authenticate", function () { + beforeEach(async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = false; + }); - it('is not called if !requireAuthentication and not /admin-auth/*', async function () { - settings.requireAuthentication = false; - await agent.get('/').expect(200); - assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']); - }); + it("is not called if !requireAuthentication and not /admin-auth/*", async function () { + settings.requireAuthentication = false; + await agent.get("/").expect(200); + assert.deepEqual(callOrder, ["preAuthorize_0", "preAuthorize_1"]); + }); - it('is called if !requireAuthentication and /admin-auth//*', async function () { - settings.requireAuthentication = false; - await agent.get('/admin-auth/').expect(401); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); + it("is called if !requireAuthentication and /admin-auth//*", async function () { + settings.requireAuthentication = false; + await agent.get("/admin-auth/").expect(401); + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + "authenticate_1", + ]); + }); - it('defers if empty list returned', async function () { - await agent.get('/').expect(401); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); + it("defers if empty list returned", async function () { + await agent.get("/").expect(401); + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + "authenticate_1", + ]); + }); - it('does not defer if return [true], 200', async function () { - handlers.authenticate[0].innerHandle = ({req}:any) => { req.session.user = {}; return [true]; }; - await agent.get('/').expect(200); - // Note: authenticate_1 was not called because authenticate_0 handled it. - assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); - }); + it("does not defer if return [true], 200", async function () { + handlers.authenticate[0].innerHandle = ({ req }: any) => { + req.session.user = {}; + return [true]; + }; + await agent.get("/").expect(200); + // Note: authenticate_1 was not called because authenticate_0 handled it. + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + ]); + }); - it('does not defer if return [false], 401', async function () { - handlers.authenticate[0].innerHandle = () => [false]; - await agent.get('/').expect(401); - // Note: authenticate_1 was not called because authenticate_0 handled it. - assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); - }); + it("does not defer if return [false], 401", async function () { + handlers.authenticate[0].innerHandle = () => [false]; + await agent.get("/").expect(401); + // Note: authenticate_1 was not called because authenticate_0 handled it. + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + ]); + }); - it('falls back to HTTP basic auth', async function () { - await agent.get('/').auth('user', 'user-password').expect(200); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); + it("falls back to HTTP basic auth", async function () { + await agent.get("/").auth("user", "user-password").expect(200); + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + "authenticate_1", + ]); + }); - it('passes settings.users in context', async function () { - handlers.authenticate[0].checkContext = ({users}:{ - users: SettingsUser - }) => { - assert.equal(users, settings.users); - }; - await agent.get('/').expect(401); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); + it("passes settings.users in context", async function () { + handlers.authenticate[0].checkContext = ({ + users, + }: { + users: SettingsUser; + }) => { + assert.equal(users, settings.users); + }; + await agent.get("/").expect(401); + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + "authenticate_1", + ]); + }); - it('passes user, password in context if provided', async function () { - handlers.authenticate[0].checkContext = ({username, password}:{ - username: string, - password: string + it("passes user, password in context if provided", async function () { + handlers.authenticate[0].checkContext = ({ + username, + password, + }: { + username: string; + password: string; + }) => { + assert.equal(username, "user"); + assert.equal(password, "user-password"); + }; + await agent.get("/").auth("user", "user-password").expect(200); + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + "authenticate_1", + ]); + }); - }) => { - assert.equal(username, 'user'); - assert.equal(password, 'user-password'); - }; - await agent.get('/').auth('user', 'user-password').expect(200); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); + it("does not pass user, password in context if not provided", async function () { + handlers.authenticate[0].checkContext = ({ + username, + password, + }: { + username: string; + password: string; + }) => { + assert(username == null); + assert(password == null); + }; + await agent.get("/").expect(401); + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + "authenticate_1", + ]); + }); - it('does not pass user, password in context if not provided', async function () { - handlers.authenticate[0].checkContext = ({username, password}:{ - username: string, - password: string - }) => { - assert(username == null); - assert(password == null); - }; - await agent.get('/').expect(401); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); + it("errors if req.session.user is not created", async function () { + handlers.authenticate[0].innerHandle = () => [true]; + await agent.get("/").expect(500); + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + ]); + }); - it('errors if req.session.user is not created', async function () { - handlers.authenticate[0].innerHandle = () => [true]; - await agent.get('/').expect(500); - assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); - }); + it("returns 500 if an exception is thrown", async function () { + handlers.authenticate[0].innerHandle = () => { + throw new Error("exception test"); + }; + await agent.get("/").expect(500); + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + ]); + }); + }); - it('returns 500 if an exception is thrown', async function () { - handlers.authenticate[0].innerHandle = () => { throw new Error('exception test'); }; - await agent.get('/').expect(500); - assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); - }); - }); + describe("authorize", function () { + beforeEach(async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + }); - describe('authorize', function () { - beforeEach(async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - }); + it("is not called if !requireAuthorization (non-/admin)", async function () { + settings.requireAuthorization = false; + await agent.get("/").auth("user", "user-password").expect(200); + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + "authenticate_1", + ]); + }); - it('is not called if !requireAuthorization (non-/admin)', async function () { - settings.requireAuthorization = false; - await agent.get('/').auth('user', 'user-password').expect(200); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); + it("is not called if !requireAuthorization (/admin)", async function () { + settings.requireAuthorization = false; + await agent + .get("/admin-auth/") + .auth("admin", "admin-password") + .expect(200); + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + "authenticate_1", + ]); + }); - it('is not called if !requireAuthorization (/admin)', async function () { - settings.requireAuthorization = false; - await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(200); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); + it("defers if empty list returned", async function () { + await agent.get("/").auth("user", "user-password").expect(403); + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + "authenticate_1", + "authorize_0", + "authorize_1", + ]); + }); - it('defers if empty list returned', async function () { - await agent.get('/').auth('user', 'user-password').expect(403); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1', - 'authorize_0', - 'authorize_1']); - }); + it("does not defer if return [true], 200", async function () { + handlers.authorize[0].innerHandle = () => [true]; + await agent.get("/").auth("user", "user-password").expect(200); + // Note: authorize_1 was not called because authorize_0 handled it. + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + "authenticate_1", + "authorize_0", + ]); + }); - it('does not defer if return [true], 200', async function () { - handlers.authorize[0].innerHandle = () => [true]; - await agent.get('/').auth('user', 'user-password').expect(200); - // Note: authorize_1 was not called because authorize_0 handled it. - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1', - 'authorize_0']); - }); + it("does not defer if return [false], 403", async function () { + handlers.authorize[0].innerHandle = () => [false]; + await agent.get("/").auth("user", "user-password").expect(403); + // Note: authorize_1 was not called because authorize_0 handled it. + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + "authenticate_1", + "authorize_0", + ]); + }); - it('does not defer if return [false], 403', async function () { - handlers.authorize[0].innerHandle = () => [false]; - await agent.get('/').auth('user', 'user-password').expect(403); - // Note: authorize_1 was not called because authorize_0 handled it. - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1', - 'authorize_0']); - }); + it("passes req.path in context", async function () { + handlers.authorize[0].checkContext = ({ + resource, + }: { + resource: string; + }) => { + assert.equal(resource, "/"); + }; + await agent.get("/").auth("user", "user-password").expect(403); + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + "authenticate_1", + "authorize_0", + "authorize_1", + ]); + }); - it('passes req.path in context', async function () { - handlers.authorize[0].checkContext = ({resource}:{ - resource: string - }) => { - assert.equal(resource, '/'); - }; - await agent.get('/').auth('user', 'user-password').expect(403); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1', - 'authorize_0', - 'authorize_1']); - }); + it("returns 500 if an exception is thrown", async function () { + handlers.authorize[0].innerHandle = () => { + throw new Error("exception test"); + }; + await agent.get("/").auth("user", "user-password").expect(500); + assert.deepEqual(callOrder, [ + "preAuthorize_0", + "preAuthorize_1", + "authenticate_0", + "authenticate_1", + "authorize_0", + ]); + }); + }); + }); - it('returns 500 if an exception is thrown', async function () { - handlers.authorize[0].innerHandle = () => { throw new Error('exception test'); }; - await agent.get('/').auth('user', 'user-password').expect(500); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1', - 'authorize_0']); - }); - }); - }); + describe("webaccess: authnFailure, authzFailure, authFailure hooks", function () { + const Handler = class { + private hookName: string; + private shouldHandle: boolean; + private called: boolean; + constructor(hookName: string) { + this.hookName = hookName; + this.shouldHandle = false; + this.called = false; + } + handle(hookName: string, context: any, cb: Function) { + assert.equal(hookName, this.hookName); + assert(context != null); + assert(context.req != null); + assert(context.res != null); + assert(!this.called); + this.called = true; + if (this.shouldHandle) { + context.res.status(200).send(this.hookName); + return cb([true]); + } + return cb([]); + } + }; + const handlers: MapArrayType = {}; - describe('webaccess: authnFailure, authzFailure, authFailure hooks', function () { - const Handler = class { - private hookName: string; - private shouldHandle: boolean; - private called: boolean; - constructor(hookName: string) { - this.hookName = hookName; - this.shouldHandle = false; - this.called = false; - } - handle(hookName: string, context:any, cb: Function) { - assert.equal(hookName, this.hookName); - assert(context != null); - assert(context.req != null); - assert(context.res != null); - assert(!this.called); - this.called = true; - if (this.shouldHandle) { - context.res.status(200).send(this.hookName); - return cb([true]); - } - return cb([]); - } - }; - const handlers:MapArrayType = {}; + beforeEach(async function () { + failHookNames.forEach((hookName) => { + const handler = new Handler(hookName); + handlers[hookName] = handler; + plugins.hooks[hookName] = [ + makeHook(hookName, handler.handle.bind(handler)), + ]; + }); + settings.requireAuthentication = true; + settings.requireAuthorization = true; + }); - beforeEach(async function () { - failHookNames.forEach((hookName) => { - const handler = new Handler(hookName); - handlers[hookName] = handler; - plugins.hooks[hookName] = [makeHook(hookName, handler.handle.bind(handler))]; - }); - settings.requireAuthentication = true; - settings.requireAuthorization = true; - }); + // authn failure tests + it("authn fail, no hooks handle -> 401", async function () { + await agent.get("/").expect(401); + assert(handlers.authnFailure.called); + assert(!handlers.authzFailure.called); + assert(handlers.authFailure.called); + }); - // authn failure tests - it('authn fail, no hooks handle -> 401', async function () { - await agent.get('/').expect(401); - assert(handlers.authnFailure.called); - assert(!handlers.authzFailure.called); - assert(handlers.authFailure.called); - }); + it("authn fail, authnFailure handles", async function () { + handlers.authnFailure.shouldHandle = true; + await agent.get("/").expect(200, "authnFailure"); + assert(handlers.authnFailure.called); + assert(!handlers.authzFailure.called); + assert(!handlers.authFailure.called); + }); - it('authn fail, authnFailure handles', async function () { - handlers.authnFailure.shouldHandle = true; - await agent.get('/').expect(200, 'authnFailure'); - assert(handlers.authnFailure.called); - assert(!handlers.authzFailure.called); - assert(!handlers.authFailure.called); - }); + it("authn fail, authFailure handles", async function () { + handlers.authFailure.shouldHandle = true; + await agent.get("/").expect(200, "authFailure"); + assert(handlers.authnFailure.called); + assert(!handlers.authzFailure.called); + assert(handlers.authFailure.called); + }); - it('authn fail, authFailure handles', async function () { - handlers.authFailure.shouldHandle = true; - await agent.get('/').expect(200, 'authFailure'); - assert(handlers.authnFailure.called); - assert(!handlers.authzFailure.called); - assert(handlers.authFailure.called); - }); + it("authnFailure trumps authFailure", async function () { + handlers.authnFailure.shouldHandle = true; + handlers.authFailure.shouldHandle = true; + await agent.get("/").expect(200, "authnFailure"); + assert(handlers.authnFailure.called); + assert(!handlers.authFailure.called); + }); - it('authnFailure trumps authFailure', async function () { - handlers.authnFailure.shouldHandle = true; - handlers.authFailure.shouldHandle = true; - await agent.get('/').expect(200, 'authnFailure'); - assert(handlers.authnFailure.called); - assert(!handlers.authFailure.called); - }); + // authz failure tests + it("authz fail, no hooks handle -> 403", async function () { + await agent.get("/").auth("user", "user-password").expect(403); + assert(!handlers.authnFailure.called); + assert(handlers.authzFailure.called); + assert(handlers.authFailure.called); + }); - // authz failure tests - it('authz fail, no hooks handle -> 403', async function () { - await agent.get('/').auth('user', 'user-password').expect(403); - assert(!handlers.authnFailure.called); - assert(handlers.authzFailure.called); - assert(handlers.authFailure.called); - }); + it("authz fail, authzFailure handles", async function () { + handlers.authzFailure.shouldHandle = true; + await agent + .get("/") + .auth("user", "user-password") + .expect(200, "authzFailure"); + assert(!handlers.authnFailure.called); + assert(handlers.authzFailure.called); + assert(!handlers.authFailure.called); + }); - it('authz fail, authzFailure handles', async function () { - handlers.authzFailure.shouldHandle = true; - await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure'); - assert(!handlers.authnFailure.called); - assert(handlers.authzFailure.called); - assert(!handlers.authFailure.called); - }); + it("authz fail, authFailure handles", async function () { + handlers.authFailure.shouldHandle = true; + await agent + .get("/") + .auth("user", "user-password") + .expect(200, "authFailure"); + assert(!handlers.authnFailure.called); + assert(handlers.authzFailure.called); + assert(handlers.authFailure.called); + }); - it('authz fail, authFailure handles', async function () { - handlers.authFailure.shouldHandle = true; - await agent.get('/').auth('user', 'user-password').expect(200, 'authFailure'); - assert(!handlers.authnFailure.called); - assert(handlers.authzFailure.called); - assert(handlers.authFailure.called); - }); - - it('authzFailure trumps authFailure', async function () { - handlers.authzFailure.shouldHandle = true; - handlers.authFailure.shouldHandle = true; - await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure'); - assert(handlers.authzFailure.called); - assert(!handlers.authFailure.called); - }); - }); + it("authzFailure trumps authFailure", async function () { + handlers.authzFailure.shouldHandle = true; + handlers.authFailure.shouldHandle = true; + await agent + .get("/") + .auth("user", "user-password") + .expect(200, "authzFailure"); + assert(handlers.authzFailure.called); + assert(!handlers.authFailure.called); + }); + }); }); diff --git a/src/tests/container/loadSettings.js b/src/tests/container/loadSettings.js index b59ff0165..a4e3bc533 100644 --- a/src/tests/container/loadSettings.js +++ b/src/tests/container/loadSettings.js @@ -12,26 +12,30 @@ * back to a default) */ -const fs = require('fs'); -const jsonminify = require('jsonminify'); +const fs = require("fs"); +const jsonminify = require("jsonminify"); function loadSettings() { - let settingsStr = fs.readFileSync(`${__dirname}/../../../settings.json.docker`).toString(); - // try to parse the settings - try { - if (settingsStr) { - settingsStr = jsonminify(settingsStr).replace(',]', ']').replace(',}', '}'); - const settings = JSON.parse(settingsStr); + let settingsStr = fs + .readFileSync(`${__dirname}/../../../settings.json.docker`) + .toString(); + // try to parse the settings + try { + if (settingsStr) { + settingsStr = jsonminify(settingsStr) + .replace(",]", "]") + .replace(",}", "}"); + const settings = JSON.parse(settingsStr); - // custom settings for running in a container - settings.ip = 'localhost'; - settings.port = '9001'; + // custom settings for running in a container + settings.ip = "localhost"; + settings.port = "9001"; - return settings; - } - } catch (e) { - console.error('whoops something is bad with settings'); - } + return settings; + } + } catch (e) { + console.error("whoops something is bad with settings"); + } } exports.loadSettings = loadSettings; diff --git a/src/tests/container/specs/api/pad.js b/src/tests/container/specs/api/pad.js index f6ff8ebf5..e840ff576 100644 --- a/src/tests/container/specs/api/pad.js +++ b/src/tests/container/specs/api/pad.js @@ -5,34 +5,32 @@ * TODO: unify those two files, and merge in a single one. */ -const settings = require('../../loadSettings').loadSettings(); -const supertest = require('supertest'); +const settings = require("../../loadSettings").loadSettings(); +const supertest = require("supertest"); const api = supertest(`http://${settings.ip}:${settings.port}`); const apiVersion = 1; -describe('Connectivity', function () { - it('can connect', function (done) { - api.get('/api/') - .expect('Content-Type', /json/) - .expect(200, done); - }); +describe("Connectivity", function () { + it("can connect", function (done) { + api.get("/api/").expect("Content-Type", /json/).expect(200, done); + }); }); -describe('API Versioning', function () { - it('finds the version tag', function (done) { - api.get('/api/') - .expect((res) => { - if (!res.body.currentVersion) throw new Error('No version set in API'); - return; - }) - .expect(200, done); - }); +describe("API Versioning", function () { + it("finds the version tag", function (done) { + api + .get("/api/") + .expect((res) => { + if (!res.body.currentVersion) throw new Error("No version set in API"); + return; + }) + .expect(200, done); + }); }); -describe('Permission', function () { - it('errors with invalid OAuth token', function (done) { - api.get(`/api/${apiVersion}/createPad?padID=test`) - .expect(401, done); - }); +describe("Permission", function () { + it("errors with invalid OAuth token", function (done) { + api.get(`/api/${apiVersion}/createPad?padID=test`).expect(401, done); + }); }); diff --git a/src/tests/frontend-new/admin-spec/adminsettings.spec.ts b/src/tests/frontend-new/admin-spec/adminsettings.spec.ts index ad3a0c441..858f37acb 100644 --- a/src/tests/frontend-new/admin-spec/adminsettings.spec.ts +++ b/src/tests/frontend-new/admin-spec/adminsettings.spec.ts @@ -1,60 +1,61 @@ -import {expect, test} from "@playwright/test"; -import {loginToAdmin, restartEtherpad, saveSettings} from "../helper/adminhelper"; +import { expect, test } from "@playwright/test"; +import { + loginToAdmin, + restartEtherpad, + saveSettings, +} from "../helper/adminhelper"; -test.beforeEach(async ({ page })=>{ - await loginToAdmin(page, 'admin', 'changeme1'); -}) +test.beforeEach(async ({ page }) => { + await loginToAdmin(page, "admin", "changeme1"); +}); -test.describe('admin settings',()=> { +test.describe("admin settings", () => { + test("Are Settings visible, populated, does save work", async ({ page }) => { + await page.goto("http://localhost:9001/admin/settings"); + await page.waitForSelector(".settings"); + const settings = page.locator(".settings"); + await expect(settings).not.toBeEmpty(); + const settingsVal = await settings.inputValue(); + const settingsLength = settingsVal.length; - test('Are Settings visible, populated, does save work', async ({page}) => { - await page.goto('http://localhost:9001/admin/settings'); - await page.waitForSelector('.settings'); - const settings = page.locator('.settings'); - await expect(settings).not.toBeEmpty(); + await settings.fill(`/* test */\n${settingsVal}`); + const newValue = await settings.inputValue(); + expect(newValue).toContain("/* test */"); + expect(newValue.length).toEqual(settingsLength + 11); + await saveSettings(page); - const settingsVal = await settings.inputValue() - const settingsLength = settingsVal.length + // Check if the changes were actually saved + await page.reload(); + await page.waitForSelector(".settings"); + await expect(settings).not.toBeEmpty(); - await settings.fill(`/* test */\n${settingsVal}`) - const newValue = await settings.inputValue() - expect(newValue).toContain('/* test */') - expect(newValue.length).toEqual(settingsLength+11) - await saveSettings(page) + const newSettings = page.locator(".settings"); - // Check if the changes were actually saved - await page.reload() - await page.waitForSelector('.settings'); - await expect(settings).not.toBeEmpty(); + const newSettingsVal = await newSettings.inputValue(); + expect(newSettingsVal).toContain("/* test */"); - const newSettings = page.locator('.settings'); + // Change back to old settings + await newSettings.fill(settingsVal); + await saveSettings(page); - const newSettingsVal = await newSettings.inputValue() - expect(newSettingsVal).toContain('/* test */') + await page.reload(); + await page.waitForSelector(".settings"); + await expect(settings).not.toBeEmpty(); + const oldSettings = page.locator(".settings"); + const oldSettingsVal = await oldSettings.inputValue(); + expect(oldSettingsVal).toEqual(settingsVal); + expect(oldSettingsVal.length).toEqual(settingsLength); + }); - - // Change back to old settings - await newSettings.fill(settingsVal) - await saveSettings(page) - - await page.reload() - await page.waitForSelector('.settings'); - await expect(settings).not.toBeEmpty(); - const oldSettings = page.locator('.settings'); - const oldSettingsVal = await oldSettings.inputValue() - expect(oldSettingsVal).toEqual(settingsVal) - expect(oldSettingsVal.length).toEqual(settingsLength) - }) - - test('restart works', async function ({page}) { - await page.goto('http://localhost:9001/admin/settings'); - await page.waitForSelector('.settings') - await restartEtherpad(page) - await page.waitForSelector('.settings') - const settings = page.locator('.settings'); - await expect(settings).not.toBeEmpty(); - await page.waitForSelector('.menu') - await page.waitForTimeout(5000) - }); -}) + test("restart works", async function ({ page }) { + await page.goto("http://localhost:9001/admin/settings"); + await page.waitForSelector(".settings"); + await restartEtherpad(page); + await page.waitForSelector(".settings"); + const settings = page.locator(".settings"); + await expect(settings).not.toBeEmpty(); + await page.waitForSelector(".menu"); + await page.waitForTimeout(5000); + }); +}); diff --git a/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts b/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts index 9dc7c7a20..832e4513f 100644 --- a/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts +++ b/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts @@ -1,39 +1,38 @@ -import {expect, test} from "@playwright/test"; -import {loginToAdmin} from "../helper/adminhelper"; +import { expect, test } from "@playwright/test"; +import { loginToAdmin } from "../helper/adminhelper"; -test.beforeEach(async ({ page })=>{ - await loginToAdmin(page, 'admin', 'changeme1'); - await page.goto('http://localhost:9001/admin/help') -}) - -test('Shows troubleshooting page manager', async ({page}) => { - await page.goto('http://localhost:9001/admin/help') - await page.waitForSelector('.menu') - const menu = page.locator('.menu'); - await expect(menu.locator('li')).toHaveCount(4); -}) - -test('Shows a version number', async function ({page}) { - await page.goto('http://localhost:9001/admin/help') - await page.waitForSelector('.menu') - const helper = page.locator('.help-block').locator('div').nth(1) - const version = (await helper.textContent())!.split('.'); - expect(version.length).toBe(3) +test.beforeEach(async ({ page }) => { + await loginToAdmin(page, "admin", "changeme1"); + await page.goto("http://localhost:9001/admin/help"); }); -test('Lists installed parts', async function ({page}) { - await page.goto('http://localhost:9001/admin/help') - await page.waitForSelector('.menu') - await page.waitForSelector('.innerwrapper ul') - const parts = page.locator('.innerwrapper ul').nth(1); - expect(await parts.textContent()).toContain('ep_etherpad-lite/adminsettings'); +test("Shows troubleshooting page manager", async ({ page }) => { + await page.goto("http://localhost:9001/admin/help"); + await page.waitForSelector(".menu"); + const menu = page.locator(".menu"); + await expect(menu.locator("li")).toHaveCount(4); }); -test('Lists installed hooks', async function ({page}) { - await page.goto('http://localhost:9001/admin/help') - await page.waitForSelector('.menu') - await page.waitForSelector('.innerwrapper ul') - const helper = page.locator('.innerwrapper ul').nth(2); - expect(await helper.textContent()).toContain('express'); +test("Shows a version number", async function ({ page }) { + await page.goto("http://localhost:9001/admin/help"); + await page.waitForSelector(".menu"); + const helper = page.locator(".help-block").locator("div").nth(1); + const version = (await helper.textContent())!.split("."); + expect(version.length).toBe(3); }); +test("Lists installed parts", async function ({ page }) { + await page.goto("http://localhost:9001/admin/help"); + await page.waitForSelector(".menu"); + await page.waitForSelector(".innerwrapper ul"); + const parts = page.locator(".innerwrapper ul").nth(1); + expect(await parts.textContent()).toContain("ep_etherpad-lite/adminsettings"); +}); + +test("Lists installed hooks", async function ({ page }) { + await page.goto("http://localhost:9001/admin/help"); + await page.waitForSelector(".menu"); + await page.waitForSelector(".innerwrapper ul"); + const helper = page.locator(".innerwrapper ul").nth(2); + expect(await helper.textContent()).toContain("express"); +}); diff --git a/src/tests/frontend-new/admin-spec/adminupdateplugins.spec.ts b/src/tests/frontend-new/admin-spec/adminupdateplugins.spec.ts index c1121d41b..79fdbf1e9 100644 --- a/src/tests/frontend-new/admin-spec/adminupdateplugins.spec.ts +++ b/src/tests/frontend-new/admin-spec/adminupdateplugins.spec.ts @@ -1,75 +1,78 @@ -import {expect, test} from "@playwright/test"; -import {loginToAdmin} from "../helper/adminhelper"; +import { expect, test } from "@playwright/test"; +import { loginToAdmin } from "../helper/adminhelper"; -test.beforeEach(async ({ page })=>{ - await loginToAdmin(page, 'admin', 'changeme1'); - await page.goto('http://localhost:9001/admin/plugins') -}) +test.beforeEach(async ({ page }) => { + await loginToAdmin(page, "admin", "changeme1"); + await page.goto("http://localhost:9001/admin/plugins"); +}); +test.describe("Plugins page", () => { + test("List some plugins", async ({ page }) => { + await page.waitForSelector(".search-field"); + const pluginTable = page.locator("table tbody").nth(1); + await expect(pluginTable).not.toBeEmpty(); + const plugins = await pluginTable.locator("tr").count(); + expect(plugins).toBeGreaterThan(10); + }); -test.describe('Plugins page', ()=> { + test("Searches for a plugin", async ({ page }) => { + await page.waitForSelector(".search-field"); + await page.click(".search-field"); + await page.keyboard.type("ep_font_color3"); + await page.keyboard.press("Enter"); + const pluginTable = page.locator("table tbody").nth(1); + await expect(pluginTable.locator("tr")).toHaveCount(1); + await expect(pluginTable.locator("tr").first()).toContainText( + "ep_font_color3", + ); + }); - test('List some plugins', async ({page}) => { - await page.waitForSelector('.search-field'); - const pluginTable = page.locator('table tbody').nth(1); - await expect(pluginTable).not.toBeEmpty() - const plugins = await pluginTable.locator('tr').count() - expect(plugins).toBeGreaterThan(10) - }) + test("Attempt to Install and Uninstall a plugin", async ({ page }) => { + await page.waitForSelector(".search-field"); + const pluginTable = page.locator("table tbody").nth(1); + await expect(pluginTable).not.toBeEmpty({ + timeout: 15000, + }); + const plugins = await pluginTable.locator("tr").count(); + expect(plugins).toBeGreaterThan(10); - test('Searches for a plugin', async ({page}) => { - await page.waitForSelector('.search-field'); - await page.click('.search-field') - await page.keyboard.type('ep_font_color3') - await page.keyboard.press('Enter') - const pluginTable = page.locator('table tbody').nth(1); - await expect(pluginTable.locator('tr')).toHaveCount(1) - await expect(pluginTable.locator('tr').first()).toContainText('ep_font_color3') - }) + // Now everything is loaded, lets install a plugin + await page.click(".search-field"); + await page.keyboard.type("ep_font_color3"); + await page.keyboard.press("Enter"); - test('Attempt to Install and Uninstall a plugin', async ({page}) => { - await page.waitForSelector('.search-field'); - const pluginTable = page.locator('table tbody').nth(1); - await expect(pluginTable).not.toBeEmpty({ - timeout: 15000 - }) - const plugins = await pluginTable.locator('tr').count() - expect(plugins).toBeGreaterThan(10) + await expect(pluginTable.locator("tr")).toHaveCount(1); + const pluginRow = pluginTable.locator("tr").first(); + await expect(pluginRow).toContainText("ep_font_color3"); - // Now everything is loaded, lets install a plugin + // Select Installation button + await pluginRow.locator("td").nth(4).locator("button").first().click(); + await page.waitForTimeout(100); + await page.waitForSelector("table tbody"); + const installedPlugins = page.locator("table tbody").first(); + const installedPluginsRows = installedPlugins.locator("tr"); + await expect(installedPluginsRows).toHaveCount(2, { + timeout: 15000, + }); - await page.click('.search-field') - await page.keyboard.type('ep_font_color3') - await page.keyboard.press('Enter') + const installedPluginRow = installedPluginsRows.nth(1); - await expect(pluginTable.locator('tr')).toHaveCount(1) - const pluginRow = pluginTable.locator('tr').first() - await expect(pluginRow).toContainText('ep_font_color3') - - // Select Installation button - await pluginRow.locator('td').nth(4).locator('button').first().click() - await page.waitForTimeout(100) - await page.waitForSelector('table tbody') - const installedPlugins = page.locator('table tbody').first() - const installedPluginsRows = installedPlugins.locator('tr') - await expect(installedPluginsRows).toHaveCount(2, { - timeout: 15000 - }) - - const installedPluginRow = installedPluginsRows.nth(1) - - await expect(installedPluginRow).toContainText('ep_font_color3') - await installedPluginRow.locator('td').nth(2).locator('button').first().click() - - // Wait for the uninstallation to complete - await expect(installedPluginsRows).toHaveCount(1, { - timeout: 15000 - }) - await page.waitForTimeout(5000) - }) -}) + await expect(installedPluginRow).toContainText("ep_font_color3"); + await installedPluginRow + .locator("td") + .nth(2) + .locator("button") + .first() + .click(); + // Wait for the uninstallation to complete + await expect(installedPluginsRows).toHaveCount(1, { + timeout: 15000, + }); + await page.waitForTimeout(5000); + }); +}); /* it('Attempt to Update a plugin', async function () { diff --git a/src/tests/frontend-new/helper/adminhelper.ts b/src/tests/frontend-new/helper/adminhelper.ts index 8f2242f89..7074bb102 100644 --- a/src/tests/frontend-new/helper/adminhelper.ts +++ b/src/tests/frontend-new/helper/adminhelper.ts @@ -1,32 +1,38 @@ -import {expect, Page} from "@playwright/test"; +import { expect, Page } from "@playwright/test"; -export const loginToAdmin = async (page: Page, username: string, password: string) => { - - await page.goto('http://localhost:9001/admin/'); - - await page.waitForSelector('input[name="username"]'); - await page.fill('input[name="username"]', username); - await page.fill('input[name="password"]', password); - await page.click('input[type="submit"]'); -} +export const loginToAdmin = async ( + page: Page, + username: string, + password: string, +) => { + await page.goto("http://localhost:9001/admin/"); + await page.waitForSelector('input[name="username"]'); + await page.fill('input[name="username"]', username); + await page.fill('input[name="password"]', password); + await page.click('input[type="submit"]'); +}; export const saveSettings = async (page: Page) => { - // Click save - await page.locator('.settings-button-bar').locator('button').first().click() - await page.waitForSelector('.ToastRootSuccess') -} + // Click save + await page.locator(".settings-button-bar").locator("button").first().click(); + await page.waitForSelector(".ToastRootSuccess"); +}; export const restartEtherpad = async (page: Page) => { - // Click restart - const restartButton = page.locator('.settings-button-bar').locator('.settingsButton').nth(1) - const settings = page.locator('.settings'); - await expect(settings).not.toBeEmpty(); - await expect(restartButton).toBeVisible() - await page.locator('.settings-button-bar') - .locator('.settingsButton') - .nth(1) - .click() - await page.waitForTimeout(500) - await page.waitForSelector('.settings') -} + // Click restart + const restartButton = page + .locator(".settings-button-bar") + .locator(".settingsButton") + .nth(1); + const settings = page.locator(".settings"); + await expect(settings).not.toBeEmpty(); + await expect(restartButton).toBeVisible(); + await page + .locator(".settings-button-bar") + .locator(".settingsButton") + .nth(1) + .click(); + await page.waitForTimeout(500); + await page.waitForSelector(".settings"); +}; diff --git a/src/tests/frontend-new/helper/padHelper.ts b/src/tests/frontend-new/helper/padHelper.ts index f52cd0a35..6b592930b 100644 --- a/src/tests/frontend-new/helper/padHelper.ts +++ b/src/tests/frontend-new/helper/padHelper.ts @@ -1,157 +1,165 @@ -import {Frame, Locator, Page} from "@playwright/test"; -import {MapArrayType} from "../../../node/types/MapType"; -import {randomUUID} from "node:crypto"; +import { Frame, Locator, Page } from "@playwright/test"; +import { MapArrayType } from "../../../node/types/MapType"; +import { randomUUID } from "node:crypto"; -export const getPadOuter = async (page: Page): Promise => { - return page.frame('ace_outer')!; -} +export const getPadOuter = async (page: Page): Promise => { + return page.frame("ace_outer")!; +}; -export const getPadBody = async (page: Page): Promise => { - return page.frame('ace_inner')!.locator('#innerdocbody') -} +export const getPadBody = async (page: Page): Promise => { + return page.frame("ace_inner")!.locator("#innerdocbody"); +}; export const selectAllText = async (page: Page) => { - await page.keyboard.down('Control'); - await page.keyboard.press('A'); - await page.keyboard.up('Control'); -} + await page.keyboard.down("Control"); + await page.keyboard.press("A"); + await page.keyboard.up("Control"); +}; export const toggleUserList = async (page: Page) => { - await page.locator("button[data-l10n-id='pad.toolbar.showusers.title']").click() -} + await page + .locator("button[data-l10n-id='pad.toolbar.showusers.title']") + .click(); +}; export const setUserName = async (page: Page, userName: string) => { - await page.waitForSelector('[class="popup popup-show"]') - await page.click("input[data-l10n-id='pad.userlist.entername']"); - await page.keyboard.type(userName); -} - + await page.waitForSelector('[class="popup popup-show"]'); + await page.click("input[data-l10n-id='pad.userlist.entername']"); + await page.keyboard.type(userName); +}; export const showChat = async (page: Page) => { - const chatIcon = page.locator("#chaticon") - const classes = await chatIcon.getAttribute('class') - if (classes && !classes.includes('visible')) return - await chatIcon.click() - await page.waitForFunction(`!document.querySelector('#chaticon').classList.contains('visible')`) -} + const chatIcon = page.locator("#chaticon"); + const classes = await chatIcon.getAttribute("class"); + if (classes && !classes.includes("visible")) return; + await chatIcon.click(); + await page.waitForFunction( + `!document.querySelector('#chaticon').classList.contains('visible')`, + ); +}; export const getCurrentChatMessageCount = async (page: Page) => { - return await page.locator('#chattext').locator('p').count() -} + return await page.locator("#chattext").locator("p").count(); +}; export const getChatUserName = async (page: Page) => { - return await page.locator('#chattext') - .locator('p') - .locator('b') - .innerText() -} + return await page.locator("#chattext").locator("p").locator("b").innerText(); +}; export const getChatMessage = async (page: Page) => { - return (await page.locator('#chattext') - .locator('p') - .textContent({}))! - .split(await getChatTime(page))[1] - -} - + return (await page.locator("#chattext").locator("p").textContent({}))!.split( + await getChatTime(page), + )[1]; +}; export const getChatTime = async (page: Page) => { - return await page.locator('#chattext') - .locator('p') - .locator('.time') - .innerText() -} + return await page + .locator("#chattext") + .locator("p") + .locator(".time") + .innerText(); +}; export const sendChatMessage = async (page: Page, message: string) => { - let currentChatCount = await getCurrentChatMessageCount(page) + let currentChatCount = await getCurrentChatMessageCount(page); - const chatInput = page.locator('#chatinput') - await chatInput.click() - await page.keyboard.type(message) - await page.keyboard.press('Enter') - if(message === "") return - await page.waitForFunction(`document.querySelector('#chattext').querySelectorAll('p').length >${currentChatCount}`) -} + const chatInput = page.locator("#chatinput"); + await chatInput.click(); + await page.keyboard.type(message); + await page.keyboard.press("Enter"); + if (message === "") return; + await page.waitForFunction( + `document.querySelector('#chattext').querySelectorAll('p').length >${currentChatCount}`, + ); +}; -export const isChatBoxShown = async (page: Page):Promise => { - const classes = await page.locator('#chatbox').getAttribute('class') - return classes !==null && classes.includes('visible') -} +export const isChatBoxShown = async (page: Page): Promise => { + const classes = await page.locator("#chatbox").getAttribute("class"); + return classes !== null && classes.includes("visible"); +}; -export const isChatBoxSticky = async (page: Page):Promise => { - const classes = await page.locator('#chatbox').getAttribute('class') - console.log('Chat', classes && classes.includes('stickyChat')) - return classes !==null && classes.includes('stickyChat') -} +export const isChatBoxSticky = async (page: Page): Promise => { + const classes = await page.locator("#chatbox").getAttribute("class"); + console.log("Chat", classes && classes.includes("stickyChat")); + return classes !== null && classes.includes("stickyChat"); +}; export const hideChat = async (page: Page) => { - if(!await isChatBoxShown(page)|| await isChatBoxSticky(page)) return - await page.locator('#titlecross').click() - await page.waitForFunction(`!document.querySelector('#chatbox').classList.contains('stickyChat')`) - -} + if (!(await isChatBoxShown(page)) || (await isChatBoxSticky(page))) return; + await page.locator("#titlecross").click(); + await page.waitForFunction( + `!document.querySelector('#chatbox').classList.contains('stickyChat')`, + ); +}; export const enableStickyChatviaIcon = async (page: Page) => { - if(await isChatBoxSticky(page)) return - await page.locator('#titlesticky').click() - await page.waitForFunction(`document.querySelector('#chatbox').classList.contains('stickyChat')`) -} + if (await isChatBoxSticky(page)) return; + await page.locator("#titlesticky").click(); + await page.waitForFunction( + `document.querySelector('#chatbox').classList.contains('stickyChat')`, + ); +}; export const disableStickyChatviaIcon = async (page: Page) => { - if(!await isChatBoxSticky(page)) return - await page.locator('#titlecross').click() - await page.waitForFunction(`!document.querySelector('#chatbox').classList.contains('stickyChat')`) -} + if (!(await isChatBoxSticky(page))) return; + await page.locator("#titlecross").click(); + await page.waitForFunction( + `!document.querySelector('#chatbox').classList.contains('stickyChat')`, + ); +}; - -export const appendQueryParams = async (page: Page, queryParameters: MapArrayType) => { - const searchParams = new URLSearchParams(page.url().split('?')[1]); - Object.keys(queryParameters).forEach((key) => { - searchParams.append(key, queryParameters[key]); - }); - await page.goto(page.url()+"?"+ searchParams.toString()); - await page.waitForSelector('iframe[name="ace_outer"]'); -} +export const appendQueryParams = async ( + page: Page, + queryParameters: MapArrayType, +) => { + const searchParams = new URLSearchParams(page.url().split("?")[1]); + Object.keys(queryParameters).forEach((key) => { + searchParams.append(key, queryParameters[key]); + }); + await page.goto(page.url() + "?" + searchParams.toString()); + await page.waitForSelector('iframe[name="ace_outer"]'); +}; export const goToNewPad = async (page: Page) => { - // create a new pad before each test run - const padId = "FRONTEND_TESTS"+randomUUID(); - await page.goto('http://localhost:9001/p/'+padId); - await page.waitForSelector('iframe[name="ace_outer"]'); - return padId; -} + // create a new pad before each test run + const padId = "FRONTEND_TESTS" + randomUUID(); + await page.goto("http://localhost:9001/p/" + padId); + await page.waitForSelector('iframe[name="ace_outer"]'); + return padId; +}; export const goToPad = async (page: Page, padId: string) => { - await page.goto('http://localhost:9001/p/'+padId); - await page.waitForSelector('iframe[name="ace_outer"]'); -} - + await page.goto("http://localhost:9001/p/" + padId); + await page.waitForSelector('iframe[name="ace_outer"]'); +}; export const clearPadContent = async (page: Page) => { - const body = await getPadBody(page); - await body.click(); - await page.keyboard.down('Control'); - await page.keyboard.press('A'); - await page.keyboard.up('Control'); - await page.keyboard.press('Delete'); -} + const body = await getPadBody(page); + await body.click(); + await page.keyboard.down("Control"); + await page.keyboard.press("A"); + await page.keyboard.up("Control"); + await page.keyboard.press("Delete"); +}; export const writeToPad = async (page: Page, text: string) => { - const body = await getPadBody(page); - await body.click(); - await page.keyboard.type(text); -} + const body = await getPadBody(page); + await body.click(); + await page.keyboard.type(text); +}; export const clearAuthorship = async (page: Page) => { - await page.locator("button[data-l10n-id='pad.toolbar.clearAuthorship.title']").click() -} + await page + .locator("button[data-l10n-id='pad.toolbar.clearAuthorship.title']") + .click(); +}; export const undoChanges = async (page: Page) => { - await page.keyboard.down('Control'); - await page.keyboard.press('z'); - await page.keyboard.up('Control'); -} + await page.keyboard.down("Control"); + await page.keyboard.press("z"); + await page.keyboard.up("Control"); +}; export const pressUndoButton = async (page: Page) => { - await page.locator('.buttonicon-undo').click() -} + await page.locator(".buttonicon-undo").click(); +}; diff --git a/src/tests/frontend-new/helper/settingsHelper.ts b/src/tests/frontend-new/helper/settingsHelper.ts index 729dd48f6..59a6a2d92 100644 --- a/src/tests/frontend-new/helper/settingsHelper.ts +++ b/src/tests/frontend-new/helper/settingsHelper.ts @@ -1,35 +1,42 @@ -import {Page} from "@playwright/test"; +import { Page } from "@playwright/test"; export const isSettingsShown = async (page: Page) => { - const classes = await page.locator('#settings').getAttribute('class') - return classes && classes.includes('popup-show') -} - + const classes = await page.locator("#settings").getAttribute("class"); + return classes && classes.includes("popup-show"); +}; export const showSettings = async (page: Page) => { - if(await isSettingsShown(page)) return - await page.locator("button[data-l10n-id='pad.toolbar.settings.title']").click() - await page.waitForFunction(`document.querySelector('#settings').classList.contains('popup-show')`) -} + if (await isSettingsShown(page)) return; + await page + .locator("button[data-l10n-id='pad.toolbar.settings.title']") + .click(); + await page.waitForFunction( + `document.querySelector('#settings').classList.contains('popup-show')`, + ); +}; export const hideSettings = async (page: Page) => { - if(!await isSettingsShown(page)) return - await page.locator("button[data-l10n-id='pad.toolbar.settings.title']").click() - await page.waitForFunction(`!document.querySelector('#settings').classList.contains('popup-show')`) -} + if (!(await isSettingsShown(page))) return; + await page + .locator("button[data-l10n-id='pad.toolbar.settings.title']") + .click(); + await page.waitForFunction( + `!document.querySelector('#settings').classList.contains('popup-show')`, + ); +}; export const enableStickyChatviaSettings = async (page: Page) => { - const stickyChat = page.locator('#options-stickychat') - const checked = await stickyChat.isChecked() - if(checked) return - await stickyChat.check({force: true}) - await page.waitForSelector('#options-stickychat:checked') -} + const stickyChat = page.locator("#options-stickychat"); + const checked = await stickyChat.isChecked(); + if (checked) return; + await stickyChat.check({ force: true }); + await page.waitForSelector("#options-stickychat:checked"); +}; export const disableStickyChat = async (page: Page) => { - const stickyChat = page.locator('#options-stickychat') - const checked = await stickyChat.isChecked() - if(!checked) return - await stickyChat.uncheck({force: true}) - await page.waitForSelector('#options-stickychat:not(:checked)') -} + const stickyChat = page.locator("#options-stickychat"); + const checked = await stickyChat.isChecked(); + if (!checked) return; + await stickyChat.uncheck({ force: true }); + await page.waitForSelector("#options-stickychat:not(:checked)"); +}; diff --git a/src/tests/frontend-new/helper/timeslider.ts b/src/tests/frontend-new/helper/timeslider.ts index e193048e0..9c683dfb6 100644 --- a/src/tests/frontend-new/helper/timeslider.ts +++ b/src/tests/frontend-new/helper/timeslider.ts @@ -1,4 +1,4 @@ -import {Page} from "@playwright/test"; +import { Page } from "@playwright/test"; /** * Sets the src-attribute of the main iframe to the timeslider @@ -13,8 +13,11 @@ import {Page} from "@playwright/test"; * @todo for some reason this does only work the first time, you cannot * goto rev 0 and then via the same method to rev 5. Use buttons instead */ -export const gotoTimeslider = async (page: Page, revision: number): Promise => { - let revisionString = Number.isInteger(revision) ? `#${revision}` : ''; - await page.goto(`${page.url()}/timeslider${revisionString}`); - await page.waitForSelector('#timer') +export const gotoTimeslider = async ( + page: Page, + revision: number, +): Promise => { + let revisionString = Number.isInteger(revision) ? `#${revision}` : ""; + await page.goto(`${page.url()}/timeslider${revisionString}`); + await page.waitForSelector("#timer"); }; diff --git a/src/tests/frontend-new/specs/alphabet.spec.ts b/src/tests/frontend-new/specs/alphabet.spec.ts index fcd8f7f9d..1db68cf2e 100644 --- a/src/tests/frontend-new/specs/alphabet.spec.ts +++ b/src/tests/frontend-new/specs/alphabet.spec.ts @@ -1,27 +1,30 @@ -import {expect, Page, test} from "@playwright/test"; -import {clearPadContent, getPadBody, getPadOuter, goToNewPad} from "../helper/padHelper"; +import { expect, Page, test } from "@playwright/test"; +import { + clearPadContent, + getPadBody, + getPadOuter, + goToNewPad, +} from "../helper/padHelper"; -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); -}) - -test.describe('All the alphabet works n stuff', () => { - const expectedString = 'abcdefghijklmnopqrstuvwxyz'; - - test('when you enter any char it appears right', async ({page}) => { - - // get the inner iframe - const innerFrame = await getPadBody(page!); - - await innerFrame.click(); - - // delete possible old content - await clearPadContent(page!); - - - await page.keyboard.type(expectedString); - const text = await innerFrame.locator('div').innerText(); - expect(text).toBe(expectedString); - }); +test.beforeEach(async ({ page }) => { + // create a new pad before each test run + await goToNewPad(page); +}); + +test.describe("All the alphabet works n stuff", () => { + const expectedString = "abcdefghijklmnopqrstuvwxyz"; + + test("when you enter any char it appears right", async ({ page }) => { + // get the inner iframe + const innerFrame = await getPadBody(page!); + + await innerFrame.click(); + + // delete possible old content + await clearPadContent(page!); + + await page.keyboard.type(expectedString); + const text = await innerFrame.locator("div").innerText(); + expect(text).toBe(expectedString); + }); }); diff --git a/src/tests/frontend-new/specs/bold.spec.ts b/src/tests/frontend-new/specs/bold.spec.ts index 6c1769da2..ddb8325bb 100644 --- a/src/tests/frontend-new/specs/bold.spec.ts +++ b/src/tests/frontend-new/specs/bold.spec.ts @@ -1,50 +1,46 @@ -import {expect, test} from "@playwright/test"; -import {randomInt} from "node:crypto"; -import {getPadBody, goToNewPad, selectAllText} from "../helper/padHelper"; +import { expect, test } from "@playwright/test"; +import { randomInt } from "node:crypto"; +import { getPadBody, goToNewPad, selectAllText } from "../helper/padHelper"; import exp from "node:constants"; -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) +test.beforeEach(async ({ page }) => { + await goToNewPad(page); +}); -test.describe('bold button', ()=>{ +test.describe("bold button", () => { + test("makes text bold on click", async ({ page }) => { + // get the inner iframe + const innerFrame = await getPadBody(page); - test('makes text bold on click', async ({page}) => { -// get the inner iframe - const innerFrame = await getPadBody(page); + await innerFrame.click(); + // Select pad text + await selectAllText(page); + await page.keyboard.type("Hi Etherpad"); + await selectAllText(page); - await innerFrame.click() - // Select pad text - await selectAllText(page); - await page.keyboard.type("Hi Etherpad"); - await selectAllText(page); + // click the bold button + await page.locator("button[data-l10n-id='pad.toolbar.bold.title']").click(); - // click the bold button - await page.locator("button[data-l10n-id='pad.toolbar.bold.title']").click(); + // check if the text is bold + expect(await innerFrame.locator("b").innerText()).toBe("Hi Etherpad"); + }); + test("makes text bold on keypress", async ({ page }) => { + // get the inner iframe + const innerFrame = await getPadBody(page); - // check if the text is bold - expect(await innerFrame.locator('b').innerText()).toBe('Hi Etherpad'); - }) + await innerFrame.click(); + // Select pad text + await selectAllText(page); + await page.keyboard.type("Hi Etherpad"); + await selectAllText(page); - test('makes text bold on keypress', async ({page}) => { - // get the inner iframe - const innerFrame = await getPadBody(page); + // Press CTRL + B + await page.keyboard.down("Control"); + await page.keyboard.press("b"); + await page.keyboard.up("Control"); - await innerFrame.click() - // Select pad text - await selectAllText(page); - await page.keyboard.type("Hi Etherpad"); - await selectAllText(page); - - // Press CTRL + B - await page.keyboard.down('Control'); - await page.keyboard.press('b'); - await page.keyboard.up('Control'); - - - // check if the text is bold - expect(await innerFrame.locator('b').innerText()).toBe('Hi Etherpad'); - }) - -}) + // check if the text is bold + expect(await innerFrame.locator("b").innerText()).toBe("Hi Etherpad"); + }); +}); diff --git a/src/tests/frontend-new/specs/change_user_color.spec.ts b/src/tests/frontend-new/specs/change_user_color.spec.ts index bc6b609a1..b27633428 100644 --- a/src/tests/frontend-new/specs/change_user_color.spec.ts +++ b/src/tests/frontend-new/specs/change_user_color.spec.ts @@ -1,103 +1,102 @@ -import {expect, test} from "@playwright/test"; -import {goToNewPad, sendChatMessage, showChat} from "../helper/padHelper"; +import { expect, test } from "@playwright/test"; +import { goToNewPad, sendChatMessage, showChat } from "../helper/padHelper"; -test.beforeEach(async ({page}) => { - await goToNewPad(page); -}) - -test.describe('change user color', function () { - - test('Color picker matches original color and remembers the user color after a refresh', - async function ({page}) { - - // click on the settings button to make settings visible - let $userButton = page.locator('.buttonicon-showusers'); - await $userButton.click() - - let $userSwatch = page.locator('#myswatch'); - await $userSwatch.click() - // Change the color value of the Farbtastic color picker - - const $colorPickerSave = page.locator('#mycolorpickersave'); - let $colorPickerPreview = page.locator('#mycolorpickerpreview'); - - // Same color represented in two different ways - const testColorHash = '#abcdef'; - const testColorRGB = 'rgb(171, 205, 239)'; - - // Check that the color picker matches the automatically assigned random color on the swatch. - // NOTE: This has a tiny chance of creating a false positive for passing in the - // off-chance the randomly assigned color is the same as the test color. - expect(await $colorPickerPreview.getAttribute('style')).toContain(await $userSwatch.getAttribute('style')); - - // The swatch updates as the test color is picked. - await page.evaluate((testRGBColor) => { - document.getElementById('mycolorpickerpreview')!.style.backgroundColor = testRGBColor; - }, testColorRGB - ) - - await $colorPickerSave.click(); - - // give it a second to save the color on the server side - await page.waitForTimeout(1000) - - - // get a new pad, but don't clear the cookies - await goToNewPad(page) - - - // click on the settings button to make settings visible - await $userButton.click() - - await $userSwatch.click() - - - - expect(await $colorPickerPreview.getAttribute('style')).toContain(await $userSwatch.getAttribute('style')); - }); - - test('Own user color is shown when you enter a chat', async function ({page}) { - - const colorOption = page.locator('#options-colorscheck'); - if (!(await colorOption.isChecked())) { - await colorOption.check(); - } - - // click on the settings button to make settings visible - const $userButton = page.locator('.buttonicon-showusers'); - await $userButton.click() - - const $userSwatch = page.locator('#myswatch'); - await $userSwatch.click() - - const $colorPickerSave = page.locator('#mycolorpickersave'); - - // Same color represented in two different ways - const testColorHash = '#abcdef'; - const testColorRGB = 'rgb(171, 205, 239)'; - - // The swatch updates as the test color is picked. - await page.evaluate((testRGBColor) => { - document.getElementById('mycolorpickerpreview')!.style.backgroundColor = testRGBColor; - }, testColorRGB - ) - - - await $colorPickerSave.click(); - // click on the chat button to make chat visible - await showChat(page) - await sendChatMessage(page, 'O hi'); - - // wait until the chat message shows up - const chatP = page.locator('#chattext').locator('p') - const chatText = await chatP.innerText(); - - expect(chatText).toContain('O hi'); - - const color = await chatP.evaluate((el) => { - return window.getComputedStyle(el).getPropertyValue('background-color'); - }, chatText); - - expect(color).toBe(testColorRGB); - }); +test.beforeEach(async ({ page }) => { + await goToNewPad(page); +}); + +test.describe("change user color", function () { + test("Color picker matches original color and remembers the user color after a refresh", async function ({ + page, + }) { + // click on the settings button to make settings visible + let $userButton = page.locator(".buttonicon-showusers"); + await $userButton.click(); + + let $userSwatch = page.locator("#myswatch"); + await $userSwatch.click(); + // Change the color value of the Farbtastic color picker + + const $colorPickerSave = page.locator("#mycolorpickersave"); + let $colorPickerPreview = page.locator("#mycolorpickerpreview"); + + // Same color represented in two different ways + const testColorHash = "#abcdef"; + const testColorRGB = "rgb(171, 205, 239)"; + + // Check that the color picker matches the automatically assigned random color on the swatch. + // NOTE: This has a tiny chance of creating a false positive for passing in the + // off-chance the randomly assigned color is the same as the test color. + expect(await $colorPickerPreview.getAttribute("style")).toContain( + await $userSwatch.getAttribute("style"), + ); + + // The swatch updates as the test color is picked. + await page.evaluate((testRGBColor) => { + document.getElementById("mycolorpickerpreview")!.style.backgroundColor = + testRGBColor; + }, testColorRGB); + + await $colorPickerSave.click(); + + // give it a second to save the color on the server side + await page.waitForTimeout(1000); + + // get a new pad, but don't clear the cookies + await goToNewPad(page); + + // click on the settings button to make settings visible + await $userButton.click(); + + await $userSwatch.click(); + + expect(await $colorPickerPreview.getAttribute("style")).toContain( + await $userSwatch.getAttribute("style"), + ); + }); + + test("Own user color is shown when you enter a chat", async function ({ + page, + }) { + const colorOption = page.locator("#options-colorscheck"); + if (!(await colorOption.isChecked())) { + await colorOption.check(); + } + + // click on the settings button to make settings visible + const $userButton = page.locator(".buttonicon-showusers"); + await $userButton.click(); + + const $userSwatch = page.locator("#myswatch"); + await $userSwatch.click(); + + const $colorPickerSave = page.locator("#mycolorpickersave"); + + // Same color represented in two different ways + const testColorHash = "#abcdef"; + const testColorRGB = "rgb(171, 205, 239)"; + + // The swatch updates as the test color is picked. + await page.evaluate((testRGBColor) => { + document.getElementById("mycolorpickerpreview")!.style.backgroundColor = + testRGBColor; + }, testColorRGB); + + await $colorPickerSave.click(); + // click on the chat button to make chat visible + await showChat(page); + await sendChatMessage(page, "O hi"); + + // wait until the chat message shows up + const chatP = page.locator("#chattext").locator("p"); + const chatText = await chatP.innerText(); + + expect(chatText).toContain("O hi"); + + const color = await chatP.evaluate((el) => { + return window.getComputedStyle(el).getPropertyValue("background-color"); + }, chatText); + + expect(color).toBe(testColorRGB); + }); }); diff --git a/src/tests/frontend-new/specs/change_user_name.spec.ts b/src/tests/frontend-new/specs/change_user_name.spec.ts index bf7ea95c3..88da518d5 100644 --- a/src/tests/frontend-new/specs/change_user_name.spec.ts +++ b/src/tests/frontend-new/specs/change_user_name.spec.ts @@ -1,35 +1,41 @@ -import {expect, test} from "@playwright/test"; -import {randomInt} from "node:crypto"; -import {goToNewPad, sendChatMessage, setUserName, showChat, toggleUserList} from "../helper/padHelper"; +import { expect, test } from "@playwright/test"; +import { randomInt } from "node:crypto"; +import { + goToNewPad, + sendChatMessage, + setUserName, + showChat, + toggleUserList, +} from "../helper/padHelper"; -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); -}) - - -test("Remembers the username after a refresh", async ({page}) => { - await toggleUserList(page); - await setUserName(page,'😃') - await toggleUserList(page) - - await page.reload(); - await toggleUserList(page); - const usernameField = page.locator("input[data-l10n-id='pad.userlist.entername']"); - await expect(usernameField).toHaveValue('😃'); -}) - - -test('Own user name is shown when you enter a chat', async ({page})=> { - const chatMessage = 'O hi'; - - await toggleUserList(page); - await setUserName(page,'😃'); - await toggleUserList(page); - - await showChat(page); - await sendChatMessage(page,chatMessage); - const chatText = await page.locator('#chattext').locator('p').innerText(); - expect(chatText).toContain('😃') - expect(chatText).toContain(chatMessage) +test.beforeEach(async ({ page }) => { + // create a new pad before each test run + await goToNewPad(page); +}); + +test("Remembers the username after a refresh", async ({ page }) => { + await toggleUserList(page); + await setUserName(page, "😃"); + await toggleUserList(page); + + await page.reload(); + await toggleUserList(page); + const usernameField = page.locator( + "input[data-l10n-id='pad.userlist.entername']", + ); + await expect(usernameField).toHaveValue("😃"); +}); + +test("Own user name is shown when you enter a chat", async ({ page }) => { + const chatMessage = "O hi"; + + await toggleUserList(page); + await setUserName(page, "😃"); + await toggleUserList(page); + + await showChat(page); + await sendChatMessage(page, chatMessage); + const chatText = await page.locator("#chattext").locator("p").innerText(); + expect(chatText).toContain("😃"); + expect(chatText).toContain(chatMessage); }); diff --git a/src/tests/frontend-new/specs/chat.spec.ts b/src/tests/frontend-new/specs/chat.spec.ts index 4d4f1bd1c..3a66c3e42 100644 --- a/src/tests/frontend-new/specs/chat.spec.ts +++ b/src/tests/frontend-new/specs/chat.spec.ts @@ -1,116 +1,132 @@ -import {expect, test} from "@playwright/test"; -import {randomInt} from "node:crypto"; +import { expect, test } from "@playwright/test"; +import { randomInt } from "node:crypto"; import { - appendQueryParams, - disableStickyChatviaIcon, - enableStickyChatviaIcon, - getChatMessage, - getChatTime, - getChatUserName, - getCurrentChatMessageCount, goToNewPad, hideChat, isChatBoxShown, isChatBoxSticky, - sendChatMessage, - showChat, + appendQueryParams, + disableStickyChatviaIcon, + enableStickyChatviaIcon, + getChatMessage, + getChatTime, + getChatUserName, + getCurrentChatMessageCount, + goToNewPad, + hideChat, + isChatBoxShown, + isChatBoxSticky, + sendChatMessage, + showChat, } from "../helper/padHelper"; -import {disableStickyChat, enableStickyChatviaSettings, hideSettings, showSettings} from "../helper/settingsHelper"; +import { + disableStickyChat, + enableStickyChatviaSettings, + hideSettings, + showSettings, +} from "../helper/settingsHelper"; - -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - - -test('opens chat, sends a message, makes sure it exists on the page and hides chat', async ({page}) => { - const chatValue = "JohnMcLear" - - // Open chat - await showChat(page); - await sendChatMessage(page, chatValue); - - expect(await getCurrentChatMessageCount(page)).toBe(1); - const username = await getChatUserName(page) - const time = await getChatTime(page) - const chatMessage = await getChatMessage(page) - - expect(username).toBe('unnamed:'); - const regex = new RegExp('^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'); - expect(time).toMatch(regex); - expect(chatMessage).toBe(" "+chatValue); -}) - -test("makes sure that an empty message can't be sent", async function ({page}) { - const chatValue = 'mluto'; - - await showChat(page); - - await sendChatMessage(page,""); - // Send a message - await sendChatMessage(page,chatValue); - - expect(await getCurrentChatMessageCount(page)).toBe(1); - - // check that the received message is not the empty one - const username = await getChatUserName(page) - const time = await getChatTime(page); - const chatMessage = await getChatMessage(page); - - expect(username).toBe('unnamed:'); - const regex = new RegExp('^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'); - expect(time).toMatch(regex); - expect(chatMessage).toBe(" "+chatValue); +test.beforeEach(async ({ page }) => { + await goToNewPad(page); }); -test('makes chat stick to right side of the screen via settings, remove sticky via settings, close it', async ({page}) =>{ - await showSettings(page); +test("opens chat, sends a message, makes sure it exists on the page and hides chat", async ({ + page, +}) => { + const chatValue = "JohnMcLear"; - await enableStickyChatviaSettings(page); - expect(await isChatBoxShown(page)).toBe(true); - expect(await isChatBoxSticky(page)).toBe(true); + // Open chat + await showChat(page); + await sendChatMessage(page, chatValue); - await disableStickyChat(page); - expect(await isChatBoxShown(page)).toBe(true); - expect(await isChatBoxSticky(page)).toBe(false); - await hideSettings(page); - await hideChat(page); - expect(await isChatBoxShown(page)).toBe(false); - expect(await isChatBoxSticky(page)).toBe(false); + expect(await getCurrentChatMessageCount(page)).toBe(1); + const username = await getChatUserName(page); + const time = await getChatTime(page); + const chatMessage = await getChatMessage(page); + + expect(username).toBe("unnamed:"); + const regex = new RegExp("^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$"); + expect(time).toMatch(regex); + expect(chatMessage).toBe(" " + chatValue); }); -test('makes chat stick to right side of the screen via icon on the top right, ' + - 'remove sticky via icon, close it', async function ({page}) { - await showChat(page); +test("makes sure that an empty message can't be sent", async function ({ + page, +}) { + const chatValue = "mluto"; - await enableStickyChatviaIcon(page); - expect(await isChatBoxShown(page)).toBe(true); - expect(await isChatBoxSticky(page)).toBe(true); + await showChat(page); - await disableStickyChatviaIcon(page); - expect(await isChatBoxShown(page)).toBe(true); - expect(await isChatBoxSticky(page)).toBe(false); + await sendChatMessage(page, ""); + // Send a message + await sendChatMessage(page, chatValue); - await hideChat(page); - expect(await isChatBoxSticky(page)).toBe(false); - expect(await isChatBoxShown(page)).toBe(false); + expect(await getCurrentChatMessageCount(page)).toBe(1); + + // check that the received message is not the empty one + const username = await getChatUserName(page); + const time = await getChatTime(page); + const chatMessage = await getChatMessage(page); + + expect(username).toBe("unnamed:"); + const regex = new RegExp("^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$"); + expect(time).toMatch(regex); + expect(chatMessage).toBe(" " + chatValue); }); +test("makes chat stick to right side of the screen via settings, remove sticky via settings, close it", async ({ + page, +}) => { + await showSettings(page); -test('Checks showChat=false URL Parameter hides chat then' + - ' when removed it shows chat', async function ({page}) { + await enableStickyChatviaSettings(page); + expect(await isChatBoxShown(page)).toBe(true); + expect(await isChatBoxSticky(page)).toBe(true); - // get a new pad, but don't clear the cookies - await appendQueryParams(page, { - showChat: 'false' - }); - - const chaticon = page.locator('#chaticon') - - - // chat should be hidden. - expect(await chaticon.isVisible()).toBe(false); - - // get a new pad, but don't clear the cookies - await goToNewPad(page); - const secondChatIcon = page.locator('#chaticon') - - // chat should be visible. - expect(await secondChatIcon.isVisible()).toBe(true) + await disableStickyChat(page); + expect(await isChatBoxShown(page)).toBe(true); + expect(await isChatBoxSticky(page)).toBe(false); + await hideSettings(page); + await hideChat(page); + expect(await isChatBoxShown(page)).toBe(false); + expect(await isChatBoxSticky(page)).toBe(false); }); + +test( + "makes chat stick to right side of the screen via icon on the top right, " + + "remove sticky via icon, close it", + async function ({ page }) { + await showChat(page); + + await enableStickyChatviaIcon(page); + expect(await isChatBoxShown(page)).toBe(true); + expect(await isChatBoxSticky(page)).toBe(true); + + await disableStickyChatviaIcon(page); + expect(await isChatBoxShown(page)).toBe(true); + expect(await isChatBoxSticky(page)).toBe(false); + + await hideChat(page); + expect(await isChatBoxSticky(page)).toBe(false); + expect(await isChatBoxShown(page)).toBe(false); + }, +); + +test( + "Checks showChat=false URL Parameter hides chat then" + + " when removed it shows chat", + async function ({ page }) { + // get a new pad, but don't clear the cookies + await appendQueryParams(page, { + showChat: "false", + }); + + const chaticon = page.locator("#chaticon"); + + // chat should be hidden. + expect(await chaticon.isVisible()).toBe(false); + + // get a new pad, but don't clear the cookies + await goToNewPad(page); + const secondChatIcon = page.locator("#chaticon"); + + // chat should be visible. + expect(await secondChatIcon.isVisible()).toBe(true); + }, +); diff --git a/src/tests/frontend-new/specs/clear_authorship_color.spec.ts b/src/tests/frontend-new/specs/clear_authorship_color.spec.ts index 6a999a57e..2fda4795e 100644 --- a/src/tests/frontend-new/specs/clear_authorship_color.spec.ts +++ b/src/tests/frontend-new/specs/clear_authorship_color.spec.ts @@ -1,87 +1,105 @@ -import {expect, test} from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { - clearAuthorship, - clearPadContent, - getPadBody, - goToNewPad, pressUndoButton, - selectAllText, - undoChanges, - writeToPad + clearAuthorship, + clearPadContent, + getPadBody, + goToNewPad, + pressUndoButton, + selectAllText, + undoChanges, + writeToPad, } from "../helper/padHelper"; -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); -}) - -test('clear authorship color', async ({page}) => { - // get the inner iframe - const innerFrame = await getPadBody(page); - const padText = "Hello" - - // type some text - await clearPadContent(page); - await writeToPad(page, padText); - const retrievedClasses = await innerFrame.locator('div span').nth(0).getAttribute('class') - expect(retrievedClasses).toContain('author'); - - // select the text - await innerFrame.click() - await selectAllText(page); - - await clearAuthorship(page); - // does the first div include an author class? - const firstDivClass = await innerFrame.locator('div').nth(0).getAttribute('class'); - expect(firstDivClass).not.toContain('author'); - const classes = page.locator('div.disconnected') - expect(await classes.isVisible()).toBe(false) -}) - - -test("makes text clear authorship colors and checks it can't be undone", async function ({page}) { - const innnerPad = await getPadBody(page); - const padText = "Hello" - - // type some text - await clearPadContent(page); - await writeToPad(page, padText); - - // get the first text element out of the inner iframe - const firstDivClass = innnerPad.locator('div').nth(0) - const retrievedClasses = await innnerPad.locator('div span').nth(0).getAttribute('class') - expect(retrievedClasses).toContain('author'); - - - await firstDivClass.focus() - await clearAuthorship(page); - expect(await firstDivClass.getAttribute('class')).not.toContain('author'); - - await undoChanges(page); - const changedFirstDiv = innnerPad.locator('div').nth(0) - expect(await changedFirstDiv.getAttribute('class')).not.toContain('author'); - - - await pressUndoButton(page); - const secondChangedFirstDiv = innnerPad.locator('div').nth(0) - expect(await secondChangedFirstDiv.getAttribute('class')).not.toContain('author'); +test.beforeEach(async ({ page }) => { + // create a new pad before each test run + await goToNewPad(page); }); +test("clear authorship color", async ({ page }) => { + // get the inner iframe + const innerFrame = await getPadBody(page); + const padText = "Hello"; + + // type some text + await clearPadContent(page); + await writeToPad(page, padText); + const retrievedClasses = await innerFrame + .locator("div span") + .nth(0) + .getAttribute("class"); + expect(retrievedClasses).toContain("author"); + + // select the text + await innerFrame.click(); + await selectAllText(page); + + await clearAuthorship(page); + // does the first div include an author class? + const firstDivClass = await innerFrame + .locator("div") + .nth(0) + .getAttribute("class"); + expect(firstDivClass).not.toContain("author"); + const classes = page.locator("div.disconnected"); + expect(await classes.isVisible()).toBe(false); +}); + +test("makes text clear authorship colors and checks it can't be undone", async function ({ + page, +}) { + const innnerPad = await getPadBody(page); + const padText = "Hello"; + + // type some text + await clearPadContent(page); + await writeToPad(page, padText); + + // get the first text element out of the inner iframe + const firstDivClass = innnerPad.locator("div").nth(0); + const retrievedClasses = await innnerPad + .locator("div span") + .nth(0) + .getAttribute("class"); + expect(retrievedClasses).toContain("author"); + + await firstDivClass.focus(); + await clearAuthorship(page); + expect(await firstDivClass.getAttribute("class")).not.toContain("author"); + + await undoChanges(page); + const changedFirstDiv = innnerPad.locator("div").nth(0); + expect(await changedFirstDiv.getAttribute("class")).not.toContain("author"); + + await pressUndoButton(page); + const secondChangedFirstDiv = innnerPad.locator("div").nth(0); + expect(await secondChangedFirstDiv.getAttribute("class")).not.toContain( + "author", + ); +}); // Test for https://github.com/ether/etherpad-lite/issues/5128 -test('clears authorship when first line has line attributes', async function ({page}) { - // Make sure there is text with author info. The first line must have a line attribute. - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page); - await writeToPad(page,'Hello') - await page.locator('.buttonicon-insertunorderedlist').click(); - const retrievedClasses = await padBody.locator('div span').nth(0).getAttribute('class') - expect(retrievedClasses).toContain('author'); - await padBody.click() - await selectAllText(page); - await clearAuthorship(page); - const retrievedClasses2 = await padBody.locator('div span').nth(0).getAttribute('class') - expect(retrievedClasses2).not.toContain('author'); +test("clears authorship when first line has line attributes", async function ({ + page, +}) { + // Make sure there is text with author info. The first line must have a line attribute. + const padBody = await getPadBody(page); + await padBody.click(); + await clearPadContent(page); + await writeToPad(page, "Hello"); + await page.locator(".buttonicon-insertunorderedlist").click(); + const retrievedClasses = await padBody + .locator("div span") + .nth(0) + .getAttribute("class"); + expect(retrievedClasses).toContain("author"); + await padBody.click(); + await selectAllText(page); + await clearAuthorship(page); + const retrievedClasses2 = await padBody + .locator("div span") + .nth(0) + .getAttribute("class"); + expect(retrievedClasses2).not.toContain("author"); - expect(await page.locator('[class*="author-"]').count()).toBe(0) + expect(await page.locator('[class*="author-"]').count()).toBe(0); }); diff --git a/src/tests/frontend-new/specs/collab_client.spec.ts b/src/tests/frontend-new/specs/collab_client.spec.ts index 5cc9c1ec3..b8214c1e6 100644 --- a/src/tests/frontend-new/specs/collab_client.spec.ts +++ b/src/tests/frontend-new/specs/collab_client.spec.ts @@ -1,94 +1,101 @@ -import {clearPadContent, getPadBody, goToNewPad, goToPad, writeToPad} from "../helper/padHelper"; -import {expect, Page, test} from "@playwright/test"; +import { + clearPadContent, + getPadBody, + goToNewPad, + goToPad, + writeToPad, +} from "../helper/padHelper"; +import { expect, Page, test } from "@playwright/test"; let padId = ""; -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - padId = await goToNewPad(page); - const body = await getPadBody(page); - await body.click(); - await clearPadContent(page); - await writeToPad(page, "Hello World"); - await page.keyboard.press('Enter'); - await writeToPad(page, "Hello World"); - await page.keyboard.press('Enter'); - await writeToPad(page, "Hello World"); - await page.keyboard.press('Enter'); - await writeToPad(page, "Hello World"); - await page.keyboard.press('Enter'); - await writeToPad(page, "Hello World"); - await page.keyboard.press('Enter'); -}) - -test.describe('Messages in the COLLABROOM', function () { - const user1Text = 'text created by user 1'; - const user2Text = 'text created by user 2'; - - const replaceLineText = async (lineNumber: number, newText: string, page: Page) => { - const body = await getPadBody(page) - - const div = body.locator('div').nth(lineNumber) - - // simulate key presses to delete content - await div.locator('span').selectText() // select all - await page.keyboard.press('Backspace') // clear the first line - await page.keyboard.type(newText) // insert the string - }; - - test('bug #4978 regression test', async function ({browser}) { - // The bug was triggered by receiving a change from another user while simultaneously composing - // a character and waiting for an acknowledgement of a previously sent change. - - // User 1 - const context1 = await browser.newContext(); - const page1 = await context1.newPage(); - await goToPad(page1, padId) - const body1 = await getPadBody(page1) - // Perform actions as User 1... - - // User 2 - const context2 = await browser.newContext(); - const page2 = await context2.newPage(); - await goToPad(page2, padId) - const body2 = await getPadBody(page1) - - await replaceLineText(0, user1Text,page1); - - const text = await body2.locator('div').nth(0).textContent() - const res = text === user1Text - expect(res).toBe(true) - - // User 1 starts a character composition. - - - await replaceLineText(1, user2Text, page2) - - await expect(body1.locator('div').nth(1)).toHaveText(user2Text) - - - // Users 1 and 2 make some more changes. - await replaceLineText(3, user2Text, page2); - - await expect(body1.locator('div').nth(3)).toHaveText(user2Text) - - await replaceLineText(2, user1Text, page1); - await expect(body2.locator('div').nth(2)).toHaveText(user1Text) - - // All changes should appear in both views. - const expectedLines = [ - user1Text, - user2Text, - user1Text, - user2Text, - ]; - - for (let i=0;i { + // create a new pad before each test run + padId = await goToNewPad(page); + const body = await getPadBody(page); + await body.click(); + await clearPadContent(page); + await writeToPad(page, "Hello World"); + await page.keyboard.press("Enter"); + await writeToPad(page, "Hello World"); + await page.keyboard.press("Enter"); + await writeToPad(page, "Hello World"); + await page.keyboard.press("Enter"); + await writeToPad(page, "Hello World"); + await page.keyboard.press("Enter"); + await writeToPad(page, "Hello World"); + await page.keyboard.press("Enter"); +}); + +test.describe("Messages in the COLLABROOM", function () { + const user1Text = "text created by user 1"; + const user2Text = "text created by user 2"; + + const replaceLineText = async ( + lineNumber: number, + newText: string, + page: Page, + ) => { + const body = await getPadBody(page); + + const div = body.locator("div").nth(lineNumber); + + // simulate key presses to delete content + await div.locator("span").selectText(); // select all + await page.keyboard.press("Backspace"); // clear the first line + await page.keyboard.type(newText); // insert the string + }; + + test("bug #4978 regression test", async function ({ browser }) { + // The bug was triggered by receiving a change from another user while simultaneously composing + // a character and waiting for an acknowledgement of a previously sent change. + + // User 1 + const context1 = await browser.newContext(); + const page1 = await context1.newPage(); + await goToPad(page1, padId); + const body1 = await getPadBody(page1); + // Perform actions as User 1... + + // User 2 + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + await goToPad(page2, padId); + const body2 = await getPadBody(page1); + + await replaceLineText(0, user1Text, page1); + + const text = await body2.locator("div").nth(0).textContent(); + const res = text === user1Text; + expect(res).toBe(true); + + // User 1 starts a character composition. + + await replaceLineText(1, user2Text, page2); + + await expect(body1.locator("div").nth(1)).toHaveText(user2Text); + + // Users 1 and 2 make some more changes. + await replaceLineText(3, user2Text, page2); + + await expect(body1.locator("div").nth(3)).toHaveText(user2Text); + + await replaceLineText(2, user1Text, page1); + await expect(body2.locator("div").nth(2)).toHaveText(user1Text); + + // All changes should appear in both views. + const expectedLines = [user1Text, user2Text, user1Text, user2Text]; + + for (let i = 0; i < expectedLines.length; i++) { + expect(await body1.locator("div").nth(i).textContent()).toBe( + expectedLines[i], + ); + } + + for (let i = 0; i < expectedLines.length; i++) { + expect(await body2.locator("div").nth(i).textContent()).toBe( + expectedLines[i], + ); + } + }); }); diff --git a/src/tests/frontend-new/specs/delete.spec.ts b/src/tests/frontend-new/specs/delete.spec.ts index 26eb3fbdb..5d2c24c49 100644 --- a/src/tests/frontend-new/specs/delete.spec.ts +++ b/src/tests/frontend-new/specs/delete.spec.ts @@ -1,22 +1,21 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad} from "../helper/padHelper"; +import { expect, test } from "@playwright/test"; +import { clearPadContent, getPadBody, goToNewPad } from "../helper/padHelper"; -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); -}) +test.beforeEach(async ({ page }) => { + // create a new pad before each test run + await goToNewPad(page); +}); - -test('delete keystroke', async ({page}) => { - const padText = "Hello World this is a test" - const body = await getPadBody(page) - await body.click() - await clearPadContent(page) - await page.keyboard.type(padText) - // Navigate to the end of the text - await page.keyboard.press('End'); - // Delete the last character - await page.keyboard.press('Backspace'); - const text = await body.locator('div').innerText(); - expect(text).toBe(padText.slice(0, -1)); -}) +test("delete keystroke", async ({ page }) => { + const padText = "Hello World this is a test"; + const body = await getPadBody(page); + await body.click(); + await clearPadContent(page); + await page.keyboard.type(padText); + // Navigate to the end of the text + await page.keyboard.press("End"); + // Delete the last character + await page.keyboard.press("Backspace"); + const text = await body.locator("div").innerText(); + expect(text).toBe(padText.slice(0, -1)); +}); diff --git a/src/tests/frontend-new/specs/embed_value.spec.ts b/src/tests/frontend-new/specs/embed_value.spec.ts index a65276cc9..23a1761e6 100644 --- a/src/tests/frontend-new/specs/embed_value.spec.ts +++ b/src/tests/frontend-new/specs/embed_value.spec.ts @@ -1,136 +1,136 @@ -import {expect, Page, test} from "@playwright/test"; -import {goToNewPad} from "../helper/padHelper"; +import { expect, Page, test } from "@playwright/test"; +import { goToNewPad } from "../helper/padHelper"; -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); -}) +test.beforeEach(async ({ page }) => { + // create a new pad before each test run + await goToNewPad(page); +}); -test.describe('embed links', function () { - const objectify = function (str: string) { - const hash = {}; - const parts = str.split('&'); - for (let i = 0; i < parts.length; i++) { - const keyValue = parts[i].split('='); - // @ts-ignore - hash[keyValue[0]] = keyValue[1]; - } - return hash; - }; +test.describe("embed links", function () { + const objectify = function (str: string) { + const hash = {}; + const parts = str.split("&"); + for (let i = 0; i < parts.length; i++) { + const keyValue = parts[i].split("="); + // @ts-ignore + hash[keyValue[0]] = keyValue[1]; + } + return hash; + }; - const checkiFrameCode = async function (embedCode: string, readonly: boolean, page: Page) { - // turn the code into an html element + const checkiFrameCode = async function ( + embedCode: string, + readonly: boolean, + page: Page, + ) { + // turn the code into an html element - await page.setContent(embedCode, {waitUntil: 'load'}) - const locator = page.locator('body').locator('iframe').last() + await page.setContent(embedCode, { waitUntil: "load" }); + const locator = page.locator("body").locator("iframe").last(); + // read and check the frame attributes + const width = await locator.getAttribute("width"); + const height = await locator.getAttribute("height"); + const name = await locator.getAttribute("name"); + expect(width).toBe("100%"); + expect(height).toBe("600"); + expect(name).toBe(readonly ? "embed_readonly" : "embed_readwrite"); - // read and check the frame attributes - const width = await locator.getAttribute('width'); - const height = await locator.getAttribute('height'); - const name = await locator.getAttribute('name'); - expect(width).toBe('100%'); - expect(height).toBe('600'); - expect(name).toBe(readonly ? 'embed_readonly' : 'embed_readwrite'); + // parse the url + const src = (await locator.getAttribute("src"))!; + const questionMark = src.indexOf("?"); + const url = src.substring(0, questionMark); + const paramsStr = src.substring(questionMark + 1); + const params = objectify(paramsStr); - // parse the url - const src = (await locator.getAttribute('src'))!; - const questionMark = src.indexOf('?'); - const url = src.substring(0, questionMark); - const paramsStr = src.substring(questionMark + 1); - const params = objectify(paramsStr); + const expectedParams = { + showControls: "true", + showChat: "true", + showLineNumbers: "true", + useMonospaceFont: "false", + }; - const expectedParams = { - showControls: 'true', - showChat: 'true', - showLineNumbers: 'true', - useMonospaceFont: 'false', - }; + // check the url + if (readonly) { + expect(url.indexOf("r.") > 0).toBe(true); + } else { + expect(url).toBe(await page.evaluate(() => window.location.href)); + } - // check the url - if (readonly) { - expect(url.indexOf('r.') > 0).toBe(true); - } else { - expect(url).toBe(await page.evaluate(() => window.location.href)); - } + // check if all parts of the url are like expected + expect(params).toEqual(expectedParams); + }; - // check if all parts of the url are like expected - expect(params).toEqual(expectedParams); - }; + test.describe("read and write", function () { + test.beforeEach(async ({ page }) => { + // create a new pad before each test run + await goToNewPad(page); + }); + test("the share link is the actual pad url", async function ({ page }) { + const shareButton = page.locator(".buttonicon-embed"); + // open share dropdown + await shareButton.click(); - test.describe('read and write', function () { - test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); - }) - test('the share link is the actual pad url', async function ({page}) { + // get the link of the share field + the actual pad url and compare them + const shareLink = await page.locator("#linkinput").inputValue(); + const padURL = page.url(); + expect(shareLink).toBe(padURL); + }); - const shareButton = page.locator('.buttonicon-embed') - // open share dropdown - await shareButton.click() + test("is an iframe with the correct url parameters and correct size", async function ({ + page, + }) { + const shareButton = page.locator(".buttonicon-embed"); + await shareButton.click(); - // get the link of the share field + the actual pad url and compare them - const shareLink = await page.locator('#linkinput').inputValue() - const padURL = page.url(); - expect(shareLink).toBe(padURL); - }); + // get the link of the share field + the actual pad url and compare them + const embedCode = await page.locator("#embedinput").inputValue(); - test('is an iframe with the correct url parameters and correct size', async function ({page}) { + await checkiFrameCode(embedCode, false, page); + }); + }); - const shareButton = page.locator('.buttonicon-embed') - await shareButton.click() + test.describe("when read only option is set", function () { + test.beforeEach(async ({ page }) => { + // create a new pad before each test run + await goToNewPad(page); + }); - // get the link of the share field + the actual pad url and compare them - const embedCode = await page.locator('#embedinput').inputValue() + test("the share link shows a read only url", async function ({ page }) { + // open share dropdown + const shareButton = page.locator(".buttonicon-embed"); + await shareButton.click(); + const readonlyCheckbox = page.locator("#readonlyinput"); + await readonlyCheckbox.click({ + force: true, + }); + await page.waitForSelector("#readonlyinput:checked"); + // get the link of the share field + the actual pad url and compare them + const shareLink = await page.locator("#linkinput").inputValue(); + const containsReadOnlyLink = shareLink.indexOf("r.") > 0; + expect(containsReadOnlyLink).toBe(true); + }); - await checkiFrameCode(embedCode, false, page); - }); - }); + test("the embed as iframe code is an iframe with the correct url parameters and correct size", async function ({ + page, + }) { + // open share dropdown + const shareButton = page.locator(".buttonicon-embed"); + await shareButton.click(); - test.describe('when read only option is set', function () { - test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); - }) + // check read only checkbox, a bit hacky + const readonlyCheckbox = page.locator("#readonlyinput"); + await readonlyCheckbox.click({ + force: true, + }); - test('the share link shows a read only url', async function ({page}) { + await page.waitForSelector("#readonlyinput:checked"); - // open share dropdown - const shareButton = page.locator('.buttonicon-embed') - await shareButton.click() - const readonlyCheckbox = page.locator('#readonlyinput') - await readonlyCheckbox.click({ - force: true - }) - await page.waitForSelector('#readonlyinput:checked') + // get the link of the share field + the actual pad url and compare them + const embedCode = await page.locator("#embedinput").inputValue(); - // get the link of the share field + the actual pad url and compare them - const shareLink = await page.locator('#linkinput').inputValue() - const containsReadOnlyLink = shareLink.indexOf('r.') > 0; - expect(containsReadOnlyLink).toBe(true); - }); - - test('the embed as iframe code is an iframe with the correct url parameters and correct size', async function ({page}) { - - - // open share dropdown - const shareButton = page.locator('.buttonicon-embed') - await shareButton.click() - - // check read only checkbox, a bit hacky - const readonlyCheckbox = page.locator('#readonlyinput') - await readonlyCheckbox.click({ - force: true - }) - - await page.waitForSelector('#readonlyinput:checked') - - - // get the link of the share field + the actual pad url and compare them - const embedCode = await page.locator('#embedinput').inputValue() - - await checkiFrameCode(embedCode, true, page); - }); - }) -}) + await checkiFrameCode(embedCode, true, page); + }); + }); +}); diff --git a/src/tests/frontend-new/specs/enter.spec.ts b/src/tests/frontend-new/specs/enter.spec.ts index fd9c732c2..fd68ff0f6 100644 --- a/src/tests/frontend-new/specs/enter.spec.ts +++ b/src/tests/frontend-new/specs/enter.spec.ts @@ -1,63 +1,70 @@ -'use strict'; -import {expect, test} from "@playwright/test"; -import {getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; +"use strict"; +import { expect, test } from "@playwright/test"; +import { getPadBody, goToNewPad, writeToPad } from "../helper/padHelper"; -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - -test.describe('enter keystroke', function () { - - test('creates a new line & puts cursor onto a new line', async function ({page}) { - const padBody = await getPadBody(page); - - // get the first text element out of the inner iframe - const firstTextElement = padBody.locator('div').nth(0) - - // get the original string value minus the last char - const originalTextValue = await firstTextElement.textContent(); - - // simulate key presses to enter content - await firstTextElement.click() - await page.keyboard.press('Home'); - await page.keyboard.press('Enter'); - - const updatedFirstElement = padBody.locator('div').nth(0) - expect(await updatedFirstElement.textContent()).toBe('') - - const newSecondLine = padBody.locator('div').nth(1); - // expect the second line to be the same as the original first line. - expect(await newSecondLine.textContent()).toBe(originalTextValue); - }); - - test('enter is always visible after event', async function ({page}) { - const padBody = await getPadBody(page); - const originalLength = await padBody.locator('div').count(); - let lastLine = padBody.locator('div').last(); - - // simulate key presses to enter content - let i = 0; - const numberOfLines = 15; - while (i < numberOfLines) { - lastLine = padBody.locator('div').last(); - await lastLine.focus(); - await page.keyboard.press('End'); - await page.keyboard.press('Enter'); - - // check we can see the caret.. - i++; - } - - expect(await padBody.locator('div').count()).toBe(numberOfLines + originalLength); - - // is edited line fully visible? - const lastDiv = padBody.locator('div').last() - const lastDivOffset = await lastDiv.boundingBox(); - const bottomOfLastLine = lastDivOffset!.y + lastDivOffset!.height; - const scrolledWindow = page.frames()[0]; - const windowOffset = await scrolledWindow.evaluate(() => window.pageYOffset); - const windowHeight = await scrolledWindow.evaluate(() => window.innerHeight); - - expect(windowOffset + windowHeight).toBeGreaterThan(bottomOfLastLine); - }); +test.beforeEach(async ({ page }) => { + await goToNewPad(page); +}); + +test.describe("enter keystroke", function () { + test("creates a new line & puts cursor onto a new line", async function ({ + page, + }) { + const padBody = await getPadBody(page); + + // get the first text element out of the inner iframe + const firstTextElement = padBody.locator("div").nth(0); + + // get the original string value minus the last char + const originalTextValue = await firstTextElement.textContent(); + + // simulate key presses to enter content + await firstTextElement.click(); + await page.keyboard.press("Home"); + await page.keyboard.press("Enter"); + + const updatedFirstElement = padBody.locator("div").nth(0); + expect(await updatedFirstElement.textContent()).toBe(""); + + const newSecondLine = padBody.locator("div").nth(1); + // expect the second line to be the same as the original first line. + expect(await newSecondLine.textContent()).toBe(originalTextValue); + }); + + test("enter is always visible after event", async function ({ page }) { + const padBody = await getPadBody(page); + const originalLength = await padBody.locator("div").count(); + let lastLine = padBody.locator("div").last(); + + // simulate key presses to enter content + let i = 0; + const numberOfLines = 15; + while (i < numberOfLines) { + lastLine = padBody.locator("div").last(); + await lastLine.focus(); + await page.keyboard.press("End"); + await page.keyboard.press("Enter"); + + // check we can see the caret.. + i++; + } + + expect(await padBody.locator("div").count()).toBe( + numberOfLines + originalLength, + ); + + // is edited line fully visible? + const lastDiv = padBody.locator("div").last(); + const lastDivOffset = await lastDiv.boundingBox(); + const bottomOfLastLine = lastDivOffset!.y + lastDivOffset!.height; + const scrolledWindow = page.frames()[0]; + const windowOffset = await scrolledWindow.evaluate( + () => window.pageYOffset, + ); + const windowHeight = await scrolledWindow.evaluate( + () => window.innerHeight, + ); + + expect(windowOffset + windowHeight).toBeGreaterThan(bottomOfLastLine); + }); }); diff --git a/src/tests/frontend-new/specs/font_type.spec.ts b/src/tests/frontend-new/specs/font_type.spec.ts index a2772da99..e3e08279c 100644 --- a/src/tests/frontend-new/specs/font_type.spec.ts +++ b/src/tests/frontend-new/specs/font_type.spec.ts @@ -1,39 +1,39 @@ -import {expect, test} from "@playwright/test"; -import {getPadBody, goToNewPad} from "../helper/padHelper"; -import {showSettings} from "../helper/settingsHelper"; +import { expect, test } from "@playwright/test"; +import { getPadBody, goToNewPad } from "../helper/padHelper"; +import { showSettings } from "../helper/settingsHelper"; -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); -}) - - -test.describe('font select', function () { - // create a new pad before each test run - - test('makes text RobotoMono', async function ({page}) { - // click on the settings button to make settings visible - await showSettings(page); - - // get the font menu and RobotoMono option - const viewFontMenu = page.locator('#viewfontmenu'); - - // select RobotoMono and fire change event - // $RobotoMonooption.attr('selected','selected'); - // commenting out above will break safari test - const dropdown = page.locator('.dropdowns-container .dropdown-line .current').nth(0) - await dropdown.click() - await page.locator('li:text("RobotoMono")').click() - - await viewFontMenu.dispatchEvent('change'); - const padBody = await getPadBody(page) - const color = await padBody.evaluate((e) => { - return window.getComputedStyle(e).getPropertyValue("font-family") - }) - - - // check if font changed to RobotoMono - const containsStr = color.toLowerCase().indexOf('robotomono'); - expect(containsStr).not.toBe(-1); - }); +test.beforeEach(async ({ page }) => { + // create a new pad before each test run + await goToNewPad(page); +}); + +test.describe("font select", function () { + // create a new pad before each test run + + test("makes text RobotoMono", async function ({ page }) { + // click on the settings button to make settings visible + await showSettings(page); + + // get the font menu and RobotoMono option + const viewFontMenu = page.locator("#viewfontmenu"); + + // select RobotoMono and fire change event + // $RobotoMonooption.attr('selected','selected'); + // commenting out above will break safari test + const dropdown = page + .locator(".dropdowns-container .dropdown-line .current") + .nth(0); + await dropdown.click(); + await page.locator('li:text("RobotoMono")').click(); + + await viewFontMenu.dispatchEvent("change"); + const padBody = await getPadBody(page); + const color = await padBody.evaluate((e) => { + return window.getComputedStyle(e).getPropertyValue("font-family"); + }); + + // check if font changed to RobotoMono + const containsStr = color.toLowerCase().indexOf("robotomono"); + expect(containsStr).not.toBe(-1); + }); }); diff --git a/src/tests/frontend-new/specs/indentation.spec.ts b/src/tests/frontend-new/specs/indentation.spec.ts index 3e94dbad3..e99876b04 100644 --- a/src/tests/frontend-new/specs/indentation.spec.ts +++ b/src/tests/frontend-new/specs/indentation.spec.ts @@ -1,241 +1,250 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - -test.describe('indentation button', function () { - test('indent text with keypress', async function ({page}) { - const padBody = await getPadBody(page); - - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); - - // select this text element - await $firstTextElement.selectText() - - await page.keyboard.press('Tab'); - - const uls = padBody.locator('div').first().locator('ul li') - await expect(uls).toHaveCount(1); - }); - - test('indent text with button', async function ({page}) { - const padBody = await getPadBody(page); - await page.locator('.buttonicon-indent').click() - - const uls = padBody.locator('div').first().locator('ul') - await expect(uls).toHaveCount(1); - }); - - - test('keeps the indent on enter for the new line', async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - await page.locator('.buttonicon-indent').click() - - // type a bit, make a line break and type again - await padBody.locator('div').first().focus() - await page.keyboard.type('line 1') - await page.keyboard.press('Enter'); - await page.keyboard.type('line 2') - await page.keyboard.press('Enter'); - - const $newSecondLine = padBody.locator('div span').nth(1) - - const hasULElement = padBody.locator('ul li') - - await expect(hasULElement).toHaveCount(3); - await expect($newSecondLine).toHaveText('line 2'); - }); - - - test('indents text with spaces on enter if previous line ends ' + - "with ':', '[', '(', or '{'", async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - // type a bit, make a line break and type again - const $firstTextElement = padBody.locator('div').first(); - await writeToPad(page, "line with ':'"); - await page.keyboard.press('Enter'); - await writeToPad(page, "line with '['"); - await page.keyboard.press('Enter'); - await writeToPad(page, "line with '('"); - await page.keyboard.press('Enter'); - await writeToPad(page, "line with '{{}'"); - - await expect(padBody.locator('div').nth(3)).toHaveText("line with '{{}'"); - - // we validate bottom to top for easier implementation - - - // curly braces - const $lineWithCurlyBraces = padBody.locator('div').nth(3) - await $lineWithCurlyBraces.click(); - await page.keyboard.press('End'); - await page.keyboard.type('{{'); - - // cannot use sendkeys('{enter}') here, browser does not read the command properly - await page.keyboard.press('Enter'); - - expect(await padBody.locator('div').nth(4).textContent()).toMatch(/\s{4}/); // tab === 4 spaces - - - - // parenthesis - const $lineWithParenthesis = padBody.locator('div').nth(2) - await $lineWithParenthesis.click(); - await page.keyboard.press('End'); - await page.keyboard.type('('); - await page.keyboard.press('Enter'); - const $lineAfterParenthesis = padBody.locator('div').nth(3) - expect(await $lineAfterParenthesis.textContent()).toMatch(/\s{4}/); - - // bracket - const $lineWithBracket = padBody.locator('div').nth(1) - await $lineWithBracket.click(); - await page.keyboard.press('End'); - await page.keyboard.type('['); - await page.keyboard.press('Enter'); - const $lineAfterBracket = padBody.locator('div').nth(2); - expect(await $lineAfterBracket.textContent()).toMatch(/\s{4}/); - - // colon - const $lineWithColon = padBody.locator('div').first(); - await $lineWithColon.click(); - await page.keyboard.press('End'); - await page.keyboard.type(':'); - await page.keyboard.press('Enter'); - const $lineAfterColon = padBody.locator('div').nth(1); - expect(await $lineAfterColon.textContent()).toMatch(/\s{4}/); - }); - - test('appends indentation to the indent of previous line if previous line ends ' + - "with ':', '[', '(', or '{'", async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - // type a bit, make a line break and type again - await writeToPad(page, " line with some indentation and ':'") - await page.keyboard.press('Enter'); - await writeToPad(page, "line 2") - - const $lineWithColon = padBody.locator('div').first(); - await $lineWithColon.click(); - await page.keyboard.press('End'); - await page.keyboard.type(':'); - await page.keyboard.press('Enter'); - - const $lineAfterColon = padBody.locator('div').nth(1); - // previous line indentation + regular tab (4 spaces) - expect(await $lineAfterColon.textContent()).toMatch(/\s{6}/); - }); - - test("issue #2772 shows '*' when multiple indented lines " + - ' receive a style and are outdented', async function ({page}) { - - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - const inner = padBody.locator('div').first(); - // make sure pad has more than one line - await inner.click() - await page.keyboard.type('First'); - await page.keyboard.press('Enter'); - await page.keyboard.type('Second'); - - - // indent first 2 lines - await padBody.locator('div').nth(0).selectText(); - await page.locator('.buttonicon-indent').click() - - await padBody.locator('div').nth(1).selectText(); - await page.locator('.buttonicon-indent').click() - - - await expect(padBody.locator('ul li')).toHaveCount(2); - - - // apply bold - await padBody.locator('div').nth(0).selectText(); - await page.locator('.buttonicon-bold').click() - - await padBody.locator('div').nth(1).selectText(); - await page.locator('.buttonicon-bold').click() - - await expect(padBody.locator('div b')).toHaveCount(2); - - // outdent first 2 lines - await padBody.locator('div').nth(0).selectText(); - await page.locator('.buttonicon-outdent').click() - - await padBody.locator('div').nth(1).selectText(); - await page.locator('.buttonicon-outdent').click() - - await expect(padBody.locator('ul li')).toHaveCount(0); - - // check if '*' is displayed - const secondLine = padBody.locator('div').nth(1); - await expect(secondLine).toHaveText('Second'); - }); - - test('makes text indented and outdented', async function ({page}) { - // get the inner iframe - - const padBody = await getPadBody(page); - - // get the first text element out of the inner iframe - let firstTextElement = padBody.locator('div').first(); - - // select this text element - await firstTextElement.selectText() - - // get the indentation button and click it - await page.locator('.buttonicon-indent').click() - - let newFirstTextElement = padBody.locator('div').first(); - - // is there a list-indent class element now? - await expect(newFirstTextElement.locator('ul')).toHaveCount(1); - - await expect(newFirstTextElement.locator('li')).toHaveCount(1); - - // indent again - await page.locator('.buttonicon-indent').click() - - newFirstTextElement = padBody.locator('div').first(); - - - // is there a list-indent class element now? - const ulList = newFirstTextElement.locator('ul').first() - await expect(ulList).toHaveCount(1); - // expect it to be part of a list - expect(await ulList.getAttribute('class')).toBe('list-indent2'); - - // make sure the text hasn't changed - expect(await newFirstTextElement.textContent()).toBe(await firstTextElement.textContent()); - - - // test outdent - - // get the unindentation button and click it twice - newFirstTextElement = padBody.locator('div').first(); - await newFirstTextElement.selectText() - await page.locator('.buttonicon-outdent').click() - await page.locator('.buttonicon-outdent').click() - - newFirstTextElement = padBody.locator('div').first(); - - // is there a list-indent class element now? - await expect(newFirstTextElement.locator('ul')).toHaveCount(0); - - // make sure the text hasn't changed - expect(await newFirstTextElement.textContent()).toEqual(await firstTextElement.textContent()); - }); +import { expect, test } from "@playwright/test"; +import { + clearPadContent, + getPadBody, + goToNewPad, + writeToPad, +} from "../helper/padHelper"; + +test.beforeEach(async ({ page }) => { + await goToNewPad(page); +}); + +test.describe("indentation button", function () { + test("indent text with keypress", async function ({ page }) { + const padBody = await getPadBody(page); + + // get the first text element out of the inner iframe + const $firstTextElement = padBody.locator("div").first(); + + // select this text element + await $firstTextElement.selectText(); + + await page.keyboard.press("Tab"); + + const uls = padBody.locator("div").first().locator("ul li"); + await expect(uls).toHaveCount(1); + }); + + test("indent text with button", async function ({ page }) { + const padBody = await getPadBody(page); + await page.locator(".buttonicon-indent").click(); + + const uls = padBody.locator("div").first().locator("ul"); + await expect(uls).toHaveCount(1); + }); + + test("keeps the indent on enter for the new line", async function ({ page }) { + const padBody = await getPadBody(page); + await padBody.click(); + await clearPadContent(page); + + await page.locator(".buttonicon-indent").click(); + + // type a bit, make a line break and type again + await padBody.locator("div").first().focus(); + await page.keyboard.type("line 1"); + await page.keyboard.press("Enter"); + await page.keyboard.type("line 2"); + await page.keyboard.press("Enter"); + + const $newSecondLine = padBody.locator("div span").nth(1); + + const hasULElement = padBody.locator("ul li"); + + await expect(hasULElement).toHaveCount(3); + await expect($newSecondLine).toHaveText("line 2"); + }); + + test( + "indents text with spaces on enter if previous line ends " + + "with ':', '[', '(', or '{'", + async function ({ page }) { + const padBody = await getPadBody(page); + await padBody.click(); + await clearPadContent(page); + // type a bit, make a line break and type again + const $firstTextElement = padBody.locator("div").first(); + await writeToPad(page, "line with ':'"); + await page.keyboard.press("Enter"); + await writeToPad(page, "line with '['"); + await page.keyboard.press("Enter"); + await writeToPad(page, "line with '('"); + await page.keyboard.press("Enter"); + await writeToPad(page, "line with '{{}'"); + + await expect(padBody.locator("div").nth(3)).toHaveText("line with '{{}'"); + + // we validate bottom to top for easier implementation + + // curly braces + const $lineWithCurlyBraces = padBody.locator("div").nth(3); + await $lineWithCurlyBraces.click(); + await page.keyboard.press("End"); + await page.keyboard.type("{{"); + + // cannot use sendkeys('{enter}') here, browser does not read the command properly + await page.keyboard.press("Enter"); + + expect(await padBody.locator("div").nth(4).textContent()).toMatch( + /\s{4}/, + ); // tab === 4 spaces + + // parenthesis + const $lineWithParenthesis = padBody.locator("div").nth(2); + await $lineWithParenthesis.click(); + await page.keyboard.press("End"); + await page.keyboard.type("("); + await page.keyboard.press("Enter"); + const $lineAfterParenthesis = padBody.locator("div").nth(3); + expect(await $lineAfterParenthesis.textContent()).toMatch(/\s{4}/); + + // bracket + const $lineWithBracket = padBody.locator("div").nth(1); + await $lineWithBracket.click(); + await page.keyboard.press("End"); + await page.keyboard.type("["); + await page.keyboard.press("Enter"); + const $lineAfterBracket = padBody.locator("div").nth(2); + expect(await $lineAfterBracket.textContent()).toMatch(/\s{4}/); + + // colon + const $lineWithColon = padBody.locator("div").first(); + await $lineWithColon.click(); + await page.keyboard.press("End"); + await page.keyboard.type(":"); + await page.keyboard.press("Enter"); + const $lineAfterColon = padBody.locator("div").nth(1); + expect(await $lineAfterColon.textContent()).toMatch(/\s{4}/); + }, + ); + + test( + "appends indentation to the indent of previous line if previous line ends " + + "with ':', '[', '(', or '{'", + async function ({ page }) { + const padBody = await getPadBody(page); + await padBody.click(); + await clearPadContent(page); + + // type a bit, make a line break and type again + await writeToPad(page, " line with some indentation and ':'"); + await page.keyboard.press("Enter"); + await writeToPad(page, "line 2"); + + const $lineWithColon = padBody.locator("div").first(); + await $lineWithColon.click(); + await page.keyboard.press("End"); + await page.keyboard.type(":"); + await page.keyboard.press("Enter"); + + const $lineAfterColon = padBody.locator("div").nth(1); + // previous line indentation + regular tab (4 spaces) + expect(await $lineAfterColon.textContent()).toMatch(/\s{6}/); + }, + ); + + test( + "issue #2772 shows '*' when multiple indented lines " + + " receive a style and are outdented", + async function ({ page }) { + const padBody = await getPadBody(page); + await padBody.click(); + await clearPadContent(page); + + const inner = padBody.locator("div").first(); + // make sure pad has more than one line + await inner.click(); + await page.keyboard.type("First"); + await page.keyboard.press("Enter"); + await page.keyboard.type("Second"); + + // indent first 2 lines + await padBody.locator("div").nth(0).selectText(); + await page.locator(".buttonicon-indent").click(); + + await padBody.locator("div").nth(1).selectText(); + await page.locator(".buttonicon-indent").click(); + + await expect(padBody.locator("ul li")).toHaveCount(2); + + // apply bold + await padBody.locator("div").nth(0).selectText(); + await page.locator(".buttonicon-bold").click(); + + await padBody.locator("div").nth(1).selectText(); + await page.locator(".buttonicon-bold").click(); + + await expect(padBody.locator("div b")).toHaveCount(2); + + // outdent first 2 lines + await padBody.locator("div").nth(0).selectText(); + await page.locator(".buttonicon-outdent").click(); + + await padBody.locator("div").nth(1).selectText(); + await page.locator(".buttonicon-outdent").click(); + + await expect(padBody.locator("ul li")).toHaveCount(0); + + // check if '*' is displayed + const secondLine = padBody.locator("div").nth(1); + await expect(secondLine).toHaveText("Second"); + }, + ); + + test("makes text indented and outdented", async function ({ page }) { + // get the inner iframe + + const padBody = await getPadBody(page); + + // get the first text element out of the inner iframe + let firstTextElement = padBody.locator("div").first(); + + // select this text element + await firstTextElement.selectText(); + + // get the indentation button and click it + await page.locator(".buttonicon-indent").click(); + + let newFirstTextElement = padBody.locator("div").first(); + + // is there a list-indent class element now? + await expect(newFirstTextElement.locator("ul")).toHaveCount(1); + + await expect(newFirstTextElement.locator("li")).toHaveCount(1); + + // indent again + await page.locator(".buttonicon-indent").click(); + + newFirstTextElement = padBody.locator("div").first(); + + // is there a list-indent class element now? + const ulList = newFirstTextElement.locator("ul").first(); + await expect(ulList).toHaveCount(1); + // expect it to be part of a list + expect(await ulList.getAttribute("class")).toBe("list-indent2"); + + // make sure the text hasn't changed + expect(await newFirstTextElement.textContent()).toBe( + await firstTextElement.textContent(), + ); + + // test outdent + + // get the unindentation button and click it twice + newFirstTextElement = padBody.locator("div").first(); + await newFirstTextElement.selectText(); + await page.locator(".buttonicon-outdent").click(); + await page.locator(".buttonicon-outdent").click(); + + newFirstTextElement = padBody.locator("div").first(); + + // is there a list-indent class element now? + await expect(newFirstTextElement.locator("ul")).toHaveCount(0); + + // make sure the text hasn't changed + expect(await newFirstTextElement.textContent()).toEqual( + await firstTextElement.textContent(), + ); + }); }); diff --git a/src/tests/frontend-new/specs/inner_height.spec.ts b/src/tests/frontend-new/specs/inner_height.spec.ts index 3baa7e49b..13b8d28ef 100644 --- a/src/tests/frontend-new/specs/inner_height.spec.ts +++ b/src/tests/frontend-new/specs/inner_height.spec.ts @@ -1,56 +1,66 @@ -'use strict'; +"use strict"; -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; +import { expect, test } from "@playwright/test"; +import { + clearPadContent, + getPadBody, + goToNewPad, + writeToPad, +} from "../helper/padHelper"; -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - -test.describe('height regression after ace.js refactoring', function () { - - test('clientHeight should equal scrollHeight with few lines', async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - const iframe = page.locator('iframe').first() - const scrollHeight = await iframe.evaluate((element) => { - return element.scrollHeight; - }) - - const clientHeight = await iframe.evaluate((element) => { - return element.clientHeight; - }) - - - expect(clientHeight).toEqual(scrollHeight); - }); - - test('client height should be less than scrollHeight with many lines', async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - await writeToPad(page,'Test line\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n'); - - const iframe = page.locator('iframe').first() - const scrollHeight = await iframe.evaluate((element) => { - return element.scrollHeight; - }) - - const clientHeight = await iframe.evaluate((element) => { - return element.clientHeight; - }) - - // Need to poll because the heights take some time to settle. - expect(clientHeight).toBeLessThanOrEqual(scrollHeight); - }); +test.beforeEach(async ({ page }) => { + await goToNewPad(page); +}); + +test.describe("height regression after ace.js refactoring", function () { + test("clientHeight should equal scrollHeight with few lines", async function ({ + page, + }) { + const padBody = await getPadBody(page); + await padBody.click(); + await clearPadContent(page); + + const iframe = page.locator("iframe").first(); + const scrollHeight = await iframe.evaluate((element) => { + return element.scrollHeight; + }); + + const clientHeight = await iframe.evaluate((element) => { + return element.clientHeight; + }); + + expect(clientHeight).toEqual(scrollHeight); + }); + + test("client height should be less than scrollHeight with many lines", async function ({ + page, + }) { + const padBody = await getPadBody(page); + await padBody.click(); + await clearPadContent(page); + + await writeToPad( + page, + "Test line\n" + + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + ); + + const iframe = page.locator("iframe").first(); + const scrollHeight = await iframe.evaluate((element) => { + return element.scrollHeight; + }); + + const clientHeight = await iframe.evaluate((element) => { + return element.clientHeight; + }); + + // Need to poll because the heights take some time to settle. + expect(clientHeight).toBeLessThanOrEqual(scrollHeight); + }); }); diff --git a/src/tests/frontend-new/specs/italic.spec.ts b/src/tests/frontend-new/specs/italic.spec.ts index dc69f0e38..45693e55b 100644 --- a/src/tests/frontend-new/specs/italic.spec.ts +++ b/src/tests/frontend-new/specs/italic.spec.ts @@ -1,65 +1,72 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; +import { expect, test } from "@playwright/test"; +import { + clearPadContent, + getPadBody, + goToNewPad, + writeToPad, +} from "../helper/padHelper"; -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - -test.describe('italic some text', function () { - - test('makes text italic using button', async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); - await $firstTextElement.click() - await writeToPad(page, 'Foo') - - // select this text element - await padBody.click() - await page.keyboard.press('Control+A'); - - // get the bold button and click it - const $boldButton = page.locator('.buttonicon-italic'); - await $boldButton.click(); - - // ace creates a new dom element when you press a button, just get the first text element again - const $newFirstTextElement = padBody.locator('div').first(); - - // is there a element now? - // expect it to be italic - await expect($newFirstTextElement.locator('i')).toHaveCount(1); - - - // make sure the text hasn't changed - expect(await $newFirstTextElement.textContent()).toEqual(await $firstTextElement.textContent()); - }); - - test('makes text italic using keypress', async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); - - // select this text element - await writeToPad(page, 'Foo') - - await page.keyboard.press('Control+A'); - - await page.keyboard.press('Control+I'); - - // ace creates a new dom element when you press a button, just get the first text element again - const $newFirstTextElement = padBody.locator('div').first(); - - // is there a element now? - // expect it to be italic - await expect($newFirstTextElement.locator('i')).toHaveCount(1); - - // make sure the text hasn't changed - expect(await $newFirstTextElement.textContent()).toBe(await $firstTextElement.textContent()); - }); +test.beforeEach(async ({ page }) => { + await goToNewPad(page); +}); + +test.describe("italic some text", function () { + test("makes text italic using button", async function ({ page }) { + const padBody = await getPadBody(page); + await padBody.click(); + await clearPadContent(page); + + // get the first text element out of the inner iframe + const $firstTextElement = padBody.locator("div").first(); + await $firstTextElement.click(); + await writeToPad(page, "Foo"); + + // select this text element + await padBody.click(); + await page.keyboard.press("Control+A"); + + // get the bold button and click it + const $boldButton = page.locator(".buttonicon-italic"); + await $boldButton.click(); + + // ace creates a new dom element when you press a button, just get the first text element again + const $newFirstTextElement = padBody.locator("div").first(); + + // is there a element now? + // expect it to be italic + await expect($newFirstTextElement.locator("i")).toHaveCount(1); + + // make sure the text hasn't changed + expect(await $newFirstTextElement.textContent()).toEqual( + await $firstTextElement.textContent(), + ); + }); + + test("makes text italic using keypress", async function ({ page }) { + const padBody = await getPadBody(page); + await padBody.click(); + await clearPadContent(page); + + // get the first text element out of the inner iframe + const $firstTextElement = padBody.locator("div").first(); + + // select this text element + await writeToPad(page, "Foo"); + + await page.keyboard.press("Control+A"); + + await page.keyboard.press("Control+I"); + + // ace creates a new dom element when you press a button, just get the first text element again + const $newFirstTextElement = padBody.locator("div").first(); + + // is there a element now? + // expect it to be italic + await expect($newFirstTextElement.locator("i")).toHaveCount(1); + + // make sure the text hasn't changed + expect(await $newFirstTextElement.textContent()).toBe( + await $firstTextElement.textContent(), + ); + }); }); diff --git a/src/tests/frontend-new/specs/language.spec.ts b/src/tests/frontend-new/specs/language.spec.ts index 87da86b13..a4afc12ff 100644 --- a/src/tests/frontend-new/specs/language.spec.ts +++ b/src/tests/frontend-new/specs/language.spec.ts @@ -1,88 +1,90 @@ -import {expect, test} from "@playwright/test"; -import {getPadBody, goToNewPad} from "../helper/padHelper"; -import {showSettings} from "../helper/settingsHelper"; +import { expect, test } from "@playwright/test"; +import { getPadBody, goToNewPad } from "../helper/padHelper"; +import { showSettings } from "../helper/settingsHelper"; -test.beforeEach(async ({ page, browser })=>{ - const context = await browser.newContext() - await context.clearCookies() - await goToNewPad(page); -}) - - - -test.describe('Language select and change', function () { - - // Destroy language cookies - test('makes text german', async function ({page}) { - // click on the settings button to make settings visible - await showSettings(page) - - // click the language button - const languageDropDown = page.locator('.nice-select').nth(1) - - await languageDropDown.click() - await page.locator('.nice-select').locator('[data-value=de]').click() - await expect(languageDropDown.locator('.current')).toHaveText('Deutsch') - - // select german - await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title === 'Fett (Strg-B)'); - }); - - test('makes text English', async function ({page}) { - - await showSettings(page) - - // click the language button - await page.locator('.nice-select').nth(1).locator('.current').click() - await page.locator('.nice-select').locator('[data-value=de]').click() - - // select german - await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title === 'Fett (Strg-B)'); - - - // change to english - await page.locator('.nice-select').nth(1).locator('.current').click() - await page.locator('.nice-select').locator('[data-value=en]').click() - - // check if the language is now English - await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title !== 'Fett (Strg-B)'); - }); - - test('changes direction when picking an rtl lang', async function ({page}) { - - await showSettings(page) - - // click the language button - await page.locator('.nice-select').nth(1).locator('.current').click() - await page.locator('.nice-select').locator('[data-value=de]').click() - - // select german - await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title === 'Fett (Strg-B)'); - - // click the language button - await page.locator('.nice-select').nth(1).locator('.current').click() - // select arabic - // $languageoption.attr('selected','selected'); // Breaks the test.. - await page.locator('.nice-select').locator('[data-value=ar]').click() - - await page.waitForSelector('html[dir="rtl"]') - }); - - test('changes direction when picking an ltr lang', async function ({page}) { - await showSettings(page) - - // change to english - const languageDropDown = page.locator('.nice-select').nth(1) - await languageDropDown.locator('.current').click() - await languageDropDown.locator('[data-value=en]').click() - - await expect(languageDropDown.locator('.current')).toHaveText('English') - - // check if the language is now English - await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title !== 'Fett (Strg-B)'); - - - await page.waitForSelector('html[dir="ltr"]') - - }); +test.beforeEach(async ({ page, browser }) => { + const context = await browser.newContext(); + await context.clearCookies(); + await goToNewPad(page); +}); + +test.describe("Language select and change", function () { + // Destroy language cookies + test("makes text german", async function ({ page }) { + // click on the settings button to make settings visible + await showSettings(page); + + // click the language button + const languageDropDown = page.locator(".nice-select").nth(1); + + await languageDropDown.click(); + await page.locator(".nice-select").locator("[data-value=de]").click(); + await expect(languageDropDown.locator(".current")).toHaveText("Deutsch"); + + // select german + await page + .locator(".buttonicon-bold") + .evaluate((el) => el.parentElement!.title === "Fett (Strg-B)"); + }); + + test("makes text English", async function ({ page }) { + await showSettings(page); + + // click the language button + await page.locator(".nice-select").nth(1).locator(".current").click(); + await page.locator(".nice-select").locator("[data-value=de]").click(); + + // select german + await page + .locator(".buttonicon-bold") + .evaluate((el) => el.parentElement!.title === "Fett (Strg-B)"); + + // change to english + await page.locator(".nice-select").nth(1).locator(".current").click(); + await page.locator(".nice-select").locator("[data-value=en]").click(); + + // check if the language is now English + await page + .locator(".buttonicon-bold") + .evaluate((el) => el.parentElement!.title !== "Fett (Strg-B)"); + }); + + test("changes direction when picking an rtl lang", async function ({ page }) { + await showSettings(page); + + // click the language button + await page.locator(".nice-select").nth(1).locator(".current").click(); + await page.locator(".nice-select").locator("[data-value=de]").click(); + + // select german + await page + .locator(".buttonicon-bold") + .evaluate((el) => el.parentElement!.title === "Fett (Strg-B)"); + + // click the language button + await page.locator(".nice-select").nth(1).locator(".current").click(); + // select arabic + // $languageoption.attr('selected','selected'); // Breaks the test.. + await page.locator(".nice-select").locator("[data-value=ar]").click(); + + await page.waitForSelector('html[dir="rtl"]'); + }); + + test("changes direction when picking an ltr lang", async function ({ page }) { + await showSettings(page); + + // change to english + const languageDropDown = page.locator(".nice-select").nth(1); + await languageDropDown.locator(".current").click(); + await languageDropDown.locator("[data-value=en]").click(); + + await expect(languageDropDown.locator(".current")).toHaveText("English"); + + // check if the language is now English + await page + .locator(".buttonicon-bold") + .evaluate((el) => el.parentElement!.title !== "Fett (Strg-B)"); + + await page.waitForSelector('html[dir="ltr"]'); + }); }); diff --git a/src/tests/frontend-new/specs/ordered_list.spec.ts b/src/tests/frontend-new/specs/ordered_list.spec.ts index 04e996e66..1a9d0f935 100644 --- a/src/tests/frontend-new/specs/ordered_list.spec.ts +++ b/src/tests/frontend-new/specs/ordered_list.spec.ts @@ -1,109 +1,135 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; +import { expect, test } from "@playwright/test"; +import { + clearPadContent, + getPadBody, + goToNewPad, + writeToPad, +} from "../helper/padHelper"; -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) +test.beforeEach(async ({ page }) => { + await goToNewPad(page); +}); +test.describe("ordered_list.js", function () { + test("issue #4748 keeps numbers increment on OL", async function ({ page }) { + const padBody = await getPadBody(page); + await clearPadContent(page); + await writeToPad(page, "Line 1"); + await page.keyboard.press("Enter"); + await writeToPad(page, "Line 2"); -test.describe('ordered_list.js', function () { + const $insertorderedlistButton = page.locator( + ".buttonicon-insertorderedlist", + ); + await padBody.locator("div").first().selectText(); + await $insertorderedlistButton.first().click(); - test('issue #4748 keeps numbers increment on OL', async function ({page}) { - const padBody = await getPadBody(page); - await clearPadContent(page) - await writeToPad(page, 'Line 1') - await page.keyboard.press('Enter') - await writeToPad(page, 'Line 2') + const secondLine = padBody.locator("div").nth(1); - const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist') - await padBody.locator('div').first().selectText() - await $insertorderedlistButton.first().click(); + await secondLine.selectText(); + await $insertorderedlistButton.click(); - const secondLine = padBody.locator('div').nth(1) + expect(await secondLine.locator("ol").getAttribute("start")).toEqual("2"); + }); - await secondLine.selectText() - await $insertorderedlistButton.click(); + test("issue #1125 keeps the numbered list on enter for the new line", async function ({ + page, + }) { + // EMULATES PASTING INTO A PAD + const padBody = await getPadBody(page); + await clearPadContent(page); + await expect(padBody.locator("div")).toHaveCount(1); + const $insertorderedlistButton = page.locator( + ".buttonicon-insertorderedlist", + ); + await $insertorderedlistButton.click(); - expect(await secondLine.locator('ol').getAttribute('start')).toEqual('2'); - }); + // type a bit, make a line break and type again + const firstTextElement = padBody.locator("div").first(); + await firstTextElement.click(); + await writeToPad(page, "line 1"); + await page.keyboard.press("Enter"); + await writeToPad(page, "line 2"); + await page.keyboard.press("Enter"); - test('issue #1125 keeps the numbered list on enter for the new line', async function ({page}) { - // EMULATES PASTING INTO A PAD - const padBody = await getPadBody(page); - await clearPadContent(page) - await expect(padBody.locator('div')).toHaveCount(1) - const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist') - await $insertorderedlistButton.click(); + await expect(padBody.locator("div span").nth(1)).toHaveText("line 2"); - // type a bit, make a line break and type again - const firstTextElement = padBody.locator('div').first() - await firstTextElement.click() - await writeToPad(page, 'line 1') - await page.keyboard.press('Enter') - await writeToPad(page, 'line 2') - await page.keyboard.press('Enter') + const $newSecondLine = padBody.locator("div").nth(1); + expect(await $newSecondLine.locator("ol li").count()).toEqual(1); + await expect($newSecondLine.locator("ol li").nth(0)).toHaveText("line 2"); + const hasLineNumber = await $newSecondLine + .locator("ol") + .getAttribute("start"); + // This doesn't work because pasting in content doesn't work + expect(Number(hasLineNumber)).toBe(2); + }); +}); - await expect(padBody.locator('div span').nth(1)).toHaveText('line 2'); +test.describe("Pressing Tab in an OL increases and decreases indentation", function () { + test("indent and de-indent list item with keypress", async function ({ + page, + }) { + const padBody = await getPadBody(page); - const $newSecondLine = padBody.locator('div').nth(1) - expect(await $newSecondLine.locator('ol li').count()).toEqual(1); - await expect($newSecondLine.locator('ol li').nth(0)).toHaveText('line 2'); - const hasLineNumber = await $newSecondLine.locator('ol').getAttribute('start'); - // This doesn't work because pasting in content doesn't work - expect(Number(hasLineNumber)).toBe(2); - }); - }); + // get the first text element out of the inner iframe + const $firstTextElement = padBody.locator("div").first(); - test.describe('Pressing Tab in an OL increases and decreases indentation', function () { + // select this text element + await $firstTextElement.selectText(); - test('indent and de-indent list item with keypress', async function ({page}) { - const padBody = await getPadBody(page); + const $insertorderedlistButton = page.locator( + ".buttonicon-insertorderedlist", + ); + await $insertorderedlistButton.click(); - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); + await page.keyboard.press("Tab"); - // select this text element - await $firstTextElement.selectText() + await expect( + padBody.locator("div").first().locator(".list-number2"), + ).toHaveCount(1); - const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist') - await $insertorderedlistButton.click() + await page.keyboard.press("Shift+Tab"); - await page.keyboard.press('Tab') + await expect( + padBody.locator("div").first().locator(".list-number1"), + ).toHaveCount(1); + }); +}); - await expect(padBody.locator('div').first().locator('.list-number2')).toHaveCount(1) +test.describe( + "Pressing indent/outdent button in an OL increases and " + + "decreases indentation and bullet / ol formatting", + function () { + test("indent and de-indent list item with indent button", async function ({ + page, + }) { + const padBody = await getPadBody(page); - await page.keyboard.press('Shift+Tab') + // get the first text element out of the inner iframe + const $firstTextElement = padBody.locator("div").first(); + // select this text element + await $firstTextElement.selectText(); - await expect(padBody.locator('div').first().locator('.list-number1')).toHaveCount(1) - }); - }); + const $insertorderedlistButton = page.locator( + ".buttonicon-insertorderedlist", + ); + await $insertorderedlistButton.click(); + const $indentButton = page.locator(".buttonicon-indent"); + await $indentButton.dblclick(); // make it indented twice - test.describe('Pressing indent/outdent button in an OL increases and ' + - 'decreases indentation and bullet / ol formatting', function () { + const outdentButton = page.locator(".buttonicon-outdent"); - test('indent and de-indent list item with indent button', async function ({page}) { - const padBody = await getPadBody(page); + await expect( + padBody.locator("div").first().locator(".list-number3"), + ).toHaveCount(1); - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); + await outdentButton.click(); // make it deindented to 1 - // select this text element - await $firstTextElement.selectText() - - const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist') - await $insertorderedlistButton.click() - - const $indentButton = page.locator('.buttonicon-indent') - await $indentButton.dblclick() // make it indented twice - - const outdentButton = page.locator('.buttonicon-outdent') - - await expect(padBody.locator('div').first().locator('.list-number3')).toHaveCount(1) - - await outdentButton.click(); // make it deindented to 1 - - await expect(padBody.locator('div').first().locator('.list-number2')).toHaveCount(1) - }); - }); + await expect( + padBody.locator("div").first().locator(".list-number2"), + ).toHaveCount(1); + }); + }, +); diff --git a/src/tests/frontend-new/specs/redo.spec.ts b/src/tests/frontend-new/specs/redo.spec.ts index b3df70c69..6f09e2f58 100644 --- a/src/tests/frontend-new/specs/redo.spec.ts +++ b/src/tests/frontend-new/specs/redo.spec.ts @@ -1,65 +1,66 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; +import { expect, test } from "@playwright/test"; +import { + clearPadContent, + getPadBody, + goToNewPad, + writeToPad, +} from "../helper/padHelper"; -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - - -test.describe('undo button then redo button', function () { - - - test('redo some typing with button', async function ({page}) { - const padBody = await getPadBody(page); - - // get the first text element inside the editable space - const $firstTextElement = padBody.locator('div span').first(); - const originalValue = await $firstTextElement.textContent(); // get the original value - const newString = 'Foo'; - - await $firstTextElement.focus() - expect(await $firstTextElement.textContent()).toContain(originalValue); - await padBody.click() - await clearPadContent(page) - await writeToPad(page, newString); // send line 1 to the pad - - const modifiedValue = await $firstTextElement.textContent(); // get the modified value - expect(modifiedValue).not.toBe(originalValue); // expect the value to change - - // get undo and redo buttons // click the buttons - await page.locator('.buttonicon-undo').click() // removes foo - await page.locator('.buttonicon-redo').click() // resends foo - - await expect($firstTextElement).toHaveText(newString); - - const finalValue = await padBody.locator('div').first().textContent(); - expect(finalValue).toBe(modifiedValue); // expect the value to change - }); - - test('redo some typing with keypress', async function ({page}) { - const padBody = await getPadBody(page); - - // get the first text element inside the editable space - const $firstTextElement = padBody.locator('div span').first(); - const originalValue = await $firstTextElement.textContent(); // get the original value - const newString = 'Foo'; - - await padBody.click() - await clearPadContent(page) - await writeToPad(page, newString); // send line 1 to the pad - const modifiedValue = await $firstTextElement.textContent(); // get the modified value - expect(modifiedValue).not.toBe(originalValue); // expect the value to change - - // undo the change - await padBody.click() - await page.keyboard.press('Control+Z'); - - await page.keyboard.press('Control+Y'); // redo the change - - - await expect($firstTextElement).toHaveText(newString); - - const finalValue = await padBody.locator('div').first().textContent(); - expect(finalValue).toBe(modifiedValue); // expect the value to change - }); +test.beforeEach(async ({ page }) => { + await goToNewPad(page); +}); + +test.describe("undo button then redo button", function () { + test("redo some typing with button", async function ({ page }) { + const padBody = await getPadBody(page); + + // get the first text element inside the editable space + const $firstTextElement = padBody.locator("div span").first(); + const originalValue = await $firstTextElement.textContent(); // get the original value + const newString = "Foo"; + + await $firstTextElement.focus(); + expect(await $firstTextElement.textContent()).toContain(originalValue); + await padBody.click(); + await clearPadContent(page); + await writeToPad(page, newString); // send line 1 to the pad + + const modifiedValue = await $firstTextElement.textContent(); // get the modified value + expect(modifiedValue).not.toBe(originalValue); // expect the value to change + + // get undo and redo buttons // click the buttons + await page.locator(".buttonicon-undo").click(); // removes foo + await page.locator(".buttonicon-redo").click(); // resends foo + + await expect($firstTextElement).toHaveText(newString); + + const finalValue = await padBody.locator("div").first().textContent(); + expect(finalValue).toBe(modifiedValue); // expect the value to change + }); + + test("redo some typing with keypress", async function ({ page }) { + const padBody = await getPadBody(page); + + // get the first text element inside the editable space + const $firstTextElement = padBody.locator("div span").first(); + const originalValue = await $firstTextElement.textContent(); // get the original value + const newString = "Foo"; + + await padBody.click(); + await clearPadContent(page); + await writeToPad(page, newString); // send line 1 to the pad + const modifiedValue = await $firstTextElement.textContent(); // get the modified value + expect(modifiedValue).not.toBe(originalValue); // expect the value to change + + // undo the change + await padBody.click(); + await page.keyboard.press("Control+Z"); + + await page.keyboard.press("Control+Y"); // redo the change + + await expect($firstTextElement).toHaveText(newString); + + const finalValue = await padBody.locator("div").first().textContent(); + expect(finalValue).toBe(modifiedValue); // expect the value to change + }); }); diff --git a/src/tests/frontend-new/specs/strikethrough.spec.ts b/src/tests/frontend-new/specs/strikethrough.spec.ts index a4f68b4a7..8c9c4bd4c 100644 --- a/src/tests/frontend-new/specs/strikethrough.spec.ts +++ b/src/tests/frontend-new/specs/strikethrough.spec.ts @@ -1,30 +1,36 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; +import { expect, test } from "@playwright/test"; +import { + clearPadContent, + getPadBody, + goToNewPad, + writeToPad, +} from "../helper/padHelper"; -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - -test.describe('strikethrough button', function () { - - test('makes text strikethrough', async function ({page}) { - const padBody = await getPadBody(page); - - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); - - // select this text element - await $firstTextElement.selectText() - - // get the strikethrough button and click it - await page.locator('.buttonicon-strikethrough').click(); - - // ace creates a new dom element when you press a button, just get the first text element again - - // is there a element now? - await expect($firstTextElement.locator('s')).toHaveCount(1); - - // make sure the text hasn't changed - expect(await $firstTextElement.textContent()).toEqual(await $firstTextElement.textContent()); - }); +test.beforeEach(async ({ page }) => { + await goToNewPad(page); +}); + +test.describe("strikethrough button", function () { + test("makes text strikethrough", async function ({ page }) { + const padBody = await getPadBody(page); + + // get the first text element out of the inner iframe + const $firstTextElement = padBody.locator("div").first(); + + // select this text element + await $firstTextElement.selectText(); + + // get the strikethrough button and click it + await page.locator(".buttonicon-strikethrough").click(); + + // ace creates a new dom element when you press a button, just get the first text element again + + // is there a element now? + await expect($firstTextElement.locator("s")).toHaveCount(1); + + // make sure the text hasn't changed + expect(await $firstTextElement.textContent()).toEqual( + await $firstTextElement.textContent(), + ); + }); }); diff --git a/src/tests/frontend-new/specs/timeslider.spec.ts b/src/tests/frontend-new/specs/timeslider.spec.ts index 317398f18..65b43ec86 100644 --- a/src/tests/frontend-new/specs/timeslider.spec.ts +++ b/src/tests/frontend-new/specs/timeslider.spec.ts @@ -1,37 +1,40 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; - -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); -}) +import { expect, test } from "@playwright/test"; +import { + clearPadContent, + getPadBody, + goToNewPad, + writeToPad, +} from "../helper/padHelper"; +test.beforeEach(async ({ page }) => { + // create a new pad before each test run + await goToNewPad(page); +}); // deactivated, we need a nice way to get the timeslider, this is ugly -test.describe('timeslider button takes you to the timeslider of a pad', function () { +test.describe("timeslider button takes you to the timeslider of a pad", function () { + test("timeslider contained in URL", async function ({ page }) { + const padBody = await getPadBody(page); + await clearPadContent(page); + await writeToPad(page, "Foo"); // send line 1 to the pad - test('timeslider contained in URL', async function ({page}) { - const padBody = await getPadBody(page); - await clearPadContent(page) - await writeToPad(page, 'Foo'); // send line 1 to the pad + // get the first text element inside the editable space + const $firstTextElement = padBody.locator("div span").first(); + const originalValue = await $firstTextElement.textContent(); // get the original value + await $firstTextElement.click(); + await writeToPad(page, "Testing"); // send line 1 to the pad - // get the first text element inside the editable space - const $firstTextElement = padBody.locator('div span').first(); - const originalValue = await $firstTextElement.textContent(); // get the original value - await $firstTextElement.click() - await writeToPad(page, 'Testing'); // send line 1 to the pad + const modifiedValue = await $firstTextElement.textContent(); // get the modified value + expect(modifiedValue).not.toBe(originalValue); // expect the value to change - const modifiedValue = await $firstTextElement.textContent(); // get the modified value - expect(modifiedValue).not.toBe(originalValue); // expect the value to change + const $timesliderButton = page.locator(".buttonicon-history"); + await $timesliderButton.click(); // So click the timeslider link - const $timesliderButton = page.locator('.buttonicon-history'); - await $timesliderButton.click(); // So click the timeslider link + await page.waitForSelector("#timeslider-wrapper"); - await page.waitForSelector('#timeslider-wrapper') + const iFrameURL = page.url(); // get the url + const inTimeslider = iFrameURL.indexOf("timeslider") !== -1; - const iFrameURL = page.url(); // get the url - const inTimeslider = iFrameURL.indexOf('timeslider') !== -1; - - expect(inTimeslider).toBe(true); // expect the value to change - }); + expect(inTimeslider).toBe(true); // expect the value to change + }); }); diff --git a/src/tests/frontend-new/specs/timeslider_follow.spec.ts b/src/tests/frontend-new/specs/timeslider_follow.spec.ts index 9f104b884..86a4bb4af 100644 --- a/src/tests/frontend-new/specs/timeslider_follow.spec.ts +++ b/src/tests/frontend-new/specs/timeslider_follow.spec.ts @@ -1,76 +1,89 @@ -'use strict'; -import {expect, Page, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; -import {gotoTimeslider} from "../helper/timeslider"; +"use strict"; +import { expect, Page, test } from "@playwright/test"; +import { + clearPadContent, + getPadBody, + goToNewPad, + writeToPad, +} from "../helper/padHelper"; +import { gotoTimeslider } from "../helper/timeslider"; -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - - -test.describe('timeslider follow', function () { - - // TODO needs test if content is also followed, when user a makes edits - // while user b is in the timeslider - test("content as it's added to timeslider", async function ({page}) { - // send 6 revisions - const revs = 6; - const message = 'a\n\n\n\n\n\n\n\n\n\n'; - const newLines = message.split('\n').length; - for (let i = 0; i < revs; i++) { - await writeToPad(page, message) - } - - await gotoTimeslider(page,0); - expect(page.url()).toContain('#0'); - - const originalTop = await page.evaluate(() => { - return window.document.querySelector('#innerdocbody')!.getBoundingClientRect().top; - }); - - // set to follow contents as it arrives - await page.check('#options-followContents'); - await page.click('#playpause_button_icon'); - - // wait for the scroll - await page.waitForTimeout(1000) - - const currentOffset = await page.evaluate(() => { - return window.document.querySelector('#innerdocbody')!.getBoundingClientRect().top; - }); - - expect(currentOffset).toBeLessThanOrEqual(originalTop); - }); - - /** - * Tests for bug described in #4389 - * The goal is to scroll to the first line that contains a change right before - * the change is applied. - */ - test('only to lines that exist in the pad view, regression test for #4389', async function ({page}) { - const padBody = await getPadBody(page) - await padBody.click() - - await clearPadContent(page) - - await writeToPad(page,'Test line\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' + - '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n'); - await padBody.locator('div').nth(40).click(); - await writeToPad(page, 'Another test line'); - - - await gotoTimeslider(page, 200); - - // set to follow contents as it arrives - await page.check('#options-followContents'); - - await page.waitForTimeout(1000) - - const oldYPosition = await page.locator('#editorcontainerbox').evaluate((el) => { - return el.scrollTop; - }) - expect(oldYPosition).toBe(0); - }); +test.beforeEach(async ({ page }) => { + await goToNewPad(page); +}); + +test.describe("timeslider follow", function () { + // TODO needs test if content is also followed, when user a makes edits + // while user b is in the timeslider + test("content as it's added to timeslider", async function ({ page }) { + // send 6 revisions + const revs = 6; + const message = "a\n\n\n\n\n\n\n\n\n\n"; + const newLines = message.split("\n").length; + for (let i = 0; i < revs; i++) { + await writeToPad(page, message); + } + + await gotoTimeslider(page, 0); + expect(page.url()).toContain("#0"); + + const originalTop = await page.evaluate(() => { + return window.document + .querySelector("#innerdocbody")! + .getBoundingClientRect().top; + }); + + // set to follow contents as it arrives + await page.check("#options-followContents"); + await page.click("#playpause_button_icon"); + + // wait for the scroll + await page.waitForTimeout(1000); + + const currentOffset = await page.evaluate(() => { + return window.document + .querySelector("#innerdocbody")! + .getBoundingClientRect().top; + }); + + expect(currentOffset).toBeLessThanOrEqual(originalTop); + }); + + /** + * Tests for bug described in #4389 + * The goal is to scroll to the first line that contains a change right before + * the change is applied. + */ + test("only to lines that exist in the pad view, regression test for #4389", async function ({ + page, + }) { + const padBody = await getPadBody(page); + await padBody.click(); + + await clearPadContent(page); + + await writeToPad( + page, + "Test line\n" + + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + ); + await padBody.locator("div").nth(40).click(); + await writeToPad(page, "Another test line"); + + await gotoTimeslider(page, 200); + + // set to follow contents as it arrives + await page.check("#options-followContents"); + + await page.waitForTimeout(1000); + + const oldYPosition = await page + .locator("#editorcontainerbox") + .evaluate((el) => { + return el.scrollTop; + }); + expect(oldYPosition).toBe(0); + }); }); diff --git a/src/tests/frontend-new/specs/undo.spec.ts b/src/tests/frontend-new/specs/undo.spec.ts index cdbc12083..8bdffb918 100644 --- a/src/tests/frontend-new/specs/undo.spec.ts +++ b/src/tests/frontend-new/specs/undo.spec.ts @@ -1,56 +1,58 @@ -'use strict'; +"use strict"; -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; +import { expect, test } from "@playwright/test"; +import { + clearPadContent, + getPadBody, + goToNewPad, + writeToPad, +} from "../helper/padHelper"; -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - - -test.describe('undo button', function () { - - test('undo some typing by clicking undo button', async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - - // get the first text element inside the editable space - const firstTextElement = padBody.locator('div').first() - const originalValue = await firstTextElement.textContent(); // get the original value - await firstTextElement.focus() - - await writeToPad(page, 'foo'); // send line 1 to the pad - - const modifiedValue = await firstTextElement.textContent(); // get the modified value - expect(modifiedValue).not.toBe(originalValue); // expect the value to change - - // get clear authorship button as a variable - const undoButton = page.locator('.buttonicon-undo') - await undoButton.click() // click the button - - await expect(firstTextElement).toHaveText(originalValue!); - }); - - test('undo some typing using a keypress', async function ({page}) { - const padBody = await getPadBody(page); - await padBody.click() - await clearPadContent(page) - - // get the first text element inside the editable space - const firstTextElement = padBody.locator('div').first() - const originalValue = await firstTextElement.textContent(); // get the original value - - await firstTextElement.focus() - await writeToPad(page, 'foo'); // send line 1 to the pad - const modifiedValue = await firstTextElement.textContent(); // get the modified value - expect(modifiedValue).not.toBe(originalValue); // expect the value to change - - // undo the change - await page.keyboard.press('Control+Z'); - await page.waitForTimeout(1000) - - await expect(firstTextElement).toHaveText(originalValue!); - }); +test.beforeEach(async ({ page }) => { + await goToNewPad(page); +}); + +test.describe("undo button", function () { + test("undo some typing by clicking undo button", async function ({ page }) { + const padBody = await getPadBody(page); + await padBody.click(); + await clearPadContent(page); + + // get the first text element inside the editable space + const firstTextElement = padBody.locator("div").first(); + const originalValue = await firstTextElement.textContent(); // get the original value + await firstTextElement.focus(); + + await writeToPad(page, "foo"); // send line 1 to the pad + + const modifiedValue = await firstTextElement.textContent(); // get the modified value + expect(modifiedValue).not.toBe(originalValue); // expect the value to change + + // get clear authorship button as a variable + const undoButton = page.locator(".buttonicon-undo"); + await undoButton.click(); // click the button + + await expect(firstTextElement).toHaveText(originalValue!); + }); + + test("undo some typing using a keypress", async function ({ page }) { + const padBody = await getPadBody(page); + await padBody.click(); + await clearPadContent(page); + + // get the first text element inside the editable space + const firstTextElement = padBody.locator("div").first(); + const originalValue = await firstTextElement.textContent(); // get the original value + + await firstTextElement.focus(); + await writeToPad(page, "foo"); // send line 1 to the pad + const modifiedValue = await firstTextElement.textContent(); // get the modified value + expect(modifiedValue).not.toBe(originalValue); // expect the value to change + + // undo the change + await page.keyboard.press("Control+Z"); + await page.waitForTimeout(1000); + + await expect(firstTextElement).toHaveText(originalValue!); + }); }); diff --git a/src/tests/frontend-new/specs/unordered_list.spec.ts b/src/tests/frontend-new/specs/unordered_list.spec.ts index a2465e5af..0266da22e 100644 --- a/src/tests/frontend-new/specs/unordered_list.spec.ts +++ b/src/tests/frontend-new/specs/unordered_list.spec.ts @@ -1,127 +1,157 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; +import { expect, test } from "@playwright/test"; +import { + clearPadContent, + getPadBody, + goToNewPad, + writeToPad, +} from "../helper/padHelper"; -test.beforeEach(async ({ page })=>{ - // create a new pad before each test run - await goToNewPad(page); -}) - -test.describe('unordered_list.js', function () { - test.describe('assign unordered list', function () { - test('insert unordered list text then removes by outdent', async function ({page}) { - const padBody = await getPadBody(page); - const originalText = await padBody.locator('div').first().textContent(); - - const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist'); - await $insertunorderedlistButton.click(); - - await expect(padBody.locator('div').first()).toHaveText(originalText!); - await expect(padBody.locator('div ul li')).toHaveCount(1); - - // remove indentation by bullet and ensure text string remains the same - const $outdentButton = page.locator('.buttonicon-outdent'); - await $outdentButton.click(); - await expect(padBody.locator('div').first()).toHaveText(originalText!); - }); - }); - - test.describe('unassign unordered list', function () { - // create a new pad before each test run - - - test('insert unordered list text then remove by clicking list again', async function ({page}) { - const padBody = await getPadBody(page); - const originalText = await padBody.locator('div').first().textContent(); - - await padBody.locator('div').first().selectText() - const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist'); - await $insertunorderedlistButton.click(); - - await expect(padBody.locator('div').first()).toHaveText(originalText!); - await expect(padBody.locator('div ul li')).toHaveCount(1); - - // remove indentation by bullet and ensure text string remains the same - await $insertunorderedlistButton.click(); - await expect(padBody.locator('div').locator('ul')).toHaveCount(0) - }); - }); - - - test.describe('keep unordered list on enter key', function () { - - test('Keeps the unordered list on enter for the new line', async function ({page}) { - const padBody = await getPadBody(page); - await clearPadContent(page) - await expect(padBody.locator('div')).toHaveCount(1) - - const $insertorderedlistButton = page.locator('.buttonicon-insertunorderedlist') - await $insertorderedlistButton.click(); - - // type a bit, make a line break and type again - const $firstTextElement = padBody.locator('div').first(); - await $firstTextElement.click() - await page.keyboard.type('line 1'); - await page.keyboard.press('Enter'); - await page.keyboard.type('line 2'); - await page.keyboard.press('Enter'); - - await expect(padBody.locator('div span')).toHaveCount(2); - - - const $newSecondLine = padBody.locator('div').nth(1) - await expect($newSecondLine.locator('ul')).toHaveCount(1); - await expect($newSecondLine).toHaveText('line 2'); - }); - }); - - test.describe('Pressing Tab in an UL increases and decreases indentation', function () { - - test('indent and de-indent list item with keypress', async function ({page}) { - const padBody = await getPadBody(page); - await clearPadContent(page) - - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); - - // select this text element - await $firstTextElement.selectText(); - - const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist'); - await $insertunorderedlistButton.click(); - - await padBody.locator('div').first().click(); - await page.keyboard.press('Home'); - await page.keyboard.press('Tab'); - await expect(padBody.locator('div').first().locator('.list-bullet2')).toHaveCount(1); - - await page.keyboard.press('Shift+Tab'); - - await expect(padBody.locator('div').first().locator('.list-bullet1')).toHaveCount(1); - }); - }); - - test.describe('Pressing indent/outdent button in an UL increases and decreases indentation ' + - 'and bullet / ol formatting', function () { - - test('indent and de-indent list item with indent button', async function ({page}) { - const padBody = await getPadBody(page); - - // get the first text element out of the inner iframe - const $firstTextElement = padBody.locator('div').first(); - - // select this text element - await $firstTextElement.selectText(); - - const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist'); - await $insertunorderedlistButton.click(); - - await page.locator('.buttonicon-indent').click(); - - await expect(padBody.locator('div').first().locator('.list-bullet2')).toHaveCount(1); - const outdentButton = page.locator('.buttonicon-outdent'); - await outdentButton.click(); - - await expect(padBody.locator('div').first().locator('.list-bullet1')).toHaveCount(1); - }); - }); +test.beforeEach(async ({ page }) => { + // create a new pad before each test run + await goToNewPad(page); +}); + +test.describe("unordered_list.js", function () { + test.describe("assign unordered list", function () { + test("insert unordered list text then removes by outdent", async function ({ + page, + }) { + const padBody = await getPadBody(page); + const originalText = await padBody.locator("div").first().textContent(); + + const $insertunorderedlistButton = page.locator( + ".buttonicon-insertunorderedlist", + ); + await $insertunorderedlistButton.click(); + + await expect(padBody.locator("div").first()).toHaveText(originalText!); + await expect(padBody.locator("div ul li")).toHaveCount(1); + + // remove indentation by bullet and ensure text string remains the same + const $outdentButton = page.locator(".buttonicon-outdent"); + await $outdentButton.click(); + await expect(padBody.locator("div").first()).toHaveText(originalText!); + }); + }); + + test.describe("unassign unordered list", function () { + // create a new pad before each test run + + test("insert unordered list text then remove by clicking list again", async function ({ + page, + }) { + const padBody = await getPadBody(page); + const originalText = await padBody.locator("div").first().textContent(); + + await padBody.locator("div").first().selectText(); + const $insertunorderedlistButton = page.locator( + ".buttonicon-insertunorderedlist", + ); + await $insertunorderedlistButton.click(); + + await expect(padBody.locator("div").first()).toHaveText(originalText!); + await expect(padBody.locator("div ul li")).toHaveCount(1); + + // remove indentation by bullet and ensure text string remains the same + await $insertunorderedlistButton.click(); + await expect(padBody.locator("div").locator("ul")).toHaveCount(0); + }); + }); + + test.describe("keep unordered list on enter key", function () { + test("Keeps the unordered list on enter for the new line", async function ({ + page, + }) { + const padBody = await getPadBody(page); + await clearPadContent(page); + await expect(padBody.locator("div")).toHaveCount(1); + + const $insertorderedlistButton = page.locator( + ".buttonicon-insertunorderedlist", + ); + await $insertorderedlistButton.click(); + + // type a bit, make a line break and type again + const $firstTextElement = padBody.locator("div").first(); + await $firstTextElement.click(); + await page.keyboard.type("line 1"); + await page.keyboard.press("Enter"); + await page.keyboard.type("line 2"); + await page.keyboard.press("Enter"); + + await expect(padBody.locator("div span")).toHaveCount(2); + + const $newSecondLine = padBody.locator("div").nth(1); + await expect($newSecondLine.locator("ul")).toHaveCount(1); + await expect($newSecondLine).toHaveText("line 2"); + }); + }); + + test.describe("Pressing Tab in an UL increases and decreases indentation", function () { + test("indent and de-indent list item with keypress", async function ({ + page, + }) { + const padBody = await getPadBody(page); + await clearPadContent(page); + + // get the first text element out of the inner iframe + const $firstTextElement = padBody.locator("div").first(); + + // select this text element + await $firstTextElement.selectText(); + + const $insertunorderedlistButton = page.locator( + ".buttonicon-insertunorderedlist", + ); + await $insertunorderedlistButton.click(); + + await padBody.locator("div").first().click(); + await page.keyboard.press("Home"); + await page.keyboard.press("Tab"); + await expect( + padBody.locator("div").first().locator(".list-bullet2"), + ).toHaveCount(1); + + await page.keyboard.press("Shift+Tab"); + + await expect( + padBody.locator("div").first().locator(".list-bullet1"), + ).toHaveCount(1); + }); + }); + + test.describe( + "Pressing indent/outdent button in an UL increases and decreases indentation " + + "and bullet / ol formatting", + function () { + test("indent and de-indent list item with indent button", async function ({ + page, + }) { + const padBody = await getPadBody(page); + + // get the first text element out of the inner iframe + const $firstTextElement = padBody.locator("div").first(); + + // select this text element + await $firstTextElement.selectText(); + + const $insertunorderedlistButton = page.locator( + ".buttonicon-insertunorderedlist", + ); + await $insertunorderedlistButton.click(); + + await page.locator(".buttonicon-indent").click(); + + await expect( + padBody.locator("div").first().locator(".list-bullet2"), + ).toHaveCount(1); + const outdentButton = page.locator(".buttonicon-outdent"); + await outdentButton.click(); + + await expect( + padBody.locator("div").first().locator(".list-bullet1"), + ).toHaveCount(1); + }); + }, + ); }); diff --git a/src/tests/frontend-new/specs/urls_become_clickable.spec.ts b/src/tests/frontend-new/specs/urls_become_clickable.spec.ts index 0397502bc..2a7508664 100644 --- a/src/tests/frontend-new/specs/urls_become_clickable.spec.ts +++ b/src/tests/frontend-new/specs/urls_become_clickable.spec.ts @@ -1,51 +1,59 @@ -import {expect, test} from "@playwright/test"; -import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper"; +import { expect, test } from "@playwright/test"; +import { + clearPadContent, + getPadBody, + goToNewPad, + writeToPad, +} from "../helper/padHelper"; -test.beforeEach(async ({ page })=>{ - await goToNewPad(page); -}) - -test.describe('entering a URL makes a link', function () { - for (const url of ['https://etherpad.org', 'www.etherpad.org', 'https://www.etherpad.org']) { - test(url, async function ({page}) { - const padBody = await getPadBody(page); - await clearPadContent(page) - const url = 'https://etherpad.org'; - await writeToPad(page, url); - await expect(padBody.locator('div').first()).toHaveText(url); - await expect(padBody.locator('a')).toHaveText(url); - await expect(padBody.locator('a')).toHaveAttribute('href', url); - }); - } +test.beforeEach(async ({ page }) => { + await goToNewPad(page); }); - -test.describe('special characters inside URL', async function () { - for (const char of '-:@_.,~%+/?=&#!;()[]$\'*') { - const url = `https://etherpad.org/${char}foo`; - test(url, async function ({page}) { - const padBody = await getPadBody(page); - await clearPadContent(page) - await padBody.click() - await clearPadContent(page) - await writeToPad(page, url); - await expect(padBody.locator('div').first()).toHaveText(url); - await expect(padBody.locator('a')).toHaveText(url); - await expect(padBody.locator('a')).toHaveAttribute('href', url); - }); - } +test.describe("entering a URL makes a link", function () { + for (const url of [ + "https://etherpad.org", + "www.etherpad.org", + "https://www.etherpad.org", + ]) { + test(url, async function ({ page }) { + const padBody = await getPadBody(page); + await clearPadContent(page); + const url = "https://etherpad.org"; + await writeToPad(page, url); + await expect(padBody.locator("div").first()).toHaveText(url); + await expect(padBody.locator("a")).toHaveText(url); + await expect(padBody.locator("a")).toHaveAttribute("href", url); + }); + } }); -test.describe('punctuation after URL is ignored', ()=> { - for (const char of ':.,;?!)]\'*') { - const want = 'https://etherpad.org'; - const input = want + char; - test(input, async function ({page}) { - const padBody = await getPadBody(page); - await clearPadContent(page) - await writeToPad(page, input); - await expect(padBody.locator('a')).toHaveCount(1); - await expect(padBody.locator('a')).toHaveAttribute('href', want); - }); - } +test.describe("special characters inside URL", async function () { + for (const char of "-:@_.,~%+/?=&#!;()[]$'*") { + const url = `https://etherpad.org/${char}foo`; + test(url, async function ({ page }) { + const padBody = await getPadBody(page); + await clearPadContent(page); + await padBody.click(); + await clearPadContent(page); + await writeToPad(page, url); + await expect(padBody.locator("div").first()).toHaveText(url); + await expect(padBody.locator("a")).toHaveText(url); + await expect(padBody.locator("a")).toHaveAttribute("href", url); + }); + } +}); + +test.describe("punctuation after URL is ignored", () => { + for (const char of ":.,;?!)]'*") { + const want = "https://etherpad.org"; + const input = want + char; + test(input, async function ({ page }) { + const padBody = await getPadBody(page); + await clearPadContent(page); + await writeToPad(page, input); + await expect(padBody.locator("a")).toHaveCount(1); + await expect(padBody.locator("a")).toHaveAttribute("href", want); + }); + } }); diff --git a/src/tests/frontend/cypress/cypress.config.js b/src/tests/frontend/cypress/cypress.config.js index 3754350de..8badd6f8b 100644 --- a/src/tests/frontend/cypress/cypress.config.js +++ b/src/tests/frontend/cypress/cypress.config.js @@ -1,9 +1,9 @@ -const { defineConfig } = require('cypress') +const { defineConfig } = require("cypress"); module.exports = defineConfig({ - e2e: { - baseUrl: "http://127.0.0.1:9001", - supportFile: false, - specPattern: 'tests/frontend/cypress/integration/**/*.js' - } -}) + e2e: { + baseUrl: "http://127.0.0.1:9001", + supportFile: false, + specPattern: "tests/frontend/cypress/integration/**/*.js", + }, +}); diff --git a/src/tests/frontend/cypress/integration/test.js b/src/tests/frontend/cypress/integration/test.js index 893d4b669..0eccd0665 100644 --- a/src/tests/frontend/cypress/integration/test.js +++ b/src/tests/frontend/cypress/integration/test.js @@ -1,23 +1,30 @@ -'use strict'; +"use strict"; -Cypress.Commands.add('iframe', {prevSubject: 'element'}, - ($iframe) => new Cypress.Promise((resolve) => { - $iframe.ready(() => { - resolve($iframe.contents().find('body')); - }); - })); +Cypress.Commands.add( + "iframe", + { prevSubject: "element" }, + ($iframe) => + new Cypress.Promise((resolve) => { + $iframe.ready(() => { + resolve($iframe.contents().find("body")); + }); + }), +); describe(__filename, () => { - it('Pad content exists', () => { - cy.visit('http://127.0.0.1:9001/p/test'); - cy.wait(10000); // wait for Minified JS to be built... - cy.get('iframe[name="ace_outer"]', {timeout: 10000}).iframe() - .find('.line-number:first') - .should('have.text', '1'); - cy.get('iframe[name="ace_outer"]').iframe() - .find('iframe[name="ace_inner"]').iframe() - .find('.ace-line:first') - .should('be.visible') - .should('have.text', 'Welcome to Etherpad!'); - }); + it("Pad content exists", () => { + cy.visit("http://127.0.0.1:9001/p/test"); + cy.wait(10000); // wait for Minified JS to be built... + cy.get('iframe[name="ace_outer"]', { timeout: 10000 }) + .iframe() + .find(".line-number:first") + .should("have.text", "1"); + cy.get('iframe[name="ace_outer"]') + .iframe() + .find('iframe[name="ace_inner"]') + .iframe() + .find(".ace-line:first") + .should("be.visible") + .should("have.text", "Welcome to Etherpad!"); + }); }); diff --git a/src/tests/frontend/easysync-helper.js b/src/tests/frontend/easysync-helper.js index b4f770963..234d02501 100644 --- a/src/tests/frontend/easysync-helper.js +++ b/src/tests/frontend/easysync-helper.js @@ -1,222 +1,217 @@ -'use strict'; +"use strict"; -const Changeset = require('../../static/js/Changeset'); -const AttributePool = require('../../static/js/AttributePool'); +const Changeset = require("../../static/js/Changeset"); +const AttributePool = require("../../static/js/AttributePool"); const randInt = (maxValue) => Math.floor(Math.random() * maxValue); const poolOrArray = (attribs) => { - if (attribs.getAttrib) { - return attribs; // it's already an attrib pool - } else { - // assume it's an array of attrib strings to be split and added - const p = new AttributePool(); - attribs.forEach((kv) => { - p.putAttrib(kv.split(',')); - }); - return p; - } + if (attribs.getAttrib) { + return attribs; // it's already an attrib pool + } else { + // assume it's an array of attrib strings to be split and added + const p = new AttributePool(); + attribs.forEach((kv) => { + p.putAttrib(kv.split(",")); + }); + return p; + } }; exports.poolOrArray = poolOrArray; const randomInlineString = (len) => { - const assem = Changeset.stringAssembler(); - for (let i = 0; i < len; i++) { - assem.append(String.fromCharCode(randInt(26) + 97)); - } - return assem.toString(); + const assem = Changeset.stringAssembler(); + for (let i = 0; i < len; i++) { + assem.append(String.fromCharCode(randInt(26) + 97)); + } + return assem.toString(); }; const randomMultiline = (approxMaxLines, approxMaxCols) => { - const numParts = randInt(approxMaxLines * 2) + 1; - const txt = Changeset.stringAssembler(); - txt.append(randInt(2) ? '\n' : ''); - for (let i = 0; i < numParts; i++) { - if ((i % 2) === 0) { - if (randInt(10)) { - txt.append(randomInlineString(randInt(approxMaxCols) + 1)); - } else { - txt.append('\n'); - } - } else { - txt.append('\n'); - } - } - return txt.toString(); + const numParts = randInt(approxMaxLines * 2) + 1; + const txt = Changeset.stringAssembler(); + txt.append(randInt(2) ? "\n" : ""); + for (let i = 0; i < numParts; i++) { + if (i % 2 === 0) { + if (randInt(10)) { + txt.append(randomInlineString(randInt(approxMaxCols) + 1)); + } else { + txt.append("\n"); + } + } else { + txt.append("\n"); + } + } + return txt.toString(); }; exports.randomMultiline = randomMultiline; const randomStringOperation = (numCharsLeft) => { - let result; - switch (randInt(11)) { - case 0: - { - // insert char - result = { - insert: randomInlineString(1), - }; - break; - } - case 1: - { - // delete char - result = { - remove: 1, - }; - break; - } - case 2: - { - // skip char - result = { - skip: 1, - }; - break; - } - case 3: - { - // insert small - result = { - insert: randomInlineString(randInt(4) + 1), - }; - break; - } - case 4: - { - // delete small - result = { - remove: randInt(4) + 1, - }; - break; - } - case 5: - { - // skip small - result = { - skip: randInt(4) + 1, - }; - break; - } - case 6: - { - // insert multiline; - result = { - insert: randomMultiline(5, 20), - }; - break; - } - case 7: - { - // delete multiline - result = { - remove: Math.round(numCharsLeft * Math.random() * Math.random()), - }; - break; - } - case 8: - { - // skip multiline - result = { - skip: Math.round(numCharsLeft * Math.random() * Math.random()), - }; - break; - } - case 9: - { - // delete to end - result = { - remove: numCharsLeft, - }; - break; - } - case 10: - { - // skip to end - result = { - skip: numCharsLeft, - }; - break; - } - } - const maxOrig = numCharsLeft - 1; - if ('remove' in result) { - result.remove = Math.min(result.remove, maxOrig); - } else if ('skip' in result) { - result.skip = Math.min(result.skip, maxOrig); - } - return result; + let result; + switch (randInt(11)) { + case 0: { + // insert char + result = { + insert: randomInlineString(1), + }; + break; + } + case 1: { + // delete char + result = { + remove: 1, + }; + break; + } + case 2: { + // skip char + result = { + skip: 1, + }; + break; + } + case 3: { + // insert small + result = { + insert: randomInlineString(randInt(4) + 1), + }; + break; + } + case 4: { + // delete small + result = { + remove: randInt(4) + 1, + }; + break; + } + case 5: { + // skip small + result = { + skip: randInt(4) + 1, + }; + break; + } + case 6: { + // insert multiline; + result = { + insert: randomMultiline(5, 20), + }; + break; + } + case 7: { + // delete multiline + result = { + remove: Math.round(numCharsLeft * Math.random() * Math.random()), + }; + break; + } + case 8: { + // skip multiline + result = { + skip: Math.round(numCharsLeft * Math.random() * Math.random()), + }; + break; + } + case 9: { + // delete to end + result = { + remove: numCharsLeft, + }; + break; + } + case 10: { + // skip to end + result = { + skip: numCharsLeft, + }; + break; + } + } + const maxOrig = numCharsLeft - 1; + if ("remove" in result) { + result.remove = Math.min(result.remove, maxOrig); + } else if ("skip" in result) { + result.skip = Math.min(result.skip, maxOrig); + } + return result; }; const randomTwoPropAttribs = (opcode) => { - // assumes attrib pool like ['apple,','apple,true','banana,','banana,true'] - if (opcode === '-' || randInt(3)) { - return ''; - } else if (randInt(3)) { // eslint-disable-line no-dupe-else-if - if (opcode === '+' || randInt(2)) { - return `*${Changeset.numToString(randInt(2) * 2 + 1)}`; - } else { - return `*${Changeset.numToString(randInt(2) * 2)}`; - } - } else if (opcode === '+' || randInt(4) === 0) { - return '*1*3'; - } else { - return ['*0*2', '*0*3', '*1*2'][randInt(3)]; - } + // assumes attrib pool like ['apple,','apple,true','banana,','banana,true'] + if (opcode === "-" || randInt(3)) { + return ""; + } else if (randInt(3)) { + // eslint-disable-line no-dupe-else-if + if (opcode === "+" || randInt(2)) { + return `*${Changeset.numToString(randInt(2) * 2 + 1)}`; + } else { + return `*${Changeset.numToString(randInt(2) * 2)}`; + } + } else if (opcode === "+" || randInt(4) === 0) { + return "*1*3"; + } else { + return ["*0*2", "*0*3", "*1*2"][randInt(3)]; + } }; const randomTestChangeset = (origText, withAttribs) => { - const charBank = Changeset.stringAssembler(); - let textLeft = origText; // always keep final newline - const outTextAssem = Changeset.stringAssembler(); - const opAssem = Changeset.smartOpAssembler(); - const oldLen = origText.length; + const charBank = Changeset.stringAssembler(); + let textLeft = origText; // always keep final newline + const outTextAssem = Changeset.stringAssembler(); + const opAssem = Changeset.smartOpAssembler(); + const oldLen = origText.length; - const nextOp = new Changeset.Op(); + const nextOp = new Changeset.Op(); - const appendMultilineOp = (opcode, txt) => { - nextOp.opcode = opcode; - if (withAttribs) { - nextOp.attribs = randomTwoPropAttribs(opcode); - } - txt.replace(/\n|[^\n]+/g, (t) => { - if (t === '\n') { - nextOp.chars = 1; - nextOp.lines = 1; - opAssem.append(nextOp); - } else { - nextOp.chars = t.length; - nextOp.lines = 0; - opAssem.append(nextOp); - } - return ''; - }); - }; + const appendMultilineOp = (opcode, txt) => { + nextOp.opcode = opcode; + if (withAttribs) { + nextOp.attribs = randomTwoPropAttribs(opcode); + } + txt.replace(/\n|[^\n]+/g, (t) => { + if (t === "\n") { + nextOp.chars = 1; + nextOp.lines = 1; + opAssem.append(nextOp); + } else { + nextOp.chars = t.length; + nextOp.lines = 0; + opAssem.append(nextOp); + } + return ""; + }); + }; - const doOp = () => { - const o = randomStringOperation(textLeft.length); - if (o.insert) { - const txt = o.insert; - charBank.append(txt); - outTextAssem.append(txt); - appendMultilineOp('+', txt); - } else if (o.skip) { - const txt = textLeft.substring(0, o.skip); - textLeft = textLeft.substring(o.skip); - outTextAssem.append(txt); - appendMultilineOp('=', txt); - } else if (o.remove) { - const txt = textLeft.substring(0, o.remove); - textLeft = textLeft.substring(o.remove); - appendMultilineOp('-', txt); - } - }; + const doOp = () => { + const o = randomStringOperation(textLeft.length); + if (o.insert) { + const txt = o.insert; + charBank.append(txt); + outTextAssem.append(txt); + appendMultilineOp("+", txt); + } else if (o.skip) { + const txt = textLeft.substring(0, o.skip); + textLeft = textLeft.substring(o.skip); + outTextAssem.append(txt); + appendMultilineOp("=", txt); + } else if (o.remove) { + const txt = textLeft.substring(0, o.remove); + textLeft = textLeft.substring(o.remove); + appendMultilineOp("-", txt); + } + }; - while (textLeft.length > 1) doOp(); - for (let i = 0; i < 5; i++) doOp(); // do some more (only insertions will happen) - const outText = `${outTextAssem.toString()}\n`; - opAssem.endDocument(); - const cs = Changeset.pack(oldLen, outText.length, opAssem.toString(), charBank.toString()); - Changeset.checkRep(cs); - return [cs, outText]; + while (textLeft.length > 1) doOp(); + for (let i = 0; i < 5; i++) doOp(); // do some more (only insertions will happen) + const outText = `${outTextAssem.toString()}\n`; + opAssem.endDocument(); + const cs = Changeset.pack( + oldLen, + outText.length, + opAssem.toString(), + charBank.toString(), + ); + Changeset.checkRep(cs); + return [cs, outText]; }; exports.randomTestChangeset = randomTestChangeset; diff --git a/src/tests/frontend/helper.js b/src/tests/frontend/helper.js index 18981897e..9b7f6bea9 100644 --- a/src/tests/frontend/helper.js +++ b/src/tests/frontend/helper.js @@ -1,336 +1,392 @@ -'use strict'; +"use strict"; const helper = {}; (() => { - let $iframe; + let $iframe; - helper.randomString = (len) => { - const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; - let randomstring = ''; - for (let i = 0; i < len; i++) { - const rnum = Math.floor(Math.random() * chars.length); - randomstring += chars.substring(rnum, rnum + 1); - } - return randomstring; - }; + helper.randomString = (len) => { + const chars = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + let randomstring = ""; + for (let i = 0; i < len; i++) { + const rnum = Math.floor(Math.random() * chars.length); + randomstring += chars.substring(rnum, rnum + 1); + } + return randomstring; + }; - helper.getFrameJQuery = async ($iframe, includeSendkeys = false) => { - const win = $iframe[0].contentWindow; - const doc = win.document; + helper.getFrameJQuery = async ($iframe, includeSendkeys = false) => { + const win = $iframe[0].contentWindow; + const doc = win.document; - const load = async (url) => { - const elem = doc.createElement('script'); - elem.setAttribute('src', url); - const p = new Promise((resolve, reject) => { - const handler = (evt) => { - elem.removeEventListener('load', handler); - elem.removeEventListener('error', handler); - if (evt.type === 'error') return reject(new Error(`failed to load ${url}`)); - resolve(); - }; - elem.addEventListener('load', handler); - elem.addEventListener('error', handler); - }); - doc.head.appendChild(elem); - await p; - }; + const load = async (url) => { + const elem = doc.createElement("script"); + elem.setAttribute("src", url); + const p = new Promise((resolve, reject) => { + const handler = (evt) => { + elem.removeEventListener("load", handler); + elem.removeEventListener("error", handler); + if (evt.type === "error") + return reject(new Error(`failed to load ${url}`)); + resolve(); + }; + elem.addEventListener("load", handler); + elem.addEventListener("error", handler); + }); + doc.head.appendChild(elem); + await p; + }; - if (!win.$) await load('../../static/js/vendors/jquery.js'); - // sendkeys.js depends on jQuery, so it cannot be loaded until jQuery has finished loading. (In - // other words, do not load both jQuery and sendkeys inside a Promise.all() call.) - if (!win.bililiteRange && includeSendkeys) await load('../tests/frontend/lib/sendkeys.js'); + if (!win.$) await load("../../static/js/vendors/jquery.js"); + // sendkeys.js depends on jQuery, so it cannot be loaded until jQuery has finished loading. (In + // other words, do not load both jQuery and sendkeys inside a Promise.all() call.) + if (!win.bililiteRange && includeSendkeys) + await load("../tests/frontend/lib/sendkeys.js"); - win.$.window = win; - win.$.document = doc; + win.$.window = win; + win.$.document = doc; - return win.$; - }; + return win.$; + }; - helper.clearSessionCookies = () => { - window.Cookies.remove('token'); - window.Cookies.remove('language'); - }; + helper.clearSessionCookies = () => { + window.Cookies.remove("token"); + window.Cookies.remove("language"); + }; - // Can only happen when the iframe exists, so we're doing it separately from other cookies - helper.clearPadPrefCookie = () => { - const {padcookie} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_cookie'); - padcookie.clear(); - }; + // Can only happen when the iframe exists, so we're doing it separately from other cookies + helper.clearPadPrefCookie = () => { + const { padcookie } = helper.padChrome$.window.require( + "ep_etherpad-lite/static/js/pad_cookie", + ); + padcookie.clear(); + }; - // Overwrite all prefs in pad cookie. - helper.setPadPrefCookie = (prefs) => { - const {padcookie} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_cookie'); - padcookie.clear(); - for (const [key, value] of Object.entries(prefs)) padcookie.setPref(key, value); - }; + // Overwrite all prefs in pad cookie. + helper.setPadPrefCookie = (prefs) => { + const { padcookie } = helper.padChrome$.window.require( + "ep_etherpad-lite/static/js/pad_cookie", + ); + padcookie.clear(); + for (const [key, value] of Object.entries(prefs)) + padcookie.setPref(key, value); + }; - // Functionality for knowing what key event type is required for tests - let evtType = 'keydown'; - // if it's IE require keypress - if (window.navigator.userAgent.indexOf('MSIE') > -1) { - evtType = 'keypress'; - } - // Edge also requires keypress. - if (window.navigator.userAgent.indexOf('Edge') > -1) { - evtType = 'keypress'; - } - // Opera also requires keypress. - if (window.navigator.userAgent.indexOf('OPR') > -1) { - evtType = 'keypress'; - } - helper.evtType = evtType; + // Functionality for knowing what key event type is required for tests + let evtType = "keydown"; + // if it's IE require keypress + if (window.navigator.userAgent.indexOf("MSIE") > -1) { + evtType = "keypress"; + } + // Edge also requires keypress. + if (window.navigator.userAgent.indexOf("Edge") > -1) { + evtType = "keypress"; + } + // Opera also requires keypress. + if (window.navigator.userAgent.indexOf("OPR") > -1) { + evtType = "keypress"; + } + helper.evtType = evtType; - // Deprecated; use helper.aNewPad() instead. - helper.newPad = (opts, id) => { - if (!id) id = `FRONTEND_TEST_${helper.randomString(20)}`; - opts = Object.assign({id}, typeof opts === 'function' ? {cb: opts} : opts); - const {cb = (err) => { if (err != null) throw err; }} = opts; - delete opts.cb; - helper.aNewPad(opts).then((id) => cb(null, id), (err) => cb(err || new Error(err))); - return id; - }; + // Deprecated; use helper.aNewPad() instead. + helper.newPad = (opts, id) => { + if (!id) id = `FRONTEND_TEST_${helper.randomString(20)}`; + opts = Object.assign( + { id }, + typeof opts === "function" ? { cb: opts } : opts, + ); + const { + cb = (err) => { + if (err != null) throw err; + }, + } = opts; + delete opts.cb; + helper.aNewPad(opts).then( + (id) => cb(null, id), + (err) => cb(err || new Error(err)), + ); + return id; + }; - helper.aNewPad = async (opts = {}) => { - opts = Object.assign({ - _retry: 0, - clearCookies: true, - id: `FRONTEND_TEST_${helper.randomString(20)}`, - hookFns: {}, - }, opts); + helper.aNewPad = async (opts = {}) => { + opts = Object.assign( + { + _retry: 0, + clearCookies: true, + id: `FRONTEND_TEST_${helper.randomString(20)}`, + hookFns: {}, + }, + opts, + ); - // Set up socket.io spying as early as possible. - /** chat messages received */ - helper.chatMessages = []; - /** changeset commits from the server */ - helper.commits = []; - /** userInfo messages from the server */ - helper.userInfos = []; - if (opts.hookFns._socketCreated == null) opts.hookFns._socketCreated = []; - opts.hookFns._socketCreated.unshift(() => helper.spyOnSocketIO()); + // Set up socket.io spying as early as possible. + /** chat messages received */ + helper.chatMessages = []; + /** changeset commits from the server */ + helper.commits = []; + /** userInfo messages from the server */ + helper.userInfos = []; + if (opts.hookFns._socketCreated == null) opts.hookFns._socketCreated = []; + opts.hookFns._socketCreated.unshift(() => helper.spyOnSocketIO()); - // if opts.params is set we manipulate the URL to include URL parameters IE ?foo=Bah. - let encodedParams; - if (opts.params) { - encodedParams = `?${$.param(opts.params)}`; - } - let hash; - if (opts.hash) { - hash = `#${opts.hash}`; - } + // if opts.params is set we manipulate the URL to include URL parameters IE ?foo=Bah. + let encodedParams; + if (opts.params) { + encodedParams = `?${$.param(opts.params)}`; + } + let hash; + if (opts.hash) { + hash = `#${opts.hash}`; + } - // clear cookies - if (opts.clearCookies) { - helper.clearSessionCookies(); - } + // clear cookies + if (opts.clearCookies) { + helper.clearSessionCookies(); + } - $iframe = $(``); + $iframe = $( + ``, + ); - // clean up inner iframe references - helper.padChrome$ = helper.padOuter$ = helper.padInner$ = null; + // clean up inner iframe references + helper.padChrome$ = helper.padOuter$ = helper.padInner$ = null; - // remove old iframe - $('#iframe-container iframe').remove(); - // set new iframe - $('#iframe-container').append($iframe); - await Promise.all([ - new Promise((resolve) => $iframe.one('load', resolve)), - // Install the hook functions as early as possible because some of them fire right away. - new Promise((resolve, reject) => { - if ($iframe[0].contentWindow._postPluginUpdateForTestingDone) { - return reject(new Error( - 'failed to set _postPluginUpdateForTesting before it would have been called')); - } - $iframe[0].contentWindow._postPluginUpdateForTesting = () => { - const {hooks} = - $iframe[0].contentWindow.require('ep_etherpad-lite/static/js/pluginfw/plugin_defs'); - for (const [hookName, hookFns] of Object.entries(opts.hookFns)) { - if (hooks[hookName] == null) hooks[hookName] = []; - hooks[hookName].push( - ...hookFns.map((hookFn) => ({hook_name: hookName, hook_fn: hookFn}))); - } - resolve(); - }; - }), - ]); - helper.padChrome$ = await helper.getFrameJQuery($('#iframe-container iframe'), true); - helper.padChrome$.padeditor = - helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_editor').padeditor; - if (opts.clearCookies) { - helper.clearPadPrefCookie(); - } - if (opts.padPrefs) { - helper.setPadPrefCookie(opts.padPrefs); - } - const $loading = helper.padChrome$('#editorloadingbox'); - const $container = helper.padChrome$('#editorcontainer'); - try { - await helper.waitForPromise( - () => !$loading.is(':visible') && $container.hasClass('initialized'), 10000); - } catch (err) { - if (opts._retry++ >= 4) throw new Error('Pad never loaded'); - return await helper.aNewPad(opts); - } - helper.padOuter$ = - await helper.getFrameJQuery(helper.padChrome$('iframe[name="ace_outer"]'), false); - helper.padInner$ = - await helper.getFrameJQuery(helper.padOuter$('iframe[name="ace_inner"]'), true); + // remove old iframe + $("#iframe-container iframe").remove(); + // set new iframe + $("#iframe-container").append($iframe); + await Promise.all([ + new Promise((resolve) => $iframe.one("load", resolve)), + // Install the hook functions as early as possible because some of them fire right away. + new Promise((resolve, reject) => { + if ($iframe[0].contentWindow._postPluginUpdateForTestingDone) { + return reject( + new Error( + "failed to set _postPluginUpdateForTesting before it would have been called", + ), + ); + } + $iframe[0].contentWindow._postPluginUpdateForTesting = () => { + const { hooks } = $iframe[0].contentWindow.require( + "ep_etherpad-lite/static/js/pluginfw/plugin_defs", + ); + for (const [hookName, hookFns] of Object.entries(opts.hookFns)) { + if (hooks[hookName] == null) hooks[hookName] = []; + hooks[hookName].push( + ...hookFns.map((hookFn) => ({ + hook_name: hookName, + hook_fn: hookFn, + })), + ); + } + resolve(); + }; + }), + ]); + helper.padChrome$ = await helper.getFrameJQuery( + $("#iframe-container iframe"), + true, + ); + helper.padChrome$.padeditor = helper.padChrome$.window.require( + "ep_etherpad-lite/static/js/pad_editor", + ).padeditor; + if (opts.clearCookies) { + helper.clearPadPrefCookie(); + } + if (opts.padPrefs) { + helper.setPadPrefCookie(opts.padPrefs); + } + const $loading = helper.padChrome$("#editorloadingbox"); + const $container = helper.padChrome$("#editorcontainer"); + try { + await helper.waitForPromise( + () => !$loading.is(":visible") && $container.hasClass("initialized"), + 10000, + ); + } catch (err) { + if (opts._retry++ >= 4) throw new Error("Pad never loaded"); + return await helper.aNewPad(opts); + } + helper.padOuter$ = await helper.getFrameJQuery( + helper.padChrome$('iframe[name="ace_outer"]'), + false, + ); + helper.padInner$ = await helper.getFrameJQuery( + helper.padOuter$('iframe[name="ace_inner"]'), + true, + ); - // disable all animations, this makes tests faster and easier - helper.padChrome$.fx.off = true; - helper.padOuter$.fx.off = true; - helper.padInner$.fx.off = true; + // disable all animations, this makes tests faster and easier + helper.padChrome$.fx.off = true; + helper.padOuter$.fx.off = true; + helper.padInner$.fx.off = true; - // 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; - }; + // 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) => { - // define the iframe - $iframe = $(``); + helper.newAdmin = async (page) => { + // define the iframe + $iframe = $(``); - // clean up inner iframe references - helper.admin$ = null; + // clean up inner iframe references + helper.admin$ = null; - // remove old iframe - $('#iframe-container iframe').remove(); - // set new iframe - $('#iframe-container').append($iframe); - $iframe.one('load', async () => { - helper.admin$ = await helper.getFrameJQuery($('#iframe-container iframe'), false); - }); - }; + // remove old iframe + $("#iframe-container iframe").remove(); + // set new iframe + $("#iframe-container").append($iframe); + $iframe.one("load", async () => { + helper.admin$ = await helper.getFrameJQuery( + $("#iframe-container iframe"), + false, + ); + }); + }; - helper.waitFor = (conditionFunc, timeoutTime = 1900, intervalTime = 10) => { - // Create an Error object to use if the condition is never satisfied. This is created here so - // that the Error has a useful stack trace associated with it. - const timeoutError = - new Error(`waitFor condition never became true ${conditionFunc.toString()}`); - const deferred = new $.Deferred(); + helper.waitFor = (conditionFunc, timeoutTime = 1900, intervalTime = 10) => { + // Create an Error object to use if the condition is never satisfied. This is created here so + // that the Error has a useful stack trace associated with it. + const timeoutError = new Error( + `waitFor condition never became true ${conditionFunc.toString()}`, + ); + const deferred = new $.Deferred(); - const _fail = deferred.fail.bind(deferred); - let listenForFail = false; - deferred.fail = (...args) => { - listenForFail = true; - return _fail(...args); - }; + const _fail = deferred.fail.bind(deferred); + let listenForFail = false; + deferred.fail = (...args) => { + listenForFail = true; + return _fail(...args); + }; - const check = async () => { - try { - if (!await conditionFunc()) return; - deferred.resolve(); - } catch (err) { - deferred.reject(err); - } - clearInterval(intervalCheck); - clearTimeout(timeout); - }; + const check = async () => { + try { + if (!(await conditionFunc())) return; + deferred.resolve(); + } catch (err) { + deferred.reject(err); + } + clearInterval(intervalCheck); + clearTimeout(timeout); + }; - const intervalCheck = setInterval(check, intervalTime); + const intervalCheck = setInterval(check, intervalTime); - const timeout = setTimeout(() => { - clearInterval(intervalCheck); - deferred.reject(timeoutError); + const timeout = setTimeout(() => { + clearInterval(intervalCheck); + deferred.reject(timeoutError); - if (!listenForFail) { - throw timeoutError; - } - }, timeoutTime); + if (!listenForFail) { + throw timeoutError; + } + }, timeoutTime); - // Check right away to avoid an unnecessary sleep if the condition is already true. - check(); + // Check right away to avoid an unnecessary sleep if the condition is already true. + check(); - return deferred; - }; + return deferred; + }; - /** - * Same as `waitFor` but using Promises - * - * @returns {Promise} - * - */ - // Note: waitFor() has a strange API: On timeout it rejects, but it also throws an uncatchable - // exception unless .fail() has been called. That uncatchable exception is disabled here by - // passing a no-op function to .fail(). - helper.waitForPromise = async (...args) => await helper.waitFor(...args).fail(() => {}); + /** + * Same as `waitFor` but using Promises + * + * @returns {Promise} + * + */ + // Note: waitFor() has a strange API: On timeout it rejects, but it also throws an uncatchable + // exception unless .fail() has been called. That uncatchable exception is disabled here by + // passing a no-op function to .fail(). + helper.waitForPromise = async (...args) => + await helper.waitFor(...args).fail(() => {}); - helper.selectLines = ($startLine, $endLine, startOffset, endOffset) => { - // if no offset is provided, use beginning of start line and end of end line - startOffset = startOffset || 0; - endOffset = endOffset === undefined ? $endLine.text().length : endOffset; + helper.selectLines = ($startLine, $endLine, startOffset, endOffset) => { + // if no offset is provided, use beginning of start line and end of end line + startOffset = startOffset || 0; + endOffset = endOffset === undefined ? $endLine.text().length : endOffset; - const inner$ = helper.padInner$; - const selection = inner$.document.getSelection(); - const range = selection.getRangeAt(0); + const inner$ = helper.padInner$; + const selection = inner$.document.getSelection(); + const range = selection.getRangeAt(0); - const start = getTextNodeAndOffsetOf($startLine, startOffset); - const end = getTextNodeAndOffsetOf($endLine, endOffset); + const start = getTextNodeAndOffsetOf($startLine, startOffset); + const end = getTextNodeAndOffsetOf($endLine, endOffset); - range.setStart(start.node, start.offset); - range.setEnd(end.node, end.offset); + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset); - selection.removeAllRanges(); - selection.addRange(range); - }; + selection.removeAllRanges(); + selection.addRange(range); + }; - // Temporarily reduces minimum time between commits and calls the provided function with a single - // argument: a function that immediately incorporates all pad edits (as opposed to waiting for the - // idle timer to fire). - helper.withFastCommit = async (fn) => { - const incorp = () => helper.padChrome$.padeditor.ace.callWithAce( - (ace) => ace.ace_inCallStackIfNecessary('helper.edit', () => ace.ace_fastIncorp())); - const cc = helper.padChrome$.window.pad.collabClient; - const {commitDelay} = cc; - cc.commitDelay = 0; - try { - return await fn(incorp); - } finally { - cc.commitDelay = commitDelay; - } - }; + // Temporarily reduces minimum time between commits and calls the provided function with a single + // argument: a function that immediately incorporates all pad edits (as opposed to waiting for the + // idle timer to fire). + helper.withFastCommit = async (fn) => { + const incorp = () => + helper.padChrome$.padeditor.ace.callWithAce((ace) => + ace.ace_inCallStackIfNecessary("helper.edit", () => + ace.ace_fastIncorp(), + ), + ); + const cc = helper.padChrome$.window.pad.collabClient; + const { commitDelay } = cc; + cc.commitDelay = 0; + try { + return await fn(incorp); + } finally { + cc.commitDelay = commitDelay; + } + }; - const getTextNodeAndOffsetOf = ($targetLine, targetOffsetAtLine) => { - const $textNodes = $targetLine.find('*').contents().filter(function () { - return this.nodeType === Node.TEXT_NODE; - }); + const getTextNodeAndOffsetOf = ($targetLine, targetOffsetAtLine) => { + const $textNodes = $targetLine + .find("*") + .contents() + .filter(function () { + return this.nodeType === Node.TEXT_NODE; + }); - // search node where targetOffsetAtLine is reached, and its 'inner offset' - let textNodeWhereOffsetIs = null; - let offsetBeforeTextNode = 0; - let offsetInsideTextNode = 0; - $textNodes.each((index, element) => { - const elementTotalOffset = element.textContent.length; - textNodeWhereOffsetIs = element; - offsetInsideTextNode = targetOffsetAtLine - offsetBeforeTextNode; + // search node where targetOffsetAtLine is reached, and its 'inner offset' + let textNodeWhereOffsetIs = null; + let offsetBeforeTextNode = 0; + let offsetInsideTextNode = 0; + $textNodes.each((index, element) => { + const elementTotalOffset = element.textContent.length; + textNodeWhereOffsetIs = element; + offsetInsideTextNode = targetOffsetAtLine - offsetBeforeTextNode; - const foundTextNode = offsetBeforeTextNode + elementTotalOffset >= targetOffsetAtLine; - if (foundTextNode) { - return false; // stop .each by returning false - } + const foundTextNode = + offsetBeforeTextNode + elementTotalOffset >= targetOffsetAtLine; + if (foundTextNode) { + return false; // stop .each by returning false + } - offsetBeforeTextNode += elementTotalOffset; - }); + offsetBeforeTextNode += elementTotalOffset; + }); - // edge cases - if (textNodeWhereOffsetIs == null) { - // there was no text node inside $targetLine, so it is an empty line (
              ). - // Use beginning of line - textNodeWhereOffsetIs = $targetLine.get(0); - offsetInsideTextNode = 0; - } - // avoid errors if provided targetOffsetAtLine is higher than line offset (maxOffset). - // Use max allowed instead - const maxOffset = textNodeWhereOffsetIs.textContent.length; - offsetInsideTextNode = Math.min(offsetInsideTextNode, maxOffset); + // edge cases + if (textNodeWhereOffsetIs == null) { + // there was no text node inside $targetLine, so it is an empty line (
              ). + // Use beginning of line + textNodeWhereOffsetIs = $targetLine.get(0); + offsetInsideTextNode = 0; + } + // avoid errors if provided targetOffsetAtLine is higher than line offset (maxOffset). + // Use max allowed instead + const maxOffset = textNodeWhereOffsetIs.textContent.length; + offsetInsideTextNode = Math.min(offsetInsideTextNode, maxOffset); - return { - node: textNodeWhereOffsetIs, - offset: offsetInsideTextNode, - }; - }; + return { + node: textNodeWhereOffsetIs, + offset: offsetInsideTextNode, + }; + }; - /* Ensure console.log doesn't blow up in IE, ugly but ok for a test framework imho*/ - window.console = window.console || {}; - window.console.log = window.console.log || (() => {}); + /* Ensure console.log doesn't blow up in IE, ugly but ok for a test framework imho*/ + window.console = window.console || {}; + window.console.log = window.console.log || (() => {}); })(); diff --git a/src/tests/frontend/helper/methods.js b/src/tests/frontend/helper/methods.js index 97ea6a643..3e0a87176 100644 --- a/src/tests/frontend/helper/methods.js +++ b/src/tests/frontend/helper/methods.js @@ -1,22 +1,22 @@ -'use strict'; +"use strict"; /** * Spys on socket.io messages and saves them into several arrays * that are visible in tests */ helper.spyOnSocketIO = () => { - helper.contentWindow().pad.socket.on('message', (msg) => { - if (msg.type !== 'COLLABROOM') return; - if (msg.data.type === 'ACCEPT_COMMIT') { - helper.commits.push(msg); - } else if (msg.data.type === 'USER_NEWINFO') { - helper.userInfos.push(msg); - } else if (msg.data.type === 'CHAT_MESSAGE') { - helper.chatMessages.push(msg.data.message); - } else if (msg.data.type === 'CHAT_MESSAGES') { - helper.chatMessages.push(...msg.data.messages); - } - }); + helper.contentWindow().pad.socket.on("message", (msg) => { + if (msg.type !== "COLLABROOM") return; + if (msg.data.type === "ACCEPT_COMMIT") { + helper.commits.push(msg); + } else if (msg.data.type === "USER_NEWINFO") { + helper.userInfos.push(msg); + } else if (msg.data.type === "CHAT_MESSAGE") { + helper.chatMessages.push(msg.data.message); + } else if (msg.data.type === "CHAT_MESSAGES") { + helper.chatMessages.push(...msg.data.messages); + } + }); }; /** @@ -32,13 +32,16 @@ helper.spyOnSocketIO = () => { * */ helper.edit = async (message, line) => { - const editsNum = helper.commits.length; - line = line ? line - 1 : 0; - await helper.withFastCommit(async (incorp) => { - helper.linesDiv()[line].sendkeys(message); - incorp(); - await helper.waitForPromise(() => editsNum + 1 === helper.commits.length, 10000); - }); + const editsNum = helper.commits.length; + line = line ? line - 1 : 0; + await helper.withFastCommit(async (incorp) => { + helper.linesDiv()[line].sendkeys(message); + incorp(); + await helper.waitForPromise( + () => editsNum + 1 === helper.commits.length, + 10000, + ); + }); }; /** @@ -49,7 +52,13 @@ helper.edit = async (message, line) => { * * @returns {Array.} array of divs */ -helper.linesDiv = () => helper.padInner$('.ace-line').map(function () { return $(this); }).get(); +helper.linesDiv = () => + helper + .padInner$(".ace-line") + .map(function () { + return $(this); + }) + .get(); /** * The pad text as an array of lines @@ -64,8 +73,9 @@ helper.textLines = () => helper.linesDiv().map((div) => div.text()); * * @returns {string} */ -helper.defaultText = - () => helper.padChrome$.window.clientVars.collab_client_vars.initialAttributedText.text; +helper.defaultText = () => + helper.padChrome$.window.clientVars.collab_client_vars.initialAttributedText + .text; /** * Sends a chat `message` via `sendKeys` @@ -82,9 +92,11 @@ helper.defaultText = * @returns {Promise} */ helper.sendChatMessage = async (message) => { - const noOfChatMessages = helper.chatMessages.length; - helper.padChrome$('#chatinput').sendkeys(message); - await helper.waitForPromise(() => noOfChatMessages + 1 === helper.chatMessages.length); + const noOfChatMessages = helper.chatMessages.length; + helper.padChrome$("#chatinput").sendkeys(message); + await helper.waitForPromise( + () => noOfChatMessages + 1 === helper.chatMessages.length, + ); }; /** @@ -93,9 +105,9 @@ helper.sendChatMessage = async (message) => { * @returns {Promise} */ helper.showSettings = async () => { - if (helper.isSettingsShown()) return; - helper.settingsButton().trigger('click'); - await helper.waitForPromise(() => helper.isSettingsShown(), 2000); + if (helper.isSettingsShown()) return; + helper.settingsButton().trigger("click"); + await helper.waitForPromise(() => helper.isSettingsShown(), 2000); }; /** @@ -105,9 +117,9 @@ helper.showSettings = async () => { * @todo untested */ helper.hideSettings = async () => { - if (!helper.isSettingsShown()) return; - helper.settingsButton().trigger('click'); - await helper.waitForPromise(() => !helper.isSettingsShown(), 2000); + if (!helper.isSettingsShown()) return; + helper.settingsButton().trigger("click"); + await helper.waitForPromise(() => !helper.isSettingsShown(), 2000); }; /** @@ -117,10 +129,10 @@ helper.hideSettings = async () => { * @returns {Promise} */ helper.enableStickyChatviaSettings = async () => { - const stickyChat = helper.padChrome$('#options-stickychat'); - if (!helper.isSettingsShown() || stickyChat.is(':checked')) return; - stickyChat.trigger('click'); - await helper.waitForPromise(() => helper.isChatboxSticky(), 2000); + const stickyChat = helper.padChrome$("#options-stickychat"); + if (!helper.isSettingsShown() || stickyChat.is(":checked")) return; + stickyChat.trigger("click"); + await helper.waitForPromise(() => helper.isChatboxSticky(), 2000); }; /** @@ -130,10 +142,10 @@ helper.enableStickyChatviaSettings = async () => { * @returns {Promise} */ helper.disableStickyChatviaSettings = async () => { - const stickyChat = helper.padChrome$('#options-stickychat'); - if (!helper.isSettingsShown() || !stickyChat.is(':checked')) return; - stickyChat.trigger('click'); - await helper.waitForPromise(() => !helper.isChatboxSticky(), 2000); + const stickyChat = helper.padChrome$("#options-stickychat"); + if (!helper.isSettingsShown() || !stickyChat.is(":checked")) return; + stickyChat.trigger("click"); + await helper.waitForPromise(() => !helper.isChatboxSticky(), 2000); }; /** @@ -143,10 +155,10 @@ helper.disableStickyChatviaSettings = async () => { * @returns {Promise} */ helper.enableStickyChatviaIcon = async () => { - const stickyChat = helper.padChrome$('#titlesticky'); - if (!helper.isChatboxShown() || helper.isChatboxSticky()) return; - stickyChat.trigger('click'); - await helper.waitForPromise(() => helper.isChatboxSticky(), 2000); + const stickyChat = helper.padChrome$("#titlesticky"); + if (!helper.isChatboxShown() || helper.isChatboxSticky()) return; + stickyChat.trigger("click"); + await helper.waitForPromise(() => helper.isChatboxSticky(), 2000); }; /** @@ -156,9 +168,9 @@ helper.enableStickyChatviaIcon = async () => { * @returns {Promise} */ helper.disableStickyChatviaIcon = async () => { - if (!helper.isChatboxShown() || !helper.isChatboxSticky()) return; - helper.titlecross().trigger('click'); - await helper.waitForPromise(() => !helper.isChatboxSticky(), 2000); + if (!helper.isChatboxShown() || !helper.isChatboxSticky()) return; + helper.titlecross().trigger("click"); + await helper.waitForPromise(() => !helper.isChatboxSticky(), 2000); }; /** @@ -174,11 +186,14 @@ helper.disableStickyChatviaIcon = async () => { * goto rev 0 and then via the same method to rev 5. Use buttons instead */ helper.gotoTimeslider = async (revision) => { - revision = Number.isInteger(revision) ? `#${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); + revision = Number.isInteger(revision) ? `#${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, + ); }; /** @@ -189,14 +204,14 @@ helper.gotoTimeslider = async (revision) => { * @param {number} X coordinate */ helper.sliderClick = (X) => { - const sliderBar = helper.sliderBar(); - const edown = new jQuery.Event('mousedown'); - const eup = new jQuery.Event('mouseup'); - edown.clientX = eup.clientX = X; - edown.clientY = eup.clientY = sliderBar.offset().top; + const sliderBar = helper.sliderBar(); + const edown = new jQuery.Event("mousedown"); + const eup = new jQuery.Event("mouseup"); + edown.clientX = eup.clientX = X; + edown.clientY = eup.clientY = sliderBar.offset().top; - sliderBar.trigger(edown); - sliderBar.trigger(eup); + sliderBar.trigger(edown); + sliderBar.trigger(eup); }; /** @@ -204,26 +219,34 @@ helper.sliderClick = (X) => { * * @returns {Array.} lines of text */ -helper.timesliderTextLines = () => helper.contentWindow().$('.ace-line').map(function () { - return $(this).text(); -}).get(); +helper.timesliderTextLines = () => + helper + .contentWindow() + .$(".ace-line") + .map(function () { + return $(this).text(); + }) + .get(); -helper.padIsEmpty = () => ( - !helper.padInner$.document.getSelection().isCollapsed || - (helper.padInner$('div').length === 1 && helper.padInner$('div').first().html() === '
              ')); +helper.padIsEmpty = () => + !helper.padInner$.document.getSelection().isCollapsed || + (helper.padInner$("div").length === 1 && + helper.padInner$("div").first().html() === "
              "); helper.clearPad = async () => { - if (helper.padIsEmpty()) return; - const commitsBefore = helper.commits.length; - const lines = helper.linesDiv(); - helper.selectLines(lines[0], lines[lines.length - 1]); - await helper.waitForPromise(() => !helper.padInner$.document.getSelection().isCollapsed); - const e = new helper.padInner$.Event(helper.evtType); - e.keyCode = 8; // delete key - await helper.withFastCommit(async (incorp) => { - helper.padInner$('#innerdocbody').trigger(e); - incorp(); - await helper.waitForPromise(helper.padIsEmpty); - await helper.waitForPromise(() => helper.commits.length > commitsBefore); - }); + if (helper.padIsEmpty()) return; + const commitsBefore = helper.commits.length; + const lines = helper.linesDiv(); + helper.selectLines(lines[0], lines[lines.length - 1]); + await helper.waitForPromise( + () => !helper.padInner$.document.getSelection().isCollapsed, + ); + const e = new helper.padInner$.Event(helper.evtType); + e.keyCode = 8; // delete key + await helper.withFastCommit(async (incorp) => { + helper.padInner$("#innerdocbody").trigger(e); + incorp(); + await helper.waitForPromise(helper.padIsEmpty); + await helper.waitForPromise(() => helper.commits.length > commitsBefore); + }); }; diff --git a/src/tests/frontend/helper/multipleUsers.js b/src/tests/frontend/helper/multipleUsers.js index 831bf403e..cd375ad9f 100644 --- a/src/tests/frontend/helper/multipleUsers.js +++ b/src/tests/frontend/helper/multipleUsers.js @@ -1,93 +1,105 @@ -'use strict'; +"use strict"; -const getCookies = - () => helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_utils').Cookies; +const getCookies = () => + helper.padChrome$.window.require("ep_etherpad-lite/static/js/pad_utils") + .Cookies; -const setToken = (token) => getCookies().set('token', token); +const setToken = (token) => getCookies().set("token", token); -const getToken = () => getCookies().get('token'); +const getToken = () => getCookies().get("token"); const startActingLike = (user) => { - helper.padChrome$ = user.padChrome$; - helper.padOuter$ = user.padOuter$; - helper.padInner$ = user.padInner$; - if (helper.padChrome$) setToken(user.token); + helper.padChrome$ = user.padChrome$; + helper.padOuter$ = user.padOuter$; + helper.padInner$ = user.padInner$; + if (helper.padChrome$) setToken(user.token); }; -const clearToken = () => getCookies().remove('token'); +const clearToken = () => getCookies().remove("token"); helper.multipleUsers = { - _user0: null, - _user1: null, + _user0: null, + _user1: null, - // open the same pad on different frames (allows concurrent editions to pad) - async init() { - this._user0 = { - $frame: $('#iframe-container iframe'), - token: getToken(), - // we'll switch between pads, need to store current values of helper.pad* - // to be able to restore those values later - padChrome$: helper.padChrome$, - padOuter$: helper.padOuter$, - padInner$: helper.padInner$, - }; - this._user1 = {}; - // Force generation of a new token. - clearToken(); - // need to perform as the other user, otherwise we'll get the userdup error message - await this.performAsOtherUser(this._createUser1Frame.bind(this)); - }, + // open the same pad on different frames (allows concurrent editions to pad) + async init() { + this._user0 = { + $frame: $("#iframe-container iframe"), + token: getToken(), + // we'll switch between pads, need to store current values of helper.pad* + // to be able to restore those values later + padChrome$: helper.padChrome$, + padOuter$: helper.padOuter$, + padInner$: helper.padInner$, + }; + this._user1 = {}; + // Force generation of a new token. + clearToken(); + // need to perform as the other user, otherwise we'll get the userdup error message + await this.performAsOtherUser(this._createUser1Frame.bind(this)); + }, - async performAsOtherUser(action) { - startActingLike(this._user1); - await action(); - startActingLike(this._user0); - }, + async performAsOtherUser(action) { + startActingLike(this._user1); + await action(); + startActingLike(this._user0); + }, - close() { - this._user0.$frame.attr('style', ''); // make the default ocopy the full height - this._user1.$frame.remove(); - }, + close() { + this._user0.$frame.attr("style", ""); // make the default ocopy the full height + this._user1.$frame.remove(); + }, - async _loadJQueryForUser1Frame() { - this._user1.padChrome$ = await helper.getFrameJQuery(this._user1.$frame, true); - this._user1.padOuter$ = - await helper.getFrameJQuery(this._user1.padChrome$('iframe[name="ace_outer"]'), false); - this._user1.padInner$ = - await helper.getFrameJQuery(this._user1.padOuter$('iframe[name="ace_inner"]'), true); + async _loadJQueryForUser1Frame() { + this._user1.padChrome$ = await helper.getFrameJQuery( + this._user1.$frame, + true, + ); + this._user1.padOuter$ = await helper.getFrameJQuery( + this._user1.padChrome$('iframe[name="ace_outer"]'), + false, + ); + this._user1.padInner$ = await helper.getFrameJQuery( + this._user1.padOuter$('iframe[name="ace_inner"]'), + true, + ); - // update helper vars now that they are available - helper.padChrome$ = this._user1.padChrome$; - helper.padOuter$ = this._user1.padOuter$; - helper.padInner$ = this._user1.padInner$; - }, + // update helper vars now that they are available + helper.padChrome$ = this._user1.padChrome$; + helper.padOuter$ = this._user1.padOuter$; + helper.padInner$ = this._user1.padInner$; + }, - async _createUser1Frame() { - this._user0.$frame.css({height: '50%'}); - this._user1.$frame = $('`); - $originalPadFrame = $('#iframe-container iframe'); - $otherIframeWithSamePad.insertAfter($originalPadFrame); + // open same pad on another iframe, to force userdup error + const $otherIframeWithSamePad = $( + ``, + ); + $originalPadFrame = $("#iframe-container iframe"); + $otherIframeWithSamePad.insertAfter($originalPadFrame); - // wait for modal to be displayed - await helper.waitForPromise(() => $errorMessageModal.is(':visible'), 50000); - }); + // wait for modal to be displayed + await helper.waitForPromise(() => $errorMessageModal.is(":visible"), 50000); + }); - it('displays a count down timer to automatically reconnect', async function () { - const $errorMessageModal = helper.padChrome$('#connectivity .userdup'); - const $countDownTimer = $errorMessageModal.find('.reconnecttimer'); + it("displays a count down timer to automatically reconnect", async function () { + const $errorMessageModal = helper.padChrome$("#connectivity .userdup"); + const $countDownTimer = $errorMessageModal.find(".reconnecttimer"); - expect($countDownTimer.is(':visible')).to.be(true); - }); + expect($countDownTimer.is(":visible")).to.be(true); + }); - context('and user clicks on Cancel', function () { - beforeEach(async function () { - const $errorMessageModal = helper.padChrome$('#connectivity .userdup'); - $errorMessageModal.find('#cancelreconnect').trigger('click'); - await helper.waitForPromise( - () => helper.padChrome$('#connectivity .userdup').is(':visible') === true); - }); + context("and user clicks on Cancel", function () { + beforeEach(async function () { + const $errorMessageModal = helper.padChrome$("#connectivity .userdup"); + $errorMessageModal.find("#cancelreconnect").trigger("click"); + await helper.waitForPromise( + () => + helper.padChrome$("#connectivity .userdup").is(":visible") === true, + ); + }); - it('does not show Cancel button nor timer anymore', async function () { - const $errorMessageModal = helper.padChrome$('#connectivity .userdup'); - const $countDownTimer = $errorMessageModal.find('.reconnecttimer'); - const $cancelButton = $errorMessageModal.find('#cancelreconnect'); + it("does not show Cancel button nor timer anymore", async function () { + const $errorMessageModal = helper.padChrome$("#connectivity .userdup"); + const $countDownTimer = $errorMessageModal.find(".reconnecttimer"); + const $cancelButton = $errorMessageModal.find("#cancelreconnect"); - expect($countDownTimer.is(':visible')).to.be(false); - expect($cancelButton.is(':visible')).to.be(false); - }); - }); + expect($countDownTimer.is(":visible")).to.be(false); + expect($cancelButton.is(":visible")).to.be(false); + }); + }); - context('and user does not click on Cancel until timer expires', function () { - it('reloads the pad', async function () { - this.timeout(10000); - await new Promise((resolve) => $originalPadFrame.one('load', resolve)); - }); - }); + context("and user does not click on Cancel until timer expires", function () { + it("reloads the pad", async function () { + this.timeout(10000); + await new Promise((resolve) => $originalPadFrame.one("load", resolve)); + }); + }); }); diff --git a/src/tests/frontend/travis/remote_runner.js b/src/tests/frontend/travis/remote_runner.js index 331e38568..26a7801c9 100644 --- a/src/tests/frontend/travis/remote_runner.js +++ b/src/tests/frontend/travis/remote_runner.js @@ -1,111 +1,137 @@ -'use strict'; +"use strict"; // As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an // unhandled rejection into an uncaught exception, which does cause Node.js to exit. -process.on('unhandledRejection', (err) => { throw err; }); +process.on("unhandledRejection", (err) => { + throw err; +}); -const async = require('async'); -const swd = require('selenium-webdriver'); -const swdChrome = require('selenium-webdriver/chrome'); -const swdEdge = require('selenium-webdriver/edge'); -const swdFirefox = require('selenium-webdriver/firefox'); +const async = require("async"); +const swd = require("selenium-webdriver"); +const swdChrome = require("selenium-webdriver/chrome"); +const swdEdge = require("selenium-webdriver/edge"); +const swdFirefox = require("selenium-webdriver/firefox"); -const isAdminRunner = process.argv[2] === 'admin'; +const isAdminRunner = process.argv[2] === "admin"; const colorSubst = { - red: '\x1B[31m', - yellow: '\x1B[33m', - green: '\x1B[32m', - clear: '\x1B[39m', + red: "\x1B[31m", + yellow: "\x1B[33m", + green: "\x1B[32m", + clear: "\x1B[39m", }; -const colorRegex = new RegExp(`\\[(${Object.keys(colorSubst).join('|')})\\]`, 'g'); +const colorRegex = new RegExp( + `\\[(${Object.keys(colorSubst).join("|")})\\]`, + "g", +); -const log = (msg, pfx = '') => { - console.log(`${pfx}${msg.replace(colorRegex, (m, p1) => colorSubst[p1])}`); +const log = (msg, pfx = "") => { + console.log(`${pfx}${msg.replace(colorRegex, (m, p1) => colorSubst[p1])}`); }; const finishedRegex = /FINISHED.*[0-9]+ tests passed, ([0-9]+) tests failed/; -const sauceTestWorker = async.queue(async ({name, pfx, browser, version, platform}) => { - const chromeOptions = new swdChrome.Options() - .addArguments('use-fake-device-for-media-stream', 'use-fake-ui-for-media-stream'); - const edgeOptions = new swdEdge.Options() - .addArguments('use-fake-device-for-media-stream', 'use-fake-ui-for-media-stream'); - const firefoxOptions = new swdFirefox.Options() - .setPreference('media.navigator.permission.disabled', true) - .setPreference('media.navigator.streams.fake', true); - const builder = new swd.Builder() - .usingServer('https://ondemand.saucelabs.com/wd/hub') - .forBrowser(browser, version, platform) - .setChromeOptions(chromeOptions) - .setEdgeOptions(edgeOptions) - .setFirefoxOptions(firefoxOptions); - builder.getCapabilities().set('sauce:options', { - username: process.env.SAUCE_USERNAME, - accessKey: process.env.SAUCE_ACCESS_KEY, - name: [process.env.GIT_HASH].concat(process.env.SAUCE_NAME || [], name).join(' - '), - public: true, - build: process.env.GIT_HASH, - // console.json can be downloaded via saucelabs, - // don't know how to print them into output of the tests - extendedDebugging: true, - tunnelIdentifier: process.env.TRAVIS_JOB_NUMBER, - }); - const driver = await builder.build(); - const url = `https://saucelabs.com/jobs/${(await driver.getSession()).getId()}`; - try { - await driver.get('http://localhost:9001/tests/frontend/'); - log(`Remote sauce test started! ${url}`, pfx); - // @TODO this should be configured in testSettings, see - // https://wiki.saucelabs.com/display/DOCS/Test+Configuration+Options#TestConfigurationOptions-Timeouts - const deadline = Date.now() + 14.5 * 60 * 1000; // Slightly less than overall test timeout. - // how many characters of the log have been sent to travis - let logIndex = 0; - const remoteFn = (skipChars) => { - const console = document.getElementById('console'); // eslint-disable-line no-undef - if (console == null) return ''; - let text = ''; - for (const n of console.childNodes) { - if (n.nodeType === n.TEXT_NODE) text += n.data; - } - return text.substring(skipChars); - }; - while (true) { - const consoleText = await driver.executeScript(remoteFn, logIndex); - (consoleText ? consoleText.split('\n') : []).forEach((line) => log(line, pfx)); - logIndex += consoleText.length; - const [finished, nFailedStr] = consoleText.match(finishedRegex) || []; - if (finished) { - if (nFailedStr !== '0') process.exitCode = 1; - break; - } - if (Date.now() >= deadline) { - log('[red]FAILED[clear] allowed test duration exceeded'); - process.exitCode = 1; - break; - } - await new Promise((resolve) => setTimeout(resolve, 5000)); - } - } finally { - log(`Remote sauce test finished! ${url}`, pfx); - await driver.quit(); - } -}, 6); // run 6 tests in parrallel +const sauceTestWorker = async.queue( + async ({ name, pfx, browser, version, platform }) => { + const chromeOptions = new swdChrome.Options().addArguments( + "use-fake-device-for-media-stream", + "use-fake-ui-for-media-stream", + ); + const edgeOptions = new swdEdge.Options().addArguments( + "use-fake-device-for-media-stream", + "use-fake-ui-for-media-stream", + ); + const firefoxOptions = new swdFirefox.Options() + .setPreference("media.navigator.permission.disabled", true) + .setPreference("media.navigator.streams.fake", true); + const builder = new swd.Builder() + .usingServer("https://ondemand.saucelabs.com/wd/hub") + .forBrowser(browser, version, platform) + .setChromeOptions(chromeOptions) + .setEdgeOptions(edgeOptions) + .setFirefoxOptions(firefoxOptions); + builder.getCapabilities().set("sauce:options", { + username: process.env.SAUCE_USERNAME, + accessKey: process.env.SAUCE_ACCESS_KEY, + name: [process.env.GIT_HASH] + .concat(process.env.SAUCE_NAME || [], name) + .join(" - "), + public: true, + build: process.env.GIT_HASH, + // console.json can be downloaded via saucelabs, + // don't know how to print them into output of the tests + extendedDebugging: true, + tunnelIdentifier: process.env.TRAVIS_JOB_NUMBER, + }); + const driver = await builder.build(); + const url = `https://saucelabs.com/jobs/${( + await driver.getSession() + ).getId()}`; + try { + await driver.get("http://localhost:9001/tests/frontend/"); + log(`Remote sauce test started! ${url}`, pfx); + // @TODO this should be configured in testSettings, see + // https://wiki.saucelabs.com/display/DOCS/Test+Configuration+Options#TestConfigurationOptions-Timeouts + const deadline = Date.now() + 14.5 * 60 * 1000; // Slightly less than overall test timeout. + // how many characters of the log have been sent to travis + let logIndex = 0; + const remoteFn = (skipChars) => { + const console = document.getElementById("console"); // eslint-disable-line no-undef + if (console == null) return ""; + let text = ""; + for (const n of console.childNodes) { + if (n.nodeType === n.TEXT_NODE) text += n.data; + } + return text.substring(skipChars); + }; + while (true) { + const consoleText = await driver.executeScript(remoteFn, logIndex); + (consoleText ? consoleText.split("\n") : []).forEach((line) => + log(line, pfx), + ); + logIndex += consoleText.length; + const [finished, nFailedStr] = consoleText.match(finishedRegex) || []; + if (finished) { + if (nFailedStr !== "0") process.exitCode = 1; + break; + } + if (Date.now() >= deadline) { + log("[red]FAILED[clear] allowed test duration exceeded"); + process.exitCode = 1; + break; + } + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + } finally { + log(`Remote sauce test finished! ${url}`, pfx); + await driver.quit(); + } + }, + 6, +); // run 6 tests in parrallel -Promise.all([ - {browser: 'chrome', version: 'latest', platform: 'Windows 10'}, - ...(isAdminRunner ? [] : [ - {browser: 'safari', version: 'latest', platform: 'macOS 11.00'}, - {browser: 'firefox', version: 'latest', platform: 'Windows 10'}, - {browser: 'MicrosoftEdge', version: 'latest', platform: 'Windows 10'}, - ]), -].map(async ({browser, version, platform}) => { - const name = `${browser} ${version}, ${platform}`; - const pfx = `[${name}] `; - try { - await sauceTestWorker.push({name, pfx, browser, version, platform}); - } catch (err) { - log(`[red]FAILED[clear] ${err.stack || err}`, pfx); - process.exitCode = 1; - } -})); +Promise.all( + [ + { browser: "chrome", version: "latest", platform: "Windows 10" }, + ...(isAdminRunner + ? [] + : [ + { browser: "safari", version: "latest", platform: "macOS 11.00" }, + { browser: "firefox", version: "latest", platform: "Windows 10" }, + { + browser: "MicrosoftEdge", + version: "latest", + platform: "Windows 10", + }, + ]), + ].map(async ({ browser, version, platform }) => { + const name = `${browser} ${version}, ${platform}`; + const pfx = `[${name}] `; + try { + await sauceTestWorker.push({ name, pfx, browser, version, platform }); + } catch (err) { + log(`[red]FAILED[clear] ${err.stack || err}`, pfx); + process.exitCode = 1; + } + }), +); diff --git a/src/tests/ratelimit/send_changesets.js b/src/tests/ratelimit/send_changesets.js index 8f4f93d03..ac001693b 100644 --- a/src/tests/ratelimit/send_changesets.js +++ b/src/tests/ratelimit/send_changesets.js @@ -1,22 +1,22 @@ -'use strict'; +"use strict"; -const etherpad = require('etherpad-cli-client'); +const etherpad = require("etherpad-cli-client"); const pad = etherpad.connect(process.argv[2]); -pad.on('connected', () => { - setTimeout(() => { - setInterval(() => { - pad.append('1'); - }, process.argv[3]); - }, 500); // wait because CLIENT_READY message is included in ratelimit +pad.on("connected", () => { + setTimeout(() => { + setInterval(() => { + pad.append("1"); + }, process.argv[3]); + }, 500); // wait because CLIENT_READY message is included in ratelimit - setTimeout(() => { - process.exit(0); - }, 11000); + setTimeout(() => { + process.exit(0); + }, 11000); }); // in case of disconnect exit code 1 -pad.on('message', (message) => { - if (message.disconnect === 'rateLimited') { - process.exit(1); - } +pad.on("message", (message) => { + if (message.disconnect === "rateLimited") { + process.exit(1); + } }); diff --git a/src/tsconfig.json b/src/tsconfig.json index a42ef0188..444fac8d5 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -1,18 +1,18 @@ { - "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - "moduleDetection": "force", - "lib": ["ES2023"], - /* Language and Environment */ - "target": "es6", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - /* Modules */ - "module": "CommonJS", /* Specify what module code is generated. */ - "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ - "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ - /* Type Checking */ - "strict": true, /* Enable all strict type-checking options. */ - /* Completeness */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */, - "resolveJsonModule": true - } + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + "moduleDetection": "force", + "lib": ["ES2023"], + /* Language and Environment */ + "target": "es6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + /* Modules */ + "module": "CommonJS" /* Specify what module code is generated. */, + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + /* Completeness */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "resolveJsonModule": true + } }