diff --git a/.babelrc b/.babelrc index 867a790a279a5..b9359fe771b40 100644 --- a/.babelrc +++ b/.babelrc @@ -1,5 +1,6 @@ { "presets": [ - "@babel/preset-env" + "@babel/preset-env", + "@babel/preset-react" ] } diff --git a/.docker/Dockerfile.rhel b/.docker/Dockerfile.rhel index 468f509afbe80..20f7de35cc0d5 100644 --- a/.docker/Dockerfile.rhel +++ b/.docker/Dockerfile.rhel @@ -1,6 +1,6 @@ FROM registry.access.redhat.com/ubi8/nodejs-12 -ENV RC_VERSION 4.1.0-develop +ENV RC_VERSION 4.3.0-develop MAINTAINER buildmaster@rocket.chat diff --git a/.eslintignore b/.eslintignore index 24f6298dbc9df..38a10ea159b18 100644 --- a/.eslintignore +++ b/.eslintignore @@ -11,12 +11,13 @@ public/packages/rocketchat_videobridge/client/public/external_api.js packages/tap-i18n/lib/tap_i18next/tap_i18next-1.7.3.js private/moment-locales/ public/livechat/ -!.scripts public/pdf.worker.min.js public/workers/**/* imports/client/**/* -!/.storybook/ ee/server/services/dist/** !/.mocharc.js +!/.mocharc.*.js +!/.scripts/ +!/.storybook/ !/client/.eslintrc.js !/ee/client/.eslintrc.js diff --git a/.eslintrc b/.eslintrc index 8833cddb4eecd..0d96bb0a34f80 100644 --- a/.eslintrc +++ b/.eslintrc @@ -72,7 +72,8 @@ }, "plugins": [ "react", - "@typescript-eslint" + "@typescript-eslint", + "anti-trojan-source" ], "rules": { "func-call-spacing": "off", @@ -122,7 +123,8 @@ "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "ignoreRestSiblings": true - }] + }], + "anti-trojan-source/no-bidi": "error" }, "env": { "browser": true, @@ -144,6 +146,16 @@ "version": "detect" } } + }, + { + "files": [ + "**/*.tests.js", + "**/*.tests.ts", + "**/*.spec.ts" + ], + "env": { + "mocha": true + } } ] } diff --git a/.github/history-manual.json b/.github/history-manual.json index de60527e3d415..6258a78a3d945 100644 --- a/.github/history-manual.json +++ b/.github/history-manual.json @@ -123,5 +123,12 @@ "sampaiodiego", "pierre-lehnen-rc" ] + }], + "4.1.1": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] }] } diff --git a/.github/history.json b/.github/history.json index 92513a4960e69..b441b4f94ab11 100644 --- a/.github/history.json +++ b/.github/history.json @@ -66042,6 +66042,1825 @@ "5.0" ], "pull_requests": [] + }, + "4.1.0-rc.0": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.0", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23524", + "title": "Chore: Fix some TS warnings", + "userLogin": "KevLehman", + "contributors": [ + "KevLehman" + ] + }, + { + "pr": "23521", + "title": "[FIX] Delay start of email inbox", + "userLogin": "KevLehman", + "contributors": [ + "KevLehman" + ] + }, + { + "pr": "23495", + "title": "Chore: Make omnichannel settings dependent on omnichannel being enabled", + "userLogin": "KevLehman", + "contributors": [ + "KevLehman", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "23523", + "title": "Chore: Update Livechat Package", + "userLogin": "MartinSchoeler", + "contributors": [ + "MartinSchoeler" + ] + }, + { + "pr": "23411", + "title": "[FIX] SAML Users' roles being reset to default on login", + "userLogin": "matheusbsilva137", + "description": "- Remove `roles` field update on `insertOrUpdateSAMLUser` function;\r\n- Add SAML `syncRoles` event;", + "milestone": "4.0.4", + "contributors": [ + "matheusbsilva137", + "pierre-lehnen-rc" + ] + }, + { + "pr": "23522", + "title": "[FIX] Queue error handling and unlocking behavior", + "userLogin": "KevLehman", + "milestone": "4.0.4", + "contributors": [ + "KevLehman" + ] + }, + { + "pr": "23314", + "title": "[FIX] MONGO_OPTIONS being ignored for oplog connection", + "userLogin": "cuonghuunguyen", + "contributors": [ + "cuonghuunguyen", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "23392", + "title": "[IMPROVE] Allow Omnichannel to handle huge queues ", + "userLogin": "KevLehman", + "contributors": [ + "KevLehman", + "web-flow" + ] + }, + { + "pr": "23515", + "title": "[IMPROVE] Make Livechat Instructions setting multi-line", + "userLogin": "murtaza98", + "description": "Since now we're supporting markdown text on this field (via this PR - https://github.com/RocketChat/Rocket.Chat.Livechat/pull/648), it would be nice to make this setting multiline so users can have more space to edit the text\r\n![image](https://user-images.githubusercontent.com/34130764/138146712-13e4968b-5312-4d53-b44c-b5699c5e49c1.png)", + "contributors": [ + "murtaza98" + ] + }, + { + "pr": "23505", + "title": "Chore: Improve watch OAuth settings logic", + "userLogin": "ggazzo", + "description": "Just prevent to perform 200 deletions for registers that not even exist", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "23519", + "title": "Regression: Fix enterprise setting validation", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "23514", + "title": "Chore: Ensure all permissions are created up to this point", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "23469", + "title": "[FIX] useEndpointAction replace by useEndpointActionExperimental", + "userLogin": "tiagoevanp", + "contributors": [ + "tiagoevanp" + ] + }, + { + "pr": "23394", + "title": "[FIX] Omni-Webhook's retry mechanism going in infinite loop", + "userLogin": "murtaza98", + "contributors": [ + "murtaza98" + ] + }, + { + "pr": "23511", + "title": "Regression: Fix user typings style", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "23510", + "title": "Chore: Update pino and pino-pretty", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "23506", + "title": "Regression: Prevent Settings Unit Test Error ", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "23486", + "title": "i18n: Language update from LingoHub 🤖 on 2021-10-18Z", + "userLogin": "lingohub[bot]", + "contributors": [ + null, + "KevLehman" + ] + }, + { + "pr": "23376", + "title": "Bump url-parse from 1.4.7 to 1.5.3", + "userLogin": "dependabot[bot]", + "contributors": [ + "dependabot[bot]", + "web-flow" + ] + }, + { + "pr": "23172", + "title": "[FIX] Rewrite missing webRTC feature", + "userLogin": "dougfabris", + "contributors": [ + "dougfabris", + "tassoevan" + ] + }, + { + "pr": "23488", + "title": "Chore: Replace `promises` helper", + "userLogin": "tassoevan", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23210", + "title": "Chore: Startup Time", + "userLogin": "ggazzo", + "description": "The settings logic has been improved as a whole.\r\n\r\nAll the logic to get the data from the env var was confusing.\r\n\r\nSetting default values was tricky to understand.\r\n\r\nEvery time the server booted, all settings were updated and callbacks were called 2x or more (horrible for environments with multiple instances and generating a turbulent startup).\r\n\r\n`Settings.get(......, callback);` was deprecated. We now have better methods for each case.", + "milestone": "4.1.0", + "contributors": [ + "ggazzo", + "sampaiodiego" + ] + }, + { + "pr": "23491", + "title": "Chore: Move `isJSON` helper", + "userLogin": "tassoevan", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23497", + "title": "Update the community open call link in README", + "userLogin": "Sing-Li", + "contributors": [ + "Sing-Li", + "web-flow", + "geekgonecrazy" + ] + }, + { + "pr": "23490", + "title": "Chore: Move `addMinutesToADate` helper", + "userLogin": "tassoevan", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23489", + "title": "Chore: Move `isEmail` helper", + "userLogin": "tassoevan", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23228", + "title": "[FIX] Admins can't update or reset user avatars when the \"Allow User Avatar Change\" setting is off", + "userLogin": "matheusbsilva137", + "description": "- Allow admins (or any other user with the `edit-other-user-avatar` permission) to update or reset user avatars even when the \"Allow User Avatar Change\" setting is off.", + "contributors": [ + "matheusbsilva137", + "web-flow", + "tassoevan" + ] + }, + { + "pr": "23473", + "title": "[FIX] Server crashing when Routing method is not available at start", + "userLogin": "KevLehman", + "milestone": "4.0.3", + "contributors": [ + "KevLehman", + "web-flow" + ] + }, + { + "pr": "22949", + "title": "[FIX] Avoid last admin deactivate itself", + "userLogin": "ostjen", + "description": "Co-authored-by: @Kartik18g", + "contributors": [ + "ostjen", + "web-flow", + null + ] + }, + { + "pr": "23418", + "title": "[FIX][APPS] Communication problem when updating and uninstalling apps in cluster", + "userLogin": "thassiov", + "description": "- Make the hook responsible for receiving app update events inside a cluster fetch the app's package (zip file) in the correct place.\r\n- Also shows a warning message on uninstalls inside a cluster. As there are many servers writing to the same place, some race conditions may occur. This prevents problems related to terminating the process in the middle due to errors being thrown and leaving the server in a faulty state.", + "milestone": "4.0.3", + "contributors": [ + "thassiov" + ] + }, + { + "pr": "23462", + "title": "[FIX] Markdown quote message style", + "userLogin": "tiagoevanp", + "description": "Before:\r\n![image](https://user-images.githubusercontent.com/17487063/137496669-3abecab4-cf90-45cb-8b1b-d9411a5682dd.png)\r\n\r\nAfter:\r\n![image](https://user-images.githubusercontent.com/17487063/137496905-fd727f90-f707-4ec6-8139-ba2eb1a2146e.png)", + "contributors": [ + "tiagoevanp" + ] + }, + { + "pr": "22950", + "title": "[NEW] Stream to get individual presence updates", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "web-flow", + "ggazzo" + ] + }, + { + "pr": "23396", + "title": "[FIX] Prevent starting Omni-Queue if Omnichannel is disabled", + "userLogin": "murtaza98", + "description": "Whenever the Routing system setting changes, and omnichannel is disabled, then we shouldn't start the queue.", + "milestone": "4.0.2", + "contributors": [ + "murtaza98" + ] + }, + { + "pr": "23404", + "title": "[FIX][ENTERPRISE] Omnichannel agent is not leaving the room when a forwarded chat is queued", + "userLogin": "murtaza98", + "milestone": "4.0.2", + "contributors": [ + "murtaza98", + "web-flow" + ] + }, + { + "pr": "23419", + "title": "Chore: Partially migrate 2FA client code to TypeScript", + "userLogin": "tassoevan", + "description": "Additionally, hides `toastr` behind an module to handle UI's toast notifications.", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23342", + "title": "Chore: clean README", + "userLogin": "AbhJ", + "contributors": [ + "AbhJ", + "web-flow" + ] + }, + { + "pr": "23355", + "title": "Chore: Fixed a Typo in 11-admin.js test", + "userLogin": "badbart", + "contributors": [ + "badbart", + "web-flow" + ] + }, + { + "pr": "23405", + "title": "Chore: Document REST API endpoints (DNS)", + "userLogin": "tassoevan", + "description": "Describes endpoints for DNS on REST API using a JSDoc annotation compatible with OpenAPI spec.", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23430", + "title": "Chore: Document REST API endpoints (E2E)", + "userLogin": "tassoevan", + "description": "Describes endpoints for end-to-end encryption on REST API using a JSDoc annotation compatible with OpenAPI spec.", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23428", + "title": "Chore: Document REST API endpoints (Misc)", + "userLogin": "tassoevan", + "description": "Describes miscellaneous endpoints on REST API using a JSDoc annotation compatible with OpenAPI spec.", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "20947", + "title": "[IMPROVE] Add markdown to custom fields in user Info", + "userLogin": "yash-rajpal", + "description": "Added markdown to custom fields to render links", + "contributors": [ + "yash-rajpal", + "dougfabris" + ] + }, + { + "pr": "23393", + "title": "[FIX] user/agent upload not working via Apps Engine after 3.16.0", + "userLogin": "murtaza98", + "description": "Fixes #22974", + "milestone": "4.0.2", + "contributors": [ + "murtaza98" + ] + }, + { + "pr": "23377", + "title": "[FIX] Attachment buttons overlap in mobile view", + "userLogin": "Aman-Maheshwari", + "milestone": "4.0.2", + "contributors": [ + "Aman-Maheshwari" + ] + }, + { + "pr": "23378", + "title": "[FIX] Users' `roles` and `type` being reset to default on LDAP DataSync", + "userLogin": "matheusbsilva137", + "description": "- Update `roles` and `type` fields only if they are specified in the data imported from LDAP (otherwise, no changes are applied).", + "milestone": "4.0.1", + "contributors": [ + "matheusbsilva137", + "sampaiodiego" + ] + }, + { + "pr": "23382", + "title": "[FIX] LDAP not stoping after wrong password", + "userLogin": "rodrigok", + "milestone": "4.0.1", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "23381", + "title": "[FIX] MongoDB deprecation link", + "userLogin": "sampaiodiego", + "milestone": "4.0.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "23385", + "title": "Chore: Remove dangling README file", + "userLogin": "tassoevan", + "description": "Removes the elderly `server/restapi/README.md`.", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23379", + "title": "[FIX] resumeToken not working", + "userLogin": "sampaiodiego", + "milestone": "4.0.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "23372", + "title": "[FIX] unwanted toastr error message when deleting user", + "userLogin": "ostjen", + "milestone": "4.0.1", + "contributors": [ + "ostjen", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "23370", + "title": "Chore: Migrate some React components/hooks to TypeScript", + "userLogin": "tassoevan", + "description": "Just low-hanging fruits.", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23366", + "title": "[FIX] BigBlueButton integration error due to missing file import", + "userLogin": "wolbernd", + "description": "Fixes BigBlueButton integration", + "milestone": "4.0.1", + "contributors": [ + "wolbernd", + "web-flow" + ] + }, + { + "pr": "23375", + "title": "Chore: Update Apps-Engine version", + "userLogin": "d-gubert", + "milestone": "4.0.1", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "23374", + "title": "[FIX] imported migration v240", + "userLogin": "ostjen", + "milestone": "4.0.1", + "contributors": [ + "ostjen" + ] + }, + { + "pr": "22941", + "title": "[IMPROVE] optimized groups.listAll response time", + "userLogin": "ostjen", + "description": "groups.listAll endpoint was having performance issues, specially when the total number of groups was high. This happened because the endpoint was loading all objects in memory then using splice to paginate, instead of paginating beforehand.\r\n\r\nConsidering 70k groups, this was the performance improvement:\r\n\r\nbefore\r\n![image](https://user-images.githubusercontent.com/28611993/129601314-bdf89337-79fa-4446-9f44-95264af4adb3.png)\r\n\r\nafter\r\n![image](https://user-images.githubusercontent.com/28611993/129601358-5872e166-f923-4c1c-b21d-eb9507365ecf.png)", + "contributors": [ + "ostjen", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "23213", + "title": "[FIX] Read only description in team creation", + "userLogin": "dougfabris", + "description": "![image](https://user-images.githubusercontent.com/27704687/133608433-8ca788a3-71a8-4d40-8c40-8156ab03c606.png)\r\n\r\n![image](https://user-images.githubusercontent.com/27704687/133608400-4cdc7a67-95e5-46c6-8c65-29ab107cd314.png)", + "contributors": [ + "dougfabris" + ] + }, + { + "pr": "23364", + "title": "Chore: Upgrade Storybook", + "userLogin": "tassoevan", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23360", + "title": "Chore: Move components away from /app/", + "userLogin": "tassoevan", + "description": "We currently do NOT recommend placing React components under `/app`.", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23361", + "title": "Chore: Document REST API endpoints (banners)", + "userLogin": "tassoevan", + "description": "Describes endpoints for banners on REST API using a JSDoc annotation compatible with OpenAPI spec.", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23362", + "title": "Merge master into develop & Set version to 4.1.0-develop", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "ggazzo", + "web-flow" + ] + } + ] + }, + "4.0.1": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.0", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23386", + "title": "Release 4.0.1", + "userLogin": "sampaiodiego", + "contributors": [ + "rodrigok", + "sampaiodiego", + "ostjen", + "wolbernd", + "d-gubert", + "matheusbsilva137" + ] + }, + { + "pr": "23378", + "title": "[FIX] Users' `roles` and `type` being reset to default on LDAP DataSync", + "userLogin": "matheusbsilva137", + "description": "- Update `roles` and `type` fields only if they are specified in the data imported from LDAP (otherwise, no changes are applied).", + "milestone": "4.0.1", + "contributors": [ + "matheusbsilva137", + "sampaiodiego" + ] + }, + { + "pr": "23374", + "title": "[FIX] imported migration v240", + "userLogin": "ostjen", + "milestone": "4.0.1", + "contributors": [ + "ostjen" + ] + }, + { + "pr": "23375", + "title": "Chore: Update Apps-Engine version", + "userLogin": "d-gubert", + "milestone": "4.0.1", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "23366", + "title": "[FIX] BigBlueButton integration error due to missing file import", + "userLogin": "wolbernd", + "description": "Fixes BigBlueButton integration", + "milestone": "4.0.1", + "contributors": [ + "wolbernd", + "web-flow" + ] + }, + { + "pr": "23372", + "title": "[FIX] unwanted toastr error message when deleting user", + "userLogin": "ostjen", + "milestone": "4.0.1", + "contributors": [ + "ostjen", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "23379", + "title": "[FIX] resumeToken not working", + "userLogin": "sampaiodiego", + "milestone": "4.0.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "23381", + "title": "[FIX] MongoDB deprecation link", + "userLogin": "sampaiodiego", + "milestone": "4.0.1", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "23382", + "title": "[FIX] LDAP not stoping after wrong password", + "userLogin": "rodrigok", + "milestone": "4.0.1", + "contributors": [ + "rodrigok" + ] + } + ] + }, + "4.0.2": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.0", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23460", + "title": "Release 4.0.2", + "userLogin": "sampaiodiego", + "contributors": [ + "murtaza98", + "sampaiodiego", + "Aman-Maheshwari" + ] + }, + { + "pr": "23377", + "title": "[FIX] Attachment buttons overlap in mobile view", + "userLogin": "Aman-Maheshwari", + "milestone": "4.0.2", + "contributors": [ + "Aman-Maheshwari" + ] + }, + { + "pr": "23393", + "title": "[FIX] user/agent upload not working via Apps Engine after 3.16.0", + "userLogin": "murtaza98", + "description": "Fixes #22974", + "milestone": "4.0.2", + "contributors": [ + "murtaza98" + ] + }, + { + "pr": "23404", + "title": "[FIX][ENTERPRISE] Omnichannel agent is not leaving the room when a forwarded chat is queued", + "userLogin": "murtaza98", + "milestone": "4.0.2", + "contributors": [ + "murtaza98", + "web-flow" + ] + }, + { + "pr": "23396", + "title": "[FIX] Prevent starting Omni-Queue if Omnichannel is disabled", + "userLogin": "murtaza98", + "description": "Whenever the Routing system setting changes, and omnichannel is disabled, then we shouldn't start the queue.", + "milestone": "4.0.2", + "contributors": [ + "murtaza98" + ] + } + ] + }, + "4.0.3": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.0", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23496", + "title": "Release 4.0.3", + "userLogin": "sampaiodiego", + "contributors": [ + "KevLehman", + "sampaiodiego", + "thassiov" + ] + }, + { + "pr": "23418", + "title": "[FIX][APPS] Communication problem when updating and uninstalling apps in cluster", + "userLogin": "thassiov", + "description": "- Make the hook responsible for receiving app update events inside a cluster fetch the app's package (zip file) in the correct place.\r\n- Also shows a warning message on uninstalls inside a cluster. As there are many servers writing to the same place, some race conditions may occur. This prevents problems related to terminating the process in the middle due to errors being thrown and leaving the server in a faulty state.", + "milestone": "4.0.3", + "contributors": [ + "thassiov" + ] + }, + { + "pr": "23473", + "title": "[FIX] Server crashing when Routing method is not available at start", + "userLogin": "KevLehman", + "milestone": "4.0.3", + "contributors": [ + "KevLehman", + "web-flow" + ] + } + ] + }, + "4.1.0-rc.1": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.0", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23531", + "title": "Regression: Waiting_queue setting not being applied due to missing module key", + "userLogin": "KevLehman", + "contributors": [ + "KevLehman" + ] + }, + { + "pr": "23528", + "title": "Regression: Settings order", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "23529", + "title": "Regression: watchByRegex without Fibers", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + } + ] + }, + "4.0.4": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.0", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23532", + "title": "Release 4.0.4", + "userLogin": "sampaiodiego", + "contributors": [ + "KevLehman", + "sampaiodiego", + "matheusbsilva137" + ] + }, + { + "pr": "23411", + "title": "[FIX] SAML Users' roles being reset to default on login", + "userLogin": "matheusbsilva137", + "description": "- Remove `roles` field update on `insertOrUpdateSAMLUser` function;\r\n- Add SAML `syncRoles` event;", + "milestone": "4.0.4", + "contributors": [ + "matheusbsilva137", + "pierre-lehnen-rc" + ] + }, + { + "pr": "23522", + "title": "[FIX] Queue error handling and unlocking behavior", + "userLogin": "KevLehman", + "milestone": "4.0.4", + "contributors": [ + "KevLehman" + ] + } + ] + }, + "4.0.5": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.0", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23554", + "title": "Release 4.0.5", + "userLogin": "sampaiodiego", + "contributors": [ + "pierre-lehnen-rc", + "sampaiodiego" + ] + }, + { + "pr": "23541", + "title": "[FIX] OAuth login not working on mobile app", + "userLogin": "pierre-lehnen-rc", + "milestone": "4.0.5", + "contributors": [ + "pierre-lehnen-rc" + ] + } + ] + }, + "4.1.0-rc.2": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.0", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23552", + "title": "Regression: Mail body contains `undefined` text", + "userLogin": "tassoevan", + "description": "### Before\r\n![image](https://user-images.githubusercontent.com/2263066/138733018-10449892-5c2d-46fb-9355-00e98e0d6c9f.png)\r\n\r\n### After\r\n![image](https://user-images.githubusercontent.com/2263066/138733074-a1b88a77-bf64-41c3-a6c3-ac9e1cb63de1.png)", + "contributors": [ + "tassoevan", + "sampaiodiego" + ] + }, + { + "pr": "23541", + "title": "[FIX] OAuth login not working on mobile app", + "userLogin": "pierre-lehnen-rc", + "milestone": "4.0.5", + "contributors": [ + "pierre-lehnen-rc" + ] + } + ] + }, + "4.1.0-rc.3": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.0", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23556", + "title": "Regression: Prevent settings from getting updated", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "23568", + "title": "Regression: Routing method not available when called from listeners at startup", + "userLogin": "KevLehman", + "milestone": "4.1.0", + "contributors": [ + "KevLehman" + ] + }, + { + "pr": "23391", + "title": "Bump: fuselage 0.30.1", + "userLogin": "ggazzo", + "contributors": [ + "dougfabris" + ] + } + ] + }, + "4.1.0-rc.4": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.0", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23577", + "title": "Regression: Debounce call based on params on omnichannel queue dispatch", + "userLogin": "KevLehman", + "contributors": [ + "KevLehman" + ] + } + ] + }, + "4.1.0": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.0", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [] + }, + "4.1.1": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.1", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23607", + "title": "[FIX] App update flow failing in HA setups", + "userLogin": "d-gubert", + "description": "The flow for app updates is broken in specific scenarios with HA setups. Here we change the method calls in the Apps-Engine to avoid race conditions", + "milestone": "4.1.1", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "23627", + "title": "[FIX] LDAP users not being re-activated on login", + "userLogin": "pierre-lehnen-rc", + "milestone": "4.1.1", + "contributors": [ + "pierre-lehnen-rc" + ] + }, + { + "pr": "23608", + "title": "[FIX] Advanced LDAP Sync Features", + "userLogin": "pierre-lehnen-rc", + "milestone": "4.1.1", + "contributors": [ + "pierre-lehnen-rc", + "web-flow" + ] + } + ] + }, + "3.18.3": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.27.1", + "mongo_versions": [ + "3.4", + "3.6", + "4.0", + "4.2" + ], + "pull_requests": [] + }, + "4.0.6": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.0", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [] + }, + "4.1.2": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.1", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23487", + "title": "[FIX] Notifications are not being filtered", + "userLogin": "matheusbsilva137", + "description": "- Add a migration to update the `Accounts_Default_User_Preferences_pushNotifications` setting's value to the `Accounts_Default_User_Preferences_mobileNotifications` setting's value;\r\n - Remove the `Accounts_Default_User_Preferences_mobileNotifications` setting (replaced by `Accounts_Default_User_Preferences_pushNotifications`);\r\n - Rename 'mobileNotifications' user's preference to 'pushNotifications'.", + "milestone": "4.1.2", + "contributors": [ + "matheusbsilva137" + ] + }, + { + "pr": "23661", + "title": "[FIX] Performance issues when running Omnichannel job queue dispatcher", + "userLogin": "renatobecker", + "milestone": "4.1.2", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "23587", + "title": "[FIX] Omnichannel status being changed on page refresh", + "userLogin": "KevLehman", + "milestone": "4.1.2", + "contributors": [ + "KevLehman" + ] + } + ] + }, + "4.2.0-rc.0": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.1", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23769", + "title": "Chore: Update settings.ts", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "23565", + "title": "[FIX] Registration not possible when any user is blocked for multiple failed logins", + "userLogin": "ostjen", + "contributors": [ + "ostjen" + ] + }, + { + "pr": "23770", + "title": "Regression: Fix sendMessagesToAdmins not in Fiber", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "23771", + "title": "Chore: Remove duplicated 'name' key from rate limiter logs", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "23761", + "title": "[NEW] Enable LDAP manual sync to deployments without EE license", + "userLogin": "rodrigok", + "description": "Open the Enterprise LDAP API that executes background sync to be used without any Enterprise License and enforce 2FA requirements.", + "milestone": "4.2.0", + "contributors": [ + "rodrigok", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "23732", + "title": "[NEW] Rate limiting for user registering", + "userLogin": "ostjen", + "milestone": "4.2.0", + "contributors": [ + "ostjen" + ] + }, + { + "pr": "23675", + "title": "Chore: add index on appId + associations for apps_persistence collection", + "userLogin": "KevLehman", + "contributors": [ + "KevLehman" + ] + }, + { + "pr": "23768", + "title": "Chore: Bump Rocket.Chat@livechat to 1.10", + "userLogin": "KevLehman", + "milestone": "4.2.0", + "contributors": [ + "KevLehman" + ] + }, + { + "pr": "23766", + "title": "[IMPROVE] Improve the add user drop down for add a user in create channel modal for UserAutoCompleteMultiple", + "userLogin": "dougfabris", + "description": "Seeing only the name of the person you are not adding is not practical in my opinion because two people can have the same name. Moreover, you can't see the username of the person you want to add in the dropdown. So I changed that and created another selection of users to show the username as well. I made this change so that it would appear in the key place for creating a room and adding a user.\r\n\r\nBefore:\r\n\r\nhttps://user-images.githubusercontent.com/45966964/115287805-faac8d00-a150-11eb-871f-147ab011ced0.mp4\r\n\r\n\r\nAfter:\r\n\r\nhttps://user-images.githubusercontent.com/45966964/115287664-d2249300-a150-11eb-8cf6-0e04730b425d.mp4", + "milestone": "4.2.0", + "contributors": [ + "Jeanstaquet", + "web-flow", + "dougfabris" + ] + }, + { + "pr": "23533", + "title": "[FIX] New specific endpoint for contactChatHistoryMessages with right permissions", + "userLogin": "tiagoevanp", + "description": "Anyone with 'View Omnichannel Rooms' permission can see the History Messages.", + "milestone": "4.2.0", + "contributors": [ + "tiagoevanp", + "web-flow", + "KevLehman", + "ggazzo" + ] + }, + { + "pr": "23588", + "title": "[FIX][ENTERPRISE] OAuth \"Merge Roles\" removes roles from users", + "userLogin": "matheusbsilva137", + "description": "- Fix OAuth \"Merge Roles\": the \"Merge Roles\" option now synchronize only the roles described in the \"**Roles to Sync**\" setting available in each Custom OAuth settings' group (instead of replacing users' roles by their OAuth roles);\r\n- Fix \"Merge Roles\" and \"Channel Mapping\" not being performed/updated on OAuth login.", + "contributors": [ + "matheusbsilva137", + "web-flow" + ] + }, + { + "pr": "23547", + "title": "[IMPROVE] Engagement Dashboard", + "userLogin": "tassoevan", + "description": "- Adds helpers `onToggledFeature` for server and client code to handle license activation/deactivation without server restart;\r\n- Replaces usage of `useEndpointData` with `useQuery` (from [React Query](https://react-query.tanstack.com/));\r\n- Introduces `view-engagement-dashboard` permission.", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23004", + "title": "[NEW] Audio and Video calling in Livechat", + "userLogin": "murtaza98", + "contributors": [ + "dhruvjain99", + "murtaza98", + "Deepak-learner" + ] + }, + { + "pr": "23758", + "title": "Chore: Type omnichannel models", + "userLogin": "KevLehman", + "contributors": [ + "KevLehman", + "ggazzo" + ] + }, + { + "pr": "23737", + "title": "[NEW] Allow registering by REG_TOKEN environment variable", + "userLogin": "geekgonecrazy", + "description": "You can provide the REG_TOKEN environment variable containing a registration token and it will automatically register to your cloud account. This simplifies the registration flow", + "contributors": [ + "geekgonecrazy" + ] + }, + { + "pr": "23686", + "title": "[NEW] Permission for download/uploading files on mobile", + "userLogin": "ostjen", + "contributors": [ + "ostjen" + ] + }, + { + "pr": "23735", + "title": "[IMPROVE] Stricter API types", + "userLogin": "tassoevan", + "description": "It:\r\n- Adds stricter types for `API`;\r\n- Enables types for `urlParams`;\r\n- Removes mandatory passage of `undefined` payload on client;\r\n- Corrects some regressions;\r\n- Reassures my belief in TypeScript supremacy.", + "contributors": [ + "tassoevan", + "ggazzo" + ] + }, + { + "pr": "23757", + "title": "Regression: Units endpoint to TS", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "23750", + "title": "[NEW] REST endpoints to manage Omnichannel Business Units", + "userLogin": "KevLehman", + "description": "Basic documentation about endpoints can be found at https://www.postman.com/kaleman960/workspace/rocketchat-public-api/request/3865466-71502450-8c8f-42b4-8954-1cd3d01fcb0c", + "contributors": [ + "KevLehman" + ] + }, + { + "pr": "23738", + "title": "[FIX] Autofocus on search input in admin", + "userLogin": "gabriellsh", + "description": "Removed \"generic\" autofocus on sidenav template.", + "contributors": [ + "gabriellsh" + ] + }, + { + "pr": "23745", + "title": "Chore: Generic Table ", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "23739", + "title": "[FIX] Await promise to handle error when attempting to transfer a room", + "userLogin": "KevLehman", + "contributors": [ + "KevLehman" + ] + }, + { + "pr": "23673", + "title": "[FIX][ENTERPRISE] Private rooms and discussions can't be audited", + "userLogin": "matheusbsilva137", + "description": "- Add Private rooms (groups) and Discussions to the Message Auditing (Channels) autocomplete;\r\n- Update \"Channels\" tab name to \"Rooms\".", + "contributors": [ + "matheusbsilva137", + "gabriellsh" + ] + }, + { + "pr": "23734", + "title": "[FIX] Missing user roles in edit user tab", + "userLogin": "dougfabris", + "contributors": [ + "dougfabris" + ] + }, + { + "pr": "23733", + "title": "[FIX] Discussions created inside discussions", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "23694", + "title": "[NEW] Allow Omnichannel statistics to be collected.", + "userLogin": "cauefcr", + "description": "This PR adds the possibility for business stakeholders to see what is actually being used of the Omnichannel integrations.", + "contributors": [ + null, + "cauefcr", + "web-flow" + ] + }, + { + "pr": "23725", + "title": "[IMPROVE] Re-naming department query param for Twilio", + "userLogin": "murtaza98", + "description": "Since the endpoint supports both, department ID and department Name, so we're renaming it to reflect the same. `departmentName` -> `department`", + "contributors": [ + "murtaza98" + ] + }, + { + "pr": "23468", + "title": "[FIX] Fixed E2E default room settings not being honoured", + "userLogin": "ostjen", + "contributors": [ + "ostjen", + "TheDigitalEagle", + "web-flow", + "tassoevan" + ] + }, + { + "pr": "23659", + "title": "[FIX] broken avatar preview when changing avatar", + "userLogin": "Aman-Maheshwari", + "contributors": [ + "Aman-Maheshwari" + ] + }, + { + "pr": "23705", + "title": "[FIX] Prevent UserAction.addStream without Subscription", + "userLogin": "tiagoevanp", + "description": "When you take an Omnichannel chat from queue, the guest's typing information will appear.", + "contributors": [ + "ggazzo", + "tiagoevanp" + ] + }, + { + "pr": "23499", + "title": "[FIX] PhotoSwipe crashing on show", + "userLogin": "tassoevan", + "description": "Waits for initial content to load before showing it.", + "contributors": [ + "tassoevan", + "dougfabris" + ] + }, + { + "pr": "23695", + "title": "Chore: add `no-bidi` rule", + "userLogin": "KevLehman", + "contributors": [ + "KevLehman" + ] + }, + { + "pr": "23711", + "title": "[FIX] Fix typo in FR translation", + "userLogin": "Cormoran96", + "contributors": [ + "Cormoran96" + ] + }, + { + "pr": "23706", + "title": "Chore: Mocha testing configuration", + "userLogin": "tassoevan", + "description": "We've been writing integration tests for the REST API quite regularly, but we can't say the same for UI-related modules. This PR is based on the assumption that _improving the developer experience on writing tests_ would increase our coverage and promote the adoption even for newcomers.\r\n\r\nHere as summary of the proposal:\r\n\r\n- Change Mocha configuration files:\r\n - Add a base configuration (`.mocharc.base.json`);\r\n - Rename the configuration for REST API tests (`mocha_end_to_end.opts.js -> .mocharc.api.js`);\r\n - Add a configuration for client modules (`.mocharc.client.js`);\r\n - Enable ESLint for them.\r\n- Add a Mocha test command exclusive for client modules (`npm run testunit-client`);\r\n- Enable fast watch mode:\r\n - Configure `ts-node` to only transpile code (skip type checking);\r\n - Define a list of files to be watched.\r\n- Configure `mocha` environment on ESLint only for test files (required when using Mocha's globals);\r\n- Adopt Chai as our assertion library:\r\n - Unify the setup of Chai plugins (`chai-spies`, `chai-datetime`, `chai-dom`);\r\n - Replace `assert` with `chai`;\r\n - Replace `chai.expect` with `expect`.\r\n- Enable integration tests with React components:\r\n - Enable JSX support on our default Babel configuration;\r\n - Adopt [testing library](https://testing-library.com/).", + "contributors": [ + "tassoevan", + "KevLehman", + "ggazzo" + ] + }, + { + "pr": "23701", + "title": "Chore: Api definitions", + "userLogin": "ggazzo", + "contributors": [ + "tassoevan", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "23703", + "title": "[FIX][ENTERPRISE] Replace all occurrences of a placeholder on string instead of just first one", + "userLogin": "KevLehman", + "contributors": [ + "KevLehman" + ] + }, + { + "pr": "23641", + "title": "[FIX] Omnichannel webhooks can't be saved", + "userLogin": "Aman-Maheshwari", + "contributors": [ + "Aman-Maheshwari" + ] + }, + { + "pr": "23595", + "title": "[FIX] Omnichannel business hours page breaking navigation", + "userLogin": "Aman-Maheshwari", + "contributors": [ + "Aman-Maheshwari", + "tiagoevanp", + "web-flow" + ] + }, + { + "pr": "23626", + "title": "[IMPROVE] Allow override of default department for SMS Livechat sessions", + "userLogin": "bhardwajaditya", + "contributors": [ + "bhardwajaditya" + ] + }, + { + "pr": "23691", + "title": "[FIX] Omnichannel contact center navigation", + "userLogin": "tiagoevanp", + "description": "Derives from: https://github.com/RocketChat/Rocket.Chat/pull/23656\r\n\r\nThis PR includes a different approach to solving navigation problems following the same code structure and UI definitions of other \"ActionButtons\" components in Sidebar.", + "contributors": [ + "tiagoevanp" + ] + }, + { + "pr": "23692", + "title": "Regression: Improve AggregationCursor types", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "23696", + "title": "Chore: Remove useCallbacks", + "userLogin": "tassoevan", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23387", + "title": "[IMPROVE] Reduce complexity in some functions", + "userLogin": "tassoevan", + "description": "Overhauls all places where eslint's `complexity` rule is disabled.", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23633", + "title": "Chore: Convert Fiber models to async Step 1", + "userLogin": "rodrigok", + "contributors": [ + "rodrigok", + "sampaiodiego" + ] + }, + { + "pr": "23389", + "title": "[NEW] Permissions for interacting with Omnichannel Contact Center", + "userLogin": "cauefcr", + "description": "Adds a new permission, one that allows for control over user access to Omnichannel Contact Center,", + "contributors": [ + null, + "cauefcr", + "web-flow" + ] + }, + { + "pr": "23587", + "title": "[FIX] Omnichannel status being changed on page refresh", + "userLogin": "KevLehman", + "milestone": "4.1.2", + "contributors": [ + "KevLehman" + ] + }, + { + "pr": "23661", + "title": "[FIX] Performance issues when running Omnichannel job queue dispatcher", + "userLogin": "renatobecker", + "milestone": "4.1.2", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "23608", + "title": "[FIX] Advanced LDAP Sync Features", + "userLogin": "pierre-lehnen-rc", + "milestone": "4.1.1", + "contributors": [ + "pierre-lehnen-rc", + "web-flow" + ] + }, + { + "pr": "23627", + "title": "[FIX] LDAP users not being re-activated on login", + "userLogin": "pierre-lehnen-rc", + "milestone": "4.1.1", + "contributors": [ + "pierre-lehnen-rc" + ] + }, + { + "pr": "23576", + "title": "[FIX] \"to users\" not working in export message", + "userLogin": "ostjen", + "contributors": [ + "ostjen", + "web-flow" + ] + }, + { + "pr": "23607", + "title": "[FIX] App update flow failing in HA setups", + "userLogin": "d-gubert", + "description": "The flow for app updates is broken in specific scenarios with HA setups. Here we change the method calls in the Apps-Engine to avoid race conditions", + "milestone": "4.1.1", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "23566", + "title": "[FIX] Apps scheduler \"losing\" jobs after server restart", + "userLogin": "d-gubert", + "description": "If a job is scheduled and the server restarted, said job won't be executed, giving the impression it's been lost.\r\n\r\nWhat happens is that the scheduler is only started when some app tries to schedule an app - if that happens, all jobs that are \"late\" will be executed; if that doesn't happen, no job will run.\r\n\r\nThis PR starts the apps scheduler right after all apps have been loaded", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "23603", + "title": "i18n: Language update from LingoHub 🤖 on 2021-11-01Z", + "userLogin": "lingohub[bot]", + "contributors": [ + null, + "sampaiodiego" + ] + }, + { + "pr": "23498", + "title": "[NEW] Show on-hold metrics on analytics pages and current chats", + "userLogin": "KevLehman", + "contributors": [ + "KevLehman" + ] + }, + { + "pr": "23452", + "title": "Chore: Rearrange module typings", + "userLogin": "tassoevan", + "description": "- Move all external module declarations (definitions and augmentations) to `/definition/externals`;\r\n- ~Symlink some modules on `/definition/externals` to `/ee/server/services/definition/externals`~ Share types with `/ee/server/services`;\r\n- Use TypeScript as server code entrypoint.", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "23487", + "title": "[FIX] Notifications are not being filtered", + "userLogin": "matheusbsilva137", + "description": "- Add a migration to update the `Accounts_Default_User_Preferences_pushNotifications` setting's value to the `Accounts_Default_User_Preferences_mobileNotifications` setting's value;\r\n - Remove the `Accounts_Default_User_Preferences_mobileNotifications` setting (replaced by `Accounts_Default_User_Preferences_pushNotifications`);\r\n - Rename 'mobileNotifications' user's preference to 'pushNotifications'.", + "milestone": "4.1.2", + "contributors": [ + "matheusbsilva137" + ] + }, + { + "pr": "23542", + "title": "[IMPROVE] MKP12 - New UI - Merge Apps and Marketplace Tabs and Content", + "userLogin": "rique223", + "description": "Merged the Marketplace and Apps page into a single page with a tabs component that changes between Markeplace and installed apps.\r\n![page merging](https://user-images.githubusercontent.com/43561537/138516558-f86d62e6-1a5c-4817-a229-a1b876323960.gif)", + "contributors": [ + "ggazzo", + "dougfabris" + ] + }, + { + "pr": "23586", + "title": "Merge master into develop & Set version to 4.2.0-develop", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "web-flow" + ] + } + ] + }, + "4.2.0-rc.1": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.1", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23778", + "title": "Regression: Fix incorrect API path for livechat calls", + "userLogin": "murtaza98", + "milestone": "4.2.0", + "contributors": [ + "murtaza98" + ] + }, + { + "pr": "23775", + "title": "Regression: Fix LDAP sync route", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "sampaiodiego" + ] + } + ] + }, + "4.2.0-rc.2": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.1", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23793", + "title": "Regression: Include files on EE services build", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, + { + "pr": "23789", + "title": "Regression: Fix sort param on omnichannel endpoints", + "userLogin": "KevLehman", + "contributors": [ + "KevLehman" + ] + } + ] + }, + "4.2.0-rc.3": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.1", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23802", + "title": "Regression: Add @rocket.chat/emitter to EE services", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + } + ] + }, + "4.2.0-rc.4": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.1", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [ + { + "pr": "23774", + "title": "Regression: Add trash to raw models", + "userLogin": "sampaiodiego", + "milestone": "4.2.0", + "contributors": [ + "sampaiodiego", + "ggazzo" + ] + }, + { + "pr": "23820", + "title": "[FIX] LDAP users being disabled when an AD security policy is enabled", + "userLogin": "pierre-lehnen-rc", + "milestone": "4.2.0", + "contributors": [ + "pierre-lehnen-rc" + ] + }, + { + "pr": "23815", + "title": "Regression: \"When is the chat busier\" and \"Users by time of day\" charts are not working", + "userLogin": "matheusbsilva137", + "description": "- Fix \"When is the chat busier\" (Hours) and \"Users by time of day\" charts, which weren't displaying any data;", + "milestone": "4.2.0", + "contributors": [ + "murtaza98", + "matheusbsilva137", + "web-flow" + ] + }, + { + "pr": "23812", + "title": "i18n: Language update from LingoHub 🤖 on 2021-11-29Z", + "userLogin": "lingohub[bot]", + "milestone": "4.2.0", + "contributors": [ + null, + "sampaiodiego" + ] + }, + { + "pr": "23813", + "title": "Regression: Mark Livechat WebRTC video calling as alpha", + "userLogin": "murtaza98", + "description": "![image](https://user-images.githubusercontent.com/34130764/143832378-82b99a72-23e8-4115-8b28-a0d210de598b.png)", + "milestone": "4.2.0", + "contributors": [ + "murtaza98" + ] + }, + { + "pr": "23803", + "title": "Regression: Current Chats not Filtering", + "userLogin": "MartinSchoeler", + "milestone": "4.2.0", + "contributors": [ + "MartinSchoeler" + ] + } + ] + }, + "4.2.0": { + "node_version": "12.22.1", + "npm_version": "6.14.1", + "apps_engine_version": "1.28.1", + "mongo_versions": [ + "3.6", + "4.0", + "4.2", + "4.4", + "5.0" + ], + "pull_requests": [] } } } \ No newline at end of file diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 57fe8317489ac..be8b2b3941096 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -127,11 +127,11 @@ jobs: # - name: Build a Meteor cache # run: | # # to do this we can clear the main files and it build the rest - # echo "" > server/main.js - # echo "" > client/main.js + # echo "" > server/main.ts + # echo "" > client/main.ts # sed -i.backup 's/rocketchat:livechat/#rocketchat:livechat/' .meteor/packages # meteor build --server-only --debug --directory /tmp/build-temp - # git checkout -- server/main.js client/main.js .meteor/packages + # git checkout -- server/main.ts client/main.ts .meteor/packages - name: Reset Meteor if: startsWith(github.ref, 'refs/tags/') == 'true' || github.ref == 'refs/heads/develop' @@ -142,6 +142,8 @@ jobs: run: | cd ./ee/server/services npm run build + # check if build succeeded + [ ! -d ./dist/ee/server/services ] && exit 1 rm -rf dist/ - name: Build Rocket.Chat From Pull Request @@ -242,9 +244,15 @@ jobs: run: | npm install + - name: Unit Test (definitions) + run: npm run testunit-definition + - name: Unit Test run: npm run testunit + - name: Unit Test (client) + run: npm run testunit-client + - name: E2E Test env: TEST_MODE: "true" @@ -358,11 +366,11 @@ jobs: # - name: Build a Meteor cache # run: | # # to do this we can clear the main files and it build the rest - # echo "" > server/main.js - # echo "" > client/main.js + # echo "" > server/main.ts + # echo "" > client/main.ts # sed -i.backup 's/rocketchat:livechat/#rocketchat:livechat/' .meteor/packages # meteor build --server-only --debug --directory /tmp/build-temp - # git checkout -- server/main.js client/main.js .meteor/packages + # git checkout -- server/main.ts client/main.ts .meteor/packages - name: Build Rocket.Chat run: | diff --git a/.husky/pre-push b/.husky/pre-push index 8f8e7a09a9aac..3c9fedc8460ae 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,2 +1,3 @@ meteor npm run lint && \ -meteor npm run testunit +meteor npm run testunit && \ +meteor npm run testunit-client diff --git a/.mocharc.api.js b/.mocharc.api.js new file mode 100644 index 0000000000000..cef49fb74933a --- /dev/null +++ b/.mocharc.api.js @@ -0,0 +1,17 @@ +'use strict'; + +/** + * Mocha configuration for REST API integration tests. + */ + +module.exports = { + ...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916 + timeout: 10000, + bail: true, + file: 'tests/end-to-end/teardown.js', + spec: [ + 'tests/end-to-end/api/*.js', + 'tests/end-to-end/api/*.ts', + 'tests/end-to-end/apps/*.js', + ], +}; diff --git a/.mocharc.base.json b/.mocharc.base.json new file mode 100644 index 0000000000000..ac8a2bcce8b7f --- /dev/null +++ b/.mocharc.base.json @@ -0,0 +1,16 @@ +{ + "ui": "bdd", + "reporter": "spec", + "extension": ["js", "ts", "tsx"], + "require": [ + "@babel/register", + "regenerator-runtime/runtime", + "ts-node/register", + "./tests/setup/chaiPlugins.ts" + ], + "watch-files": [ + "./**/*.js", + "./**/*.ts", + "./**/*.tsx" + ] +} diff --git a/.mocharc.client.js b/.mocharc.client.js new file mode 100644 index 0000000000000..e4279a9a63565 --- /dev/null +++ b/.mocharc.client.js @@ -0,0 +1,32 @@ +'use strict'; + +/** + * Mocha configuration for client-side unit and integration tests. + */ + +const base = require('./.mocharc.base.json'); + +/** + * Mocha will run `ts-node` without doing type checking to speed-up the tests. It should be fine as `npm run typecheck` + * covers test files too. + */ + +Object.assign(process.env, { + TS_NODE_FILES: true, + TS_NODE_TRANSPILE_ONLY: true, +}, process.env); + +module.exports = { + ...base, // see https://github.com/mochajs/mocha/issues/3916 + require: [ + ...base.require, + './tests/setup/registerWebApiMocks.ts', + './tests/setup/cleanupTestingLibrary.ts', + ], + exit: false, + slow: 200, + spec: [ + 'client/**/*.spec.ts', + 'client/**/*.spec.tsx', + ], +}; diff --git a/.mocharc.definition.js b/.mocharc.definition.js new file mode 100644 index 0000000000000..efffe16964d5c --- /dev/null +++ b/.mocharc.definition.js @@ -0,0 +1,29 @@ +'use strict'; + +/** + * Mocha configuration for unit tests for type guards. + */ + +const base = require('./.mocharc.base.json'); + +/** + * Mocha will run `ts-node` without doing type checking to speed-up the tests. It should be fine as `npm run typecheck` + * covers test files too. + */ + +Object.assign(process.env, { + TS_NODE_FILES: true, + TS_NODE_TRANSPILE_ONLY: true, +}, process.env); + +module.exports = { + ...base, // see https://github.com/mochajs/mocha/issues/3916 + require: [ + ...base.require, + ], + exit: false, + slow: 200, + spec: [ + 'definition/**/*.spec.ts', + ], +}; diff --git a/.mocharc.js b/.mocharc.js index bd3bd56e3c0ed..a71a3020cf4ba 100644 --- a/.mocharc.js +++ b/.mocharc.js @@ -1,17 +1,28 @@ 'use strict'; +/** + * Mocha configuration for general unit tests. + */ + +const base = require('./.mocharc.base.json'); + +/** + * Mocha will run `ts-node` without doing type checking to speed-up the tests. It should be fine as `npm run typecheck` + * covers test files too. + */ + +Object.assign(process.env, { + TS_NODE_FILES: true, + TS_NODE_TRANSPILE_ONLY: true, +}, process.env); + module.exports = { - require: [ - 'ts-node/register', - '@babel/register', - ], - reporter: 'spec', - ui: 'bdd', - extension: ['js', 'ts'], + ...base, // see https://github.com/mochajs/mocha/issues/3916 + exit: true, spec: [ + 'app/**/*.spec.ts', 'app/**/*.tests.js', 'app/**/*.tests.ts', 'server/**/*.tests.ts', - 'client/**/*.spec.ts', ], }; diff --git a/.scripts/make-migration.ts b/.scripts/make-migration.ts new file mode 100644 index 0000000000000..d9df274539af8 --- /dev/null +++ b/.scripts/make-migration.ts @@ -0,0 +1,50 @@ +import { readdirSync, readFileSync, writeFileSync } from 'fs'; + +import { renderFile } from 'template-file'; + +function main(number: string, comment: string): void { + if (!(Number(number) >= 0)) { + console.error(`1st param must be a valid number. ${ number } provided`); + return; + } + + if (comment.trim()) { + comment = `// ${ comment }`; + } + + // check if migration will conflict with current on-branch migrations + const migrationName = `v${ number }`; + const fileList = readdirSync('./server/startup/migrations'); + if (fileList.includes(`${ migrationName }.ts`)) { + console.error('Migration with specified number already exists'); + return; + } + + renderFile('./.scripts/migration.template', { number, comment }) + .then((renderedMigration) => { + // generate new migration file + writeFileSync(`./server/startup/migrations/${ migrationName }.ts`, renderedMigration); + + // get contents of index.ts to append new migration + const indexFile = readFileSync('./server/startup/migrations/index.ts'); + const splittedIndexLines = indexFile.toString().split('\n'); + + // remove end line + xrun import + splittedIndexLines.splice(splittedIndexLines.length - 2, 0, `import './${ migrationName }';`); + const data = splittedIndexLines.join('\n'); + + // append migration import to indexfile + writeFileSync('./server/startup/migrations/index.ts', data); + console.log(`Migration ${ migrationName } created`); + }) + .catch(console.error); +} + +const [, , number, comment = ''] = process.argv; + +if (!number || (comment && !comment.trim())) { + console.error('Usage:\n\tmeteor npm run migration:add [migration comment: optional]\n'); + process.exit(1); +} + +main(number, comment); diff --git a/.scripts/migration.template b/.scripts/migration.template new file mode 100644 index 0000000000000..0b1ba7b550e2c --- /dev/null +++ b/.scripts/migration.template @@ -0,0 +1,9 @@ +import { addMigration } from '../../lib/migrations'; + +{{ comment }} +addMigration({ + version: {{ number }}, + up() { + + }, +}); diff --git a/.snapcraft/resources/prepareRocketChat b/.snapcraft/resources/prepareRocketChat index 5162f965d6e1e..2573d0855ee3a 100755 --- a/.snapcraft/resources/prepareRocketChat +++ b/.snapcraft/resources/prepareRocketChat @@ -1,6 +1,6 @@ #!/bin/bash -curl -SLf "https://releases.rocket.chat/4.1.0-develop/download/" -o rocket.chat.tgz +curl -SLf "https://releases.rocket.chat/4.3.0-develop/download/" -o rocket.chat.tgz tar xf rocket.chat.tgz --strip 1 diff --git a/.snapcraft/snap/snapcraft.yaml b/.snapcraft/snap/snapcraft.yaml index 2ed13efd925e2..0f18d6ee45e23 100644 --- a/.snapcraft/snap/snapcraft.yaml +++ b/.snapcraft/snap/snapcraft.yaml @@ -7,7 +7,7 @@ # 5. `snapcraft snap` name: rocketchat-server -version: 4.1.0-develop +version: 4.3.0-develop summary: Rocket.Chat server description: Have your own Slack like online chat, built with Meteor. https://rocket.chat/ confinement: strict diff --git a/.storybook/.eslintrc.js b/.storybook/.eslintrc.js new file mode 120000 index 0000000000000..8589dc8c53248 --- /dev/null +++ b/.storybook/.eslintrc.js @@ -0,0 +1 @@ +../client/.eslintrc.js \ No newline at end of file diff --git a/.storybook/.prettierrc b/.storybook/.prettierrc new file mode 120000 index 0000000000000..4031483e531f1 --- /dev/null +++ b/.storybook/.prettierrc @@ -0,0 +1 @@ +../client/.prettierrc \ No newline at end of file diff --git a/.storybook/decorators.tsx b/.storybook/decorators.tsx index 01b7b4f93cb78..9314684c128f3 100644 --- a/.storybook/decorators.tsx +++ b/.storybook/decorators.tsx @@ -1,6 +1,8 @@ import React, { ReactElement } from 'react'; import { MeteorProviderMock } from './mocks/providers'; +import QueryClientProviderMock from './mocks/providers/QueryClientProviderMock'; +import ServerProviderMock from './mocks/providers/ServerProviderMock'; export const rocketChatDecorator = (storyFn: () => ReactElement): ReactElement => { const linkElement = document.getElementById('theme-styles') || document.createElement('link'); @@ -18,34 +20,44 @@ export const rocketChatDecorator = (storyFn: () => ReactElement): ReactElement = /* eslint-disable-next-line */ const { default: icons } = require('!!raw-loader!../private/public/icons.svg'); - return - -
-
- {storyFn()} -
- ; + return ( + + + + +
+
{storyFn()}
+ + + + ); }; -export const fullHeightDecorator = (storyFn: () => ReactElement): ReactElement => -
+export const fullHeightDecorator = (storyFn: () => ReactElement): ReactElement => ( +
{storyFn()} -
; +
+); -export const centeredDecorator = (storyFn: () => ReactElement): ReactElement => -
+export const centeredDecorator = (storyFn: () => ReactElement): ReactElement => ( +
{storyFn()} -
; +
+); diff --git a/.storybook/hooks/index.ts b/.storybook/hooks/index.ts new file mode 100644 index 0000000000000..ca0d1db71f7f5 --- /dev/null +++ b/.storybook/hooks/index.ts @@ -0,0 +1 @@ +export * from './useAutoToggle'; diff --git a/.storybook/hooks.ts b/.storybook/hooks/useAutoToggle.ts similarity index 100% rename from .storybook/hooks.ts rename to .storybook/hooks/useAutoToggle.ts diff --git a/.storybook/main.js b/.storybook/main.js index 7ac1da4c927f6..58531e40b22b2 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -3,18 +3,15 @@ const { resolve, relative, join } = require('path'); const webpack = require('webpack'); module.exports = { - typescript: { - reactDocgen: 'none', - }, stories: [ '../app/**/*.stories.{js,tsx}', '../client/**/*.stories.{js,tsx}', - '../ee/**/*.stories.{js,tsx}', - ], - addons: [ - '@storybook/addon-essentials', - '@storybook/addon-postcss', + ...(process.env.EE === 'true' ? ['../ee/**/*.stories.{js,tsx}'] : []), ], + addons: ['@storybook/addon-essentials', '@storybook/addon-postcss'], + typescript: { + reactDocgen: 'none', + }, webpackFinal: async (config) => { const cssRule = config.module.rules.find(({ test }) => test.test('index.css')); @@ -22,16 +19,21 @@ module.exports = { ...cssRule.use[2].options, postcssOptions: { plugins: [ - require('postcss-custom-properties')({ preserve: true }), - require('postcss-media-minmax')(), - require('postcss-nested')(), - require('autoprefixer')(), - require('postcss-url')({ url: ({ absolutePath, relativePath, url }) => { - const absoluteDir = absolutePath.slice(0, -relativePath.length); - const relativeDir = relative(absoluteDir, resolve(__dirname, '../public')); - const newPath = join(relativeDir, url); - return newPath; - } }), + ['postcss-custom-properties', { preserve: true }], + 'postcss-media-minmax', + 'postcss-nested', + 'autoprefixer', + [ + 'postcss-url', + { + url: ({ absolutePath, relativePath, url }) => { + const absoluteDir = absolutePath.slice(0, -relativePath.length); + const relativeDir = relative(absoluteDir, resolve(__dirname, '../public')); + const newPath = join(relativeDir, url); + return newPath; + }, + }, + ], ], }, }; @@ -59,10 +61,7 @@ module.exports = { }); config.plugins.push( - new webpack.NormalModuleReplacementPlugin( - /^meteor/, - require.resolve('./mocks/meteor.js'), - ), + new webpack.NormalModuleReplacementPlugin(/^meteor/, require.resolve('./mocks/meteor.js')), new webpack.NormalModuleReplacementPlugin( /(app)\/*.*\/(server)\/*/, require.resolve('./mocks/empty.ts'), diff --git a/.storybook/mocks/meteor.js b/.storybook/mocks/meteor.js index e4746cb59f0e0..ef22c95f67a7e 100644 --- a/.storybook/mocks/meteor.js +++ b/.storybook/mocks/meteor.js @@ -13,6 +13,10 @@ export const Meteor = { on: () => {}, removeListener: () => {}, }), + StreamerCentral: { + on: () => {}, + removeListener: () => {}, + }, startup: () => {}, methods: () => {}, call: () => {}, @@ -41,7 +45,9 @@ export const ReactiveVar = (val) => { let currentVal = val; return { get: () => currentVal, - set: (val) => { currentVal = val; }, + set: (val) => { + currentVal = val; + }, }; }; @@ -51,16 +57,19 @@ export const ReactiveDict = () => ({ all: () => {}, }); -export const Template = Object.assign(() => ({ - onCreated: () => {}, - onRendered: () => {}, - onDestroyed: () => {}, - helpers: () => {}, - events: () => {}, -}), { - registerHelper: () => {}, - __checkName: () => {}, -}); +export const Template = Object.assign( + () => ({ + onCreated: () => {}, + onRendered: () => {}, + onDestroyed: () => {}, + helpers: () => {}, + events: () => {}, + }), + { + registerHelper: () => {}, + __checkName: () => {}, + }, +); export const Blaze = { Template, diff --git a/.storybook/mocks/providers/QueryClientProviderMock.tsx b/.storybook/mocks/providers/QueryClientProviderMock.tsx new file mode 100644 index 0000000000000..d44ea0e9d0798 --- /dev/null +++ b/.storybook/mocks/providers/QueryClientProviderMock.tsx @@ -0,0 +1,20 @@ +import React, { FC } from 'react'; +import { QueryCache, QueryClient, QueryClientProvider } from 'react-query'; + +const queryCache = new QueryCache(); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: Infinity, + }, + }, + queryCache, +}); + +const QueryClientProviderMock: FC = ({ children }) => ( + {children} +); + +export default QueryClientProviderMock; diff --git a/.storybook/mocks/providers/ServerProviderMock.tsx b/.storybook/mocks/providers/ServerProviderMock.tsx new file mode 100644 index 0000000000000..7cc9a273b0651 --- /dev/null +++ b/.storybook/mocks/providers/ServerProviderMock.tsx @@ -0,0 +1,96 @@ +import { action } from '@storybook/addon-actions'; +import React, { ContextType, FC } from 'react'; + +import { + ServerContext, + ServerMethodName, + ServerMethodParameters, + ServerMethodReturn, +} from '../../../client/contexts/ServerContext'; +import { Serialized } from '../../../definition/Serialized'; +import { + MatchPathPattern, + Method, + OperationParams, + OperationResult, + PathFor, +} from '../../../definition/rest'; + +const logAction = action('ServerProvider'); + +const randomDelay = (): Promise => + new Promise((resolve) => setTimeout(resolve, Math.random() * 1000)); + +const absoluteUrl = (path: string): string => new URL(path, '/').toString(); + +const callMethod = ( + methodName: MethodName, + ...args: ServerMethodParameters +): Promise> => + Promise.resolve(logAction('callMethod', methodName, ...args)) + .then(randomDelay) + .then(() => undefined as any); + +const callEndpoint = >( + method: TMethod, + path: TPath, + params: Serialized>>, +): Promise>>> => + Promise.resolve(logAction('callEndpoint', method, path, params)) + .then(randomDelay) + .then(() => undefined as any); + +const uploadToEndpoint = (endpoint: string, params: any, formData: any): Promise => + Promise.resolve(logAction('uploadToEndpoint', endpoint, params, formData)).then(randomDelay); + +const getStream = ( + streamName: string, + options: {} = {}, +): ((eventName: string, callback: (data: T) => void) => () => void) => { + logAction('getStream', streamName, options); + + return (eventName, callback): (() => void) => { + const subId = Math.random().toString(16).slice(2); + logAction('getStream.subscribe', streamName, eventName, subId); + + randomDelay().then(() => callback(undefined as any)); + + return (): void => { + logAction('getStream.unsubscribe', streamName, eventName, subId); + }; + }; +}; + +const ServerProviderMock: FC>> = ({ + children, + ...overrides +}) => ( + +); + +export default ServerProviderMock; diff --git a/.storybook/mocks/providers.tsx b/.storybook/mocks/providers/index.tsx similarity index 74% rename from .storybook/mocks/providers.tsx rename to .storybook/mocks/providers/index.tsx index 31a66433c753d..9cecb32ff8b94 100644 --- a/.storybook/mocks/providers.tsx +++ b/.storybook/mocks/providers/index.tsx @@ -1,8 +1,10 @@ import i18next from 'i18next'; import React, { PropsWithChildren, ReactElement } from 'react'; -import { TranslationContext, TranslationContextValue } from '../../client/contexts/TranslationContext'; -import ServerProvider from '../../client/providers/ServerProvider'; +import { + TranslationContext, + TranslationContextValue, +} from '../../../client/contexts/TranslationContext'; let contextValue: TranslationContextValue; @@ -16,7 +18,7 @@ const getContextValue = (): TranslationContextValue => { defaultNS: 'project', resources: { en: { - project: require('../../packages/rocketchat-i18n/i18n/en.i18n.json'), + project: require('../../../packages/rocketchat-i18n/i18n/en.i18n.json'), }, }, interpolation: { @@ -45,11 +47,13 @@ const getContextValue = (): TranslationContextValue => { translate.has = (key: string): boolean => !!key && i18next.exists(key); contextValue = { - languages: [{ - name: 'English', - en: 'English', - key: 'en', - }], + languages: [ + { + name: 'English', + en: 'English', + key: 'en', + }, + ], language: 'en', translate, loadLanguage: async (): Promise => undefined, @@ -62,10 +66,7 @@ function TranslationProviderMock({ children }: PropsWithChildren<{}>): ReactElem return ; } +// eslint-disable-next-line react/no-multi-comp export function MeteorProviderMock({ children }: PropsWithChildren<{}>): ReactElement { - return - - {children} - - ; + return {children}; } diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 2839f538c25ef..ab9bd5a3220d1 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,4 +1,4 @@ -import { DocsPage, DocsContainer } from '@storybook/addon-docs/blocks'; +import { DocsPage, DocsContainer } from '@storybook/addon-docs'; import { addDecorator, addParameters } from '@storybook/react'; import { rocketChatDecorator } from './decorators'; @@ -18,7 +18,6 @@ addParameters({ page: DocsPage, }, options: { - storySort: ([, a], [, b]): number => - a.kind.localeCompare(b.kind), + storySort: ([, a], [, b]): number => a.kind.localeCompare(b.kind), }, }); diff --git a/HISTORY.md b/HISTORY.md index 383262cae967b..453efbf632d33 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,4 +1,805 @@ +# 4.2.0 +`2021-11-30 · 9 🎉 · 7 🚀 · 26 🐛 · 27 🔍 · 24 👩‍💻👨‍💻` + +### Engine versions +- Node: `12.22.1` +- NPM: `6.14.1` +- MongoDB: `3.6, 4.0, 4.2, 4.4, 5.0` +- Apps-Engine: `1.28.1` + +### 🎉 New features + + +- Allow Omnichannel statistics to be collected. ([#23694](https://github.com/RocketChat/Rocket.Chat/pull/23694)) + + This PR adds the possibility for business stakeholders to see what is actually being used of the Omnichannel integrations. + +- Allow registering by REG_TOKEN environment variable ([#23737](https://github.com/RocketChat/Rocket.Chat/pull/23737)) + + You can provide the REG_TOKEN environment variable containing a registration token and it will automatically register to your cloud account. This simplifies the registration flow + +- Audio and Video calling in Livechat ([#23004](https://github.com/RocketChat/Rocket.Chat/pull/23004) by [@Deepak-learner](https://github.com/Deepak-learner) & [@dhruvjain99](https://github.com/dhruvjain99)) + +- Enable LDAP manual sync to deployments without EE license ([#23761](https://github.com/RocketChat/Rocket.Chat/pull/23761)) + + Open the Enterprise LDAP API that executes background sync to be used without any Enterprise License and enforce 2FA requirements. + +- Permission for download/uploading files on mobile ([#23686](https://github.com/RocketChat/Rocket.Chat/pull/23686)) + +- Permissions for interacting with Omnichannel Contact Center ([#23389](https://github.com/RocketChat/Rocket.Chat/pull/23389)) + + Adds a new permission, one that allows for control over user access to Omnichannel Contact Center, + +- Rate limiting for user registering ([#23732](https://github.com/RocketChat/Rocket.Chat/pull/23732)) + +- REST endpoints to manage Omnichannel Business Units ([#23750](https://github.com/RocketChat/Rocket.Chat/pull/23750)) + + Basic documentation about endpoints can be found at https://www.postman.com/kaleman960/workspace/rocketchat-public-api/request/3865466-71502450-8c8f-42b4-8954-1cd3d01fcb0c + +- Show on-hold metrics on analytics pages and current chats ([#23498](https://github.com/RocketChat/Rocket.Chat/pull/23498)) + +### 🚀 Improvements + + +- Allow override of default department for SMS Livechat sessions ([#23626](https://github.com/RocketChat/Rocket.Chat/pull/23626) by [@bhardwajaditya](https://github.com/bhardwajaditya)) + +- Engagement Dashboard ([#23547](https://github.com/RocketChat/Rocket.Chat/pull/23547)) + + - Adds helpers `onToggledFeature` for server and client code to handle license activation/deactivation without server restart; + - Replaces usage of `useEndpointData` with `useQuery` (from [React Query](https://react-query.tanstack.com/)); + - Introduces `view-engagement-dashboard` permission. + +- Improve the add user drop down for add a user in create channel modal for UserAutoCompleteMultiple ([#23766](https://github.com/RocketChat/Rocket.Chat/pull/23766) by [@Jeanstaquet](https://github.com/Jeanstaquet)) + + Seeing only the name of the person you are not adding is not practical in my opinion because two people can have the same name. Moreover, you can't see the username of the person you want to add in the dropdown. So I changed that and created another selection of users to show the username as well. I made this change so that it would appear in the key place for creating a room and adding a user. + + Before: + + https://user-images.githubusercontent.com/45966964/115287805-faac8d00-a150-11eb-871f-147ab011ced0.mp4 + + + After: + + https://user-images.githubusercontent.com/45966964/115287664-d2249300-a150-11eb-8cf6-0e04730b425d.mp4 + +- MKP12 - New UI - Merge Apps and Marketplace Tabs and Content ([#23542](https://github.com/RocketChat/Rocket.Chat/pull/23542)) + + Merged the Marketplace and Apps page into a single page with a tabs component that changes between Markeplace and installed apps. + ![page merging](https://user-images.githubusercontent.com/43561537/138516558-f86d62e6-1a5c-4817-a229-a1b876323960.gif) + +- Re-naming department query param for Twilio ([#23725](https://github.com/RocketChat/Rocket.Chat/pull/23725)) + + Since the endpoint supports both, department ID and department Name, so we're renaming it to reflect the same. `departmentName` -> `department` + +- Reduce complexity in some functions ([#23387](https://github.com/RocketChat/Rocket.Chat/pull/23387)) + + Overhauls all places where eslint's `complexity` rule is disabled. + +- Stricter API types ([#23735](https://github.com/RocketChat/Rocket.Chat/pull/23735)) + + It: + - Adds stricter types for `API`; + - Enables types for `urlParams`; + - Removes mandatory passage of `undefined` payload on client; + - Corrects some regressions; + - Reassures my belief in TypeScript supremacy. + +### 🐛 Bug fixes + + +- "to users" not working in export message ([#23576](https://github.com/RocketChat/Rocket.Chat/pull/23576)) + +- **ENTERPRISE:** OAuth "Merge Roles" removes roles from users ([#23588](https://github.com/RocketChat/Rocket.Chat/pull/23588)) + + - Fix OAuth "Merge Roles": the "Merge Roles" option now synchronize only the roles described in the "**Roles to Sync**" setting available in each Custom OAuth settings' group (instead of replacing users' roles by their OAuth roles); + - Fix "Merge Roles" and "Channel Mapping" not being performed/updated on OAuth login. + +- **ENTERPRISE:** Private rooms and discussions can't be audited ([#23673](https://github.com/RocketChat/Rocket.Chat/pull/23673)) + + - Add Private rooms (groups) and Discussions to the Message Auditing (Channels) autocomplete; + - Update "Channels" tab name to "Rooms". + +- **ENTERPRISE:** Replace all occurrences of a placeholder on string instead of just first one ([#23703](https://github.com/RocketChat/Rocket.Chat/pull/23703)) + +- Advanced LDAP Sync Features ([#23608](https://github.com/RocketChat/Rocket.Chat/pull/23608)) + +- App update flow failing in HA setups ([#23607](https://github.com/RocketChat/Rocket.Chat/pull/23607)) + + The flow for app updates is broken in specific scenarios with HA setups. Here we change the method calls in the Apps-Engine to avoid race conditions + +- Apps scheduler "losing" jobs after server restart ([#23566](https://github.com/RocketChat/Rocket.Chat/pull/23566)) + + If a job is scheduled and the server restarted, said job won't be executed, giving the impression it's been lost. + + What happens is that the scheduler is only started when some app tries to schedule an app - if that happens, all jobs that are "late" will be executed; if that doesn't happen, no job will run. + + This PR starts the apps scheduler right after all apps have been loaded + +- Autofocus on search input in admin ([#23738](https://github.com/RocketChat/Rocket.Chat/pull/23738)) + + Removed "generic" autofocus on sidenav template. + +- Await promise to handle error when attempting to transfer a room ([#23739](https://github.com/RocketChat/Rocket.Chat/pull/23739)) + +- broken avatar preview when changing avatar ([#23659](https://github.com/RocketChat/Rocket.Chat/pull/23659) by [@Aman-Maheshwari](https://github.com/Aman-Maheshwari)) + +- Discussions created inside discussions ([#23733](https://github.com/RocketChat/Rocket.Chat/pull/23733)) + +- Fix typo in FR translation ([#23711](https://github.com/RocketChat/Rocket.Chat/pull/23711) by [@Cormoran96](https://github.com/Cormoran96)) + +- Fixed E2E default room settings not being honoured ([#23468](https://github.com/RocketChat/Rocket.Chat/pull/23468) by [@TheDigitalEagle](https://github.com/TheDigitalEagle)) + +- LDAP users being disabled when an AD security policy is enabled ([#23820](https://github.com/RocketChat/Rocket.Chat/pull/23820)) + +- LDAP users not being re-activated on login ([#23627](https://github.com/RocketChat/Rocket.Chat/pull/23627)) + +- Missing user roles in edit user tab ([#23734](https://github.com/RocketChat/Rocket.Chat/pull/23734)) + +- New specific endpoint for contactChatHistoryMessages with right permissions ([#23533](https://github.com/RocketChat/Rocket.Chat/pull/23533)) + + Anyone with 'View Omnichannel Rooms' permission can see the History Messages. + +- Notifications are not being filtered ([#23487](https://github.com/RocketChat/Rocket.Chat/pull/23487)) + + - Add a migration to update the `Accounts_Default_User_Preferences_pushNotifications` setting's value to the `Accounts_Default_User_Preferences_mobileNotifications` setting's value; + - Remove the `Accounts_Default_User_Preferences_mobileNotifications` setting (replaced by `Accounts_Default_User_Preferences_pushNotifications`); + - Rename 'mobileNotifications' user's preference to 'pushNotifications'. + +- Omnichannel business hours page breaking navigation ([#23595](https://github.com/RocketChat/Rocket.Chat/pull/23595) by [@Aman-Maheshwari](https://github.com/Aman-Maheshwari)) + +- Omnichannel contact center navigation ([#23691](https://github.com/RocketChat/Rocket.Chat/pull/23691)) + + Derives from: https://github.com/RocketChat/Rocket.Chat/pull/23656 + + This PR includes a different approach to solving navigation problems following the same code structure and UI definitions of other "ActionButtons" components in Sidebar. + +- Omnichannel status being changed on page refresh ([#23587](https://github.com/RocketChat/Rocket.Chat/pull/23587)) + +- Omnichannel webhooks can't be saved ([#23641](https://github.com/RocketChat/Rocket.Chat/pull/23641) by [@Aman-Maheshwari](https://github.com/Aman-Maheshwari)) + +- Performance issues when running Omnichannel job queue dispatcher ([#23661](https://github.com/RocketChat/Rocket.Chat/pull/23661)) + +- PhotoSwipe crashing on show ([#23499](https://github.com/RocketChat/Rocket.Chat/pull/23499)) + + Waits for initial content to load before showing it. + +- Prevent UserAction.addStream without Subscription ([#23705](https://github.com/RocketChat/Rocket.Chat/pull/23705)) + + When you take an Omnichannel chat from queue, the guest's typing information will appear. + +- Registration not possible when any user is blocked for multiple failed logins ([#23565](https://github.com/RocketChat/Rocket.Chat/pull/23565)) + +
+🔍 Minor changes + + +- Chore: add `no-bidi` rule ([#23695](https://github.com/RocketChat/Rocket.Chat/pull/23695)) + +- Chore: add index on appId + associations for apps_persistence collection ([#23675](https://github.com/RocketChat/Rocket.Chat/pull/23675)) + +- Chore: Api definitions ([#23701](https://github.com/RocketChat/Rocket.Chat/pull/23701)) + +- Chore: Bump Rocket.Chat@livechat to 1.10 ([#23768](https://github.com/RocketChat/Rocket.Chat/pull/23768)) + +- Chore: Convert Fiber models to async Step 1 ([#23633](https://github.com/RocketChat/Rocket.Chat/pull/23633)) + +- Chore: Generic Table ([#23745](https://github.com/RocketChat/Rocket.Chat/pull/23745)) + +- Chore: Mocha testing configuration ([#23706](https://github.com/RocketChat/Rocket.Chat/pull/23706)) + + We've been writing integration tests for the REST API quite regularly, but we can't say the same for UI-related modules. This PR is based on the assumption that _improving the developer experience on writing tests_ would increase our coverage and promote the adoption even for newcomers. + + Here as summary of the proposal: + + - Change Mocha configuration files: + - Add a base configuration (`.mocharc.base.json`); + - Rename the configuration for REST API tests (`mocha_end_to_end.opts.js -> .mocharc.api.js`); + - Add a configuration for client modules (`.mocharc.client.js`); + - Enable ESLint for them. + - Add a Mocha test command exclusive for client modules (`npm run testunit-client`); + - Enable fast watch mode: + - Configure `ts-node` to only transpile code (skip type checking); + - Define a list of files to be watched. + - Configure `mocha` environment on ESLint only for test files (required when using Mocha's globals); + - Adopt Chai as our assertion library: + - Unify the setup of Chai plugins (`chai-spies`, `chai-datetime`, `chai-dom`); + - Replace `assert` with `chai`; + - Replace `chai.expect` with `expect`. + - Enable integration tests with React components: + - Enable JSX support on our default Babel configuration; + - Adopt [testing library](https://testing-library.com/). + +- Chore: Rearrange module typings ([#23452](https://github.com/RocketChat/Rocket.Chat/pull/23452)) + + - Move all external module declarations (definitions and augmentations) to `/definition/externals`; + - ~Symlink some modules on `/definition/externals` to `/ee/server/services/definition/externals`~ Share types with `/ee/server/services`; + - Use TypeScript as server code entrypoint. + +- Chore: Remove duplicated 'name' key from rate limiter logs ([#23771](https://github.com/RocketChat/Rocket.Chat/pull/23771)) + +- Chore: Remove useCallbacks ([#23696](https://github.com/RocketChat/Rocket.Chat/pull/23696)) + +- Chore: Type omnichannel models ([#23758](https://github.com/RocketChat/Rocket.Chat/pull/23758)) + +- Chore: Update settings.ts ([#23769](https://github.com/RocketChat/Rocket.Chat/pull/23769)) + +- i18n: Language update from LingoHub 🤖 on 2021-11-01Z ([#23603](https://github.com/RocketChat/Rocket.Chat/pull/23603)) + +- i18n: Language update from LingoHub 🤖 on 2021-11-29Z ([#23812](https://github.com/RocketChat/Rocket.Chat/pull/23812)) + +- Merge master into develop & Set version to 4.2.0-develop ([#23586](https://github.com/RocketChat/Rocket.Chat/pull/23586)) + +- Regression: Units endpoint to TS ([#23757](https://github.com/RocketChat/Rocket.Chat/pull/23757)) + +- Regression: "When is the chat busier" and "Users by time of day" charts are not working ([#23815](https://github.com/RocketChat/Rocket.Chat/pull/23815)) + + - Fix "When is the chat busier" (Hours) and "Users by time of day" charts, which weren't displaying any data; + +- Regression: Add @rocket.chat/emitter to EE services ([#23802](https://github.com/RocketChat/Rocket.Chat/pull/23802)) + +- Regression: Add trash to raw models ([#23774](https://github.com/RocketChat/Rocket.Chat/pull/23774)) + +- Regression: Current Chats not Filtering ([#23803](https://github.com/RocketChat/Rocket.Chat/pull/23803)) + +- Regression: Fix incorrect API path for livechat calls ([#23778](https://github.com/RocketChat/Rocket.Chat/pull/23778)) + +- Regression: Fix LDAP sync route ([#23775](https://github.com/RocketChat/Rocket.Chat/pull/23775)) + +- Regression: Fix sendMessagesToAdmins not in Fiber ([#23770](https://github.com/RocketChat/Rocket.Chat/pull/23770)) + +- Regression: Fix sort param on omnichannel endpoints ([#23789](https://github.com/RocketChat/Rocket.Chat/pull/23789)) + +- Regression: Improve AggregationCursor types ([#23692](https://github.com/RocketChat/Rocket.Chat/pull/23692)) + +- Regression: Include files on EE services build ([#23793](https://github.com/RocketChat/Rocket.Chat/pull/23793)) + +- Regression: Mark Livechat WebRTC video calling as alpha ([#23813](https://github.com/RocketChat/Rocket.Chat/pull/23813)) + + ![image](https://user-images.githubusercontent.com/34130764/143832378-82b99a72-23e8-4115-8b28-a0d210de598b.png) + +
+ +### 👩‍💻👨‍💻 Contributors 😍 + +- [@Aman-Maheshwari](https://github.com/Aman-Maheshwari) +- [@Cormoran96](https://github.com/Cormoran96) +- [@Deepak-learner](https://github.com/Deepak-learner) +- [@Jeanstaquet](https://github.com/Jeanstaquet) +- [@TheDigitalEagle](https://github.com/TheDigitalEagle) +- [@bhardwajaditya](https://github.com/bhardwajaditya) +- [@dhruvjain99](https://github.com/dhruvjain99) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@KevLehman](https://github.com/KevLehman) +- [@MartinSchoeler](https://github.com/MartinSchoeler) +- [@cauefcr](https://github.com/cauefcr) +- [@d-gubert](https://github.com/d-gubert) +- [@dougfabris](https://github.com/dougfabris) +- [@gabriellsh](https://github.com/gabriellsh) +- [@geekgonecrazy](https://github.com/geekgonecrazy) +- [@ggazzo](https://github.com/ggazzo) +- [@matheusbsilva137](https://github.com/matheusbsilva137) +- [@murtaza98](https://github.com/murtaza98) +- [@ostjen](https://github.com/ostjen) +- [@pierre-lehnen-rc](https://github.com/pierre-lehnen-rc) +- [@renatobecker](https://github.com/renatobecker) +- [@rodrigok](https://github.com/rodrigok) +- [@sampaiodiego](https://github.com/sampaiodiego) +- [@tassoevan](https://github.com/tassoevan) +- [@tiagoevanp](https://github.com/tiagoevanp) + +# 4.1.2 +`2021-11-08 · 3 🐛 · 3 👩‍💻👨‍💻` + +### Engine versions +- Node: `12.22.1` +- NPM: `6.14.1` +- MongoDB: `3.6, 4.0, 4.2, 4.4, 5.0` +- Apps-Engine: `1.28.1` + +### 🐛 Bug fixes + + +- Notifications are not being filtered ([#23487](https://github.com/RocketChat/Rocket.Chat/pull/23487)) + + - Add a migration to update the `Accounts_Default_User_Preferences_pushNotifications` setting's value to the `Accounts_Default_User_Preferences_mobileNotifications` setting's value; + - Remove the `Accounts_Default_User_Preferences_mobileNotifications` setting (replaced by `Accounts_Default_User_Preferences_pushNotifications`); + - Rename 'mobileNotifications' user's preference to 'pushNotifications'. + +- Omnichannel status being changed on page refresh ([#23587](https://github.com/RocketChat/Rocket.Chat/pull/23587)) + +- Performance issues when running Omnichannel job queue dispatcher ([#23661](https://github.com/RocketChat/Rocket.Chat/pull/23661)) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@KevLehman](https://github.com/KevLehman) +- [@matheusbsilva137](https://github.com/matheusbsilva137) +- [@renatobecker](https://github.com/renatobecker) + +# 4.1.1 +`2021-11-05 · 4 🐛 · 3 👩‍💻👨‍💻` + +### Engine versions +- Node: `12.22.1` +- NPM: `6.14.1` +- MongoDB: `3.6, 4.0, 4.2, 4.4, 5.0` +- Apps-Engine: `1.28.1` + +### 🐛 Bug fixes + + +- Advanced LDAP Sync Features ([#23608](https://github.com/RocketChat/Rocket.Chat/pull/23608)) + +- App update flow failing in HA setups ([#23607](https://github.com/RocketChat/Rocket.Chat/pull/23607)) + + The flow for app updates is broken in specific scenarios with HA setups. Here we change the method calls in the Apps-Engine to avoid race conditions + +- LDAP users not being re-activated on login ([#23627](https://github.com/RocketChat/Rocket.Chat/pull/23627)) + +- Security Hotfix (https://docs.rocket.chat/guides/security/security-updates) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@d-gubert](https://github.com/d-gubert) +- [@pierre-lehnen-rc](https://github.com/pierre-lehnen-rc) +- [@sampaiodiego](https://github.com/sampaiodiego) + +# 4.1.0 +`2021-10-28 · 1 🎉 · 4 🚀 · 25 🐛 · 38 🔍 · 23 👩‍💻👨‍💻` + +### Engine versions +- Node: `12.22.1` +- NPM: `6.14.1` +- MongoDB: `3.6, 4.0, 4.2, 4.4, 5.0` +- Apps-Engine: `1.28.0` + +### 🎉 New features + + +- Stream to get individual presence updates ([#22950](https://github.com/RocketChat/Rocket.Chat/pull/22950)) + +### 🚀 Improvements + + +- Add markdown to custom fields in user Info ([#20947](https://github.com/RocketChat/Rocket.Chat/pull/20947)) + + Added markdown to custom fields to render links + +- Allow Omnichannel to handle huge queues ([#23392](https://github.com/RocketChat/Rocket.Chat/pull/23392)) + +- Make Livechat Instructions setting multi-line ([#23515](https://github.com/RocketChat/Rocket.Chat/pull/23515)) + + Since now we're supporting markdown text on this field (via this PR - https://github.com/RocketChat/Rocket.Chat.Livechat/pull/648), it would be nice to make this setting multiline so users can have more space to edit the text + ![image](https://user-images.githubusercontent.com/34130764/138146712-13e4968b-5312-4d53-b44c-b5699c5e49c1.png) + +- optimized groups.listAll response time ([#22941](https://github.com/RocketChat/Rocket.Chat/pull/22941)) + + groups.listAll endpoint was having performance issues, specially when the total number of groups was high. This happened because the endpoint was loading all objects in memory then using splice to paginate, instead of paginating beforehand. + + Considering 70k groups, this was the performance improvement: + + before + ![image](https://user-images.githubusercontent.com/28611993/129601314-bdf89337-79fa-4446-9f44-95264af4adb3.png) + + after + ![image](https://user-images.githubusercontent.com/28611993/129601358-5872e166-f923-4c1c-b21d-eb9507365ecf.png) + +### 🐛 Bug fixes + + +- **APPS:** Communication problem when updating and uninstalling apps in cluster ([#23418](https://github.com/RocketChat/Rocket.Chat/pull/23418)) + + - Make the hook responsible for receiving app update events inside a cluster fetch the app's package (zip file) in the correct place. + - Also shows a warning message on uninstalls inside a cluster. As there are many servers writing to the same place, some race conditions may occur. This prevents problems related to terminating the process in the middle due to errors being thrown and leaving the server in a faulty state. + +- **ENTERPRISE:** Omnichannel agent is not leaving the room when a forwarded chat is queued ([#23404](https://github.com/RocketChat/Rocket.Chat/pull/23404)) + +- Admins can't update or reset user avatars when the "Allow User Avatar Change" setting is off ([#23228](https://github.com/RocketChat/Rocket.Chat/pull/23228)) + + - Allow admins (or any other user with the `edit-other-user-avatar` permission) to update or reset user avatars even when the "Allow User Avatar Change" setting is off. + +- Attachment buttons overlap in mobile view ([#23377](https://github.com/RocketChat/Rocket.Chat/pull/23377) by [@Aman-Maheshwari](https://github.com/Aman-Maheshwari)) + +- Avoid last admin deactivate itself ([#22949](https://github.com/RocketChat/Rocket.Chat/pull/22949)) + + Co-authored-by: @Kartik18g + +- BigBlueButton integration error due to missing file import ([#23366](https://github.com/RocketChat/Rocket.Chat/pull/23366) by [@wolbernd](https://github.com/wolbernd)) + + Fixes BigBlueButton integration + +- Delay start of email inbox ([#23521](https://github.com/RocketChat/Rocket.Chat/pull/23521)) + +- imported migration v240 ([#23374](https://github.com/RocketChat/Rocket.Chat/pull/23374)) + +- LDAP not stoping after wrong password ([#23382](https://github.com/RocketChat/Rocket.Chat/pull/23382)) + +- Markdown quote message style ([#23462](https://github.com/RocketChat/Rocket.Chat/pull/23462)) + + Before: + ![image](https://user-images.githubusercontent.com/17487063/137496669-3abecab4-cf90-45cb-8b1b-d9411a5682dd.png) + + After: + ![image](https://user-images.githubusercontent.com/17487063/137496905-fd727f90-f707-4ec6-8139-ba2eb1a2146e.png) + +- MONGO_OPTIONS being ignored for oplog connection ([#23314](https://github.com/RocketChat/Rocket.Chat/pull/23314) by [@cuonghuunguyen](https://github.com/cuonghuunguyen)) + +- MongoDB deprecation link ([#23381](https://github.com/RocketChat/Rocket.Chat/pull/23381)) + +- OAuth login not working on mobile app ([#23541](https://github.com/RocketChat/Rocket.Chat/pull/23541)) + +- Omni-Webhook's retry mechanism going in infinite loop ([#23394](https://github.com/RocketChat/Rocket.Chat/pull/23394)) + +- Prevent starting Omni-Queue if Omnichannel is disabled ([#23396](https://github.com/RocketChat/Rocket.Chat/pull/23396)) + + Whenever the Routing system setting changes, and omnichannel is disabled, then we shouldn't start the queue. + +- Queue error handling and unlocking behavior ([#23522](https://github.com/RocketChat/Rocket.Chat/pull/23522)) + +- Read only description in team creation ([#23213](https://github.com/RocketChat/Rocket.Chat/pull/23213)) + + ![image](https://user-images.githubusercontent.com/27704687/133608433-8ca788a3-71a8-4d40-8c40-8156ab03c606.png) + + ![image](https://user-images.githubusercontent.com/27704687/133608400-4cdc7a67-95e5-46c6-8c65-29ab107cd314.png) + +- resumeToken not working ([#23379](https://github.com/RocketChat/Rocket.Chat/pull/23379)) + +- Rewrite missing webRTC feature ([#23172](https://github.com/RocketChat/Rocket.Chat/pull/23172)) + +- SAML Users' roles being reset to default on login ([#23411](https://github.com/RocketChat/Rocket.Chat/pull/23411)) + + - Remove `roles` field update on `insertOrUpdateSAMLUser` function; + - Add SAML `syncRoles` event; + +- Server crashing when Routing method is not available at start ([#23473](https://github.com/RocketChat/Rocket.Chat/pull/23473)) + +- unwanted toastr error message when deleting user ([#23372](https://github.com/RocketChat/Rocket.Chat/pull/23372)) + +- useEndpointAction replace by useEndpointActionExperimental ([#23469](https://github.com/RocketChat/Rocket.Chat/pull/23469)) + +- user/agent upload not working via Apps Engine after 3.16.0 ([#23393](https://github.com/RocketChat/Rocket.Chat/pull/23393)) + + Fixes #22974 + +- Users' `roles` and `type` being reset to default on LDAP DataSync ([#23378](https://github.com/RocketChat/Rocket.Chat/pull/23378)) + + - Update `roles` and `type` fields only if they are specified in the data imported from LDAP (otherwise, no changes are applied). + +
+🔍 Minor changes + + +- Bump url-parse from 1.4.7 to 1.5.3 ([#23376](https://github.com/RocketChat/Rocket.Chat/pull/23376) by [@dependabot[bot]](https://github.com/dependabot[bot])) + +- Bump: fuselage 0.30.1 ([#23391](https://github.com/RocketChat/Rocket.Chat/pull/23391)) + +- Chore: clean README ([#23342](https://github.com/RocketChat/Rocket.Chat/pull/23342) by [@AbhJ](https://github.com/AbhJ)) + +- Chore: Document REST API endpoints (banners) ([#23361](https://github.com/RocketChat/Rocket.Chat/pull/23361)) + + Describes endpoints for banners on REST API using a JSDoc annotation compatible with OpenAPI spec. + +- Chore: Document REST API endpoints (DNS) ([#23405](https://github.com/RocketChat/Rocket.Chat/pull/23405)) + + Describes endpoints for DNS on REST API using a JSDoc annotation compatible with OpenAPI spec. + +- Chore: Document REST API endpoints (E2E) ([#23430](https://github.com/RocketChat/Rocket.Chat/pull/23430)) + + Describes endpoints for end-to-end encryption on REST API using a JSDoc annotation compatible with OpenAPI spec. + +- Chore: Document REST API endpoints (Misc) ([#23428](https://github.com/RocketChat/Rocket.Chat/pull/23428)) + + Describes miscellaneous endpoints on REST API using a JSDoc annotation compatible with OpenAPI spec. + +- Chore: Ensure all permissions are created up to this point ([#23514](https://github.com/RocketChat/Rocket.Chat/pull/23514)) + +- Chore: Fix some TS warnings ([#23524](https://github.com/RocketChat/Rocket.Chat/pull/23524)) + +- Chore: Fixed a Typo in 11-admin.js test ([#23355](https://github.com/RocketChat/Rocket.Chat/pull/23355) by [@badbart](https://github.com/badbart)) + +- Chore: Improve watch OAuth settings logic ([#23505](https://github.com/RocketChat/Rocket.Chat/pull/23505)) + + Just prevent to perform 200 deletions for registers that not even exist + +- Chore: Make omnichannel settings dependent on omnichannel being enabled ([#23495](https://github.com/RocketChat/Rocket.Chat/pull/23495)) + +- Chore: Migrate some React components/hooks to TypeScript ([#23370](https://github.com/RocketChat/Rocket.Chat/pull/23370)) + + Just low-hanging fruits. + +- Chore: Move `addMinutesToADate` helper ([#23490](https://github.com/RocketChat/Rocket.Chat/pull/23490)) + +- Chore: Move `isEmail` helper ([#23489](https://github.com/RocketChat/Rocket.Chat/pull/23489)) + +- Chore: Move `isJSON` helper ([#23491](https://github.com/RocketChat/Rocket.Chat/pull/23491)) + +- Chore: Move components away from /app/ ([#23360](https://github.com/RocketChat/Rocket.Chat/pull/23360)) + + We currently do NOT recommend placing React components under `/app`. + +- Chore: Partially migrate 2FA client code to TypeScript ([#23419](https://github.com/RocketChat/Rocket.Chat/pull/23419)) + + Additionally, hides `toastr` behind an module to handle UI's toast notifications. + +- Chore: Remove dangling README file ([#23385](https://github.com/RocketChat/Rocket.Chat/pull/23385)) + + Removes the elderly `server/restapi/README.md`. + +- Chore: Replace `promises` helper ([#23488](https://github.com/RocketChat/Rocket.Chat/pull/23488)) + +- Chore: Startup Time ([#23210](https://github.com/RocketChat/Rocket.Chat/pull/23210)) + + The settings logic has been improved as a whole. + + All the logic to get the data from the env var was confusing. + + Setting default values was tricky to understand. + + Every time the server booted, all settings were updated and callbacks were called 2x or more (horrible for environments with multiple instances and generating a turbulent startup). + + `Settings.get(......, callback);` was deprecated. We now have better methods for each case. + +- Chore: Update Apps-Engine version ([#23375](https://github.com/RocketChat/Rocket.Chat/pull/23375)) + +- Chore: Update Livechat Package ([#23523](https://github.com/RocketChat/Rocket.Chat/pull/23523)) + +- Chore: Update pino and pino-pretty ([#23510](https://github.com/RocketChat/Rocket.Chat/pull/23510)) + +- Chore: Upgrade Storybook ([#23364](https://github.com/RocketChat/Rocket.Chat/pull/23364)) + +- i18n: Language update from LingoHub 🤖 on 2021-10-18Z ([#23486](https://github.com/RocketChat/Rocket.Chat/pull/23486)) + +- Merge master into develop & Set version to 4.1.0-develop ([#23362](https://github.com/RocketChat/Rocket.Chat/pull/23362)) + +- Regression: Debounce call based on params on omnichannel queue dispatch ([#23577](https://github.com/RocketChat/Rocket.Chat/pull/23577)) + +- Regression: Fix enterprise setting validation ([#23519](https://github.com/RocketChat/Rocket.Chat/pull/23519)) + +- Regression: Fix user typings style ([#23511](https://github.com/RocketChat/Rocket.Chat/pull/23511)) + +- Regression: Mail body contains `undefined` text ([#23552](https://github.com/RocketChat/Rocket.Chat/pull/23552)) + + ### Before + ![image](https://user-images.githubusercontent.com/2263066/138733018-10449892-5c2d-46fb-9355-00e98e0d6c9f.png) + + ### After + ![image](https://user-images.githubusercontent.com/2263066/138733074-a1b88a77-bf64-41c3-a6c3-ac9e1cb63de1.png) + +- Regression: Prevent settings from getting updated ([#23556](https://github.com/RocketChat/Rocket.Chat/pull/23556)) + +- Regression: Prevent Settings Unit Test Error ([#23506](https://github.com/RocketChat/Rocket.Chat/pull/23506)) + +- Regression: Routing method not available when called from listeners at startup ([#23568](https://github.com/RocketChat/Rocket.Chat/pull/23568)) + +- Regression: Settings order ([#23528](https://github.com/RocketChat/Rocket.Chat/pull/23528)) + +- Regression: Waiting_queue setting not being applied due to missing module key ([#23531](https://github.com/RocketChat/Rocket.Chat/pull/23531)) + +- Regression: watchByRegex without Fibers ([#23529](https://github.com/RocketChat/Rocket.Chat/pull/23529)) + +- Update the community open call link in README ([#23497](https://github.com/RocketChat/Rocket.Chat/pull/23497)) + +
+ +### 👩‍💻👨‍💻 Contributors 😍 + +- [@AbhJ](https://github.com/AbhJ) +- [@Aman-Maheshwari](https://github.com/Aman-Maheshwari) +- [@badbart](https://github.com/badbart) +- [@cuonghuunguyen](https://github.com/cuonghuunguyen) +- [@dependabot[bot]](https://github.com/dependabot[bot]) +- [@wolbernd](https://github.com/wolbernd) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@KevLehman](https://github.com/KevLehman) +- [@MartinSchoeler](https://github.com/MartinSchoeler) +- [@Sing-Li](https://github.com/Sing-Li) +- [@d-gubert](https://github.com/d-gubert) +- [@dougfabris](https://github.com/dougfabris) +- [@geekgonecrazy](https://github.com/geekgonecrazy) +- [@ggazzo](https://github.com/ggazzo) +- [@matheusbsilva137](https://github.com/matheusbsilva137) +- [@murtaza98](https://github.com/murtaza98) +- [@ostjen](https://github.com/ostjen) +- [@pierre-lehnen-rc](https://github.com/pierre-lehnen-rc) +- [@rodrigok](https://github.com/rodrigok) +- [@sampaiodiego](https://github.com/sampaiodiego) +- [@tassoevan](https://github.com/tassoevan) +- [@thassiov](https://github.com/thassiov) +- [@tiagoevanp](https://github.com/tiagoevanp) +- [@yash-rajpal](https://github.com/yash-rajpal) + +# 4.0.5 +`2021-10-25 · 1 🐛 · 1 🔍 · 2 👩‍💻👨‍💻` + +### Engine versions +- Node: `12.22.1` +- NPM: `6.14.1` +- MongoDB: `3.6, 4.0, 4.2, 4.4, 5.0` +- Apps-Engine: `1.28.0` + +### 🐛 Bug fixes + + +- OAuth login not working on mobile app ([#23541](https://github.com/RocketChat/Rocket.Chat/pull/23541)) + +
+🔍 Minor changes + + +- Release 4.0.5 ([#23554](https://github.com/RocketChat/Rocket.Chat/pull/23554)) + +
+ +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@pierre-lehnen-rc](https://github.com/pierre-lehnen-rc) +- [@sampaiodiego](https://github.com/sampaiodiego) + +# 4.0.4 +`2021-10-21 · 2 🐛 · 1 🔍 · 4 👩‍💻👨‍💻` + +### Engine versions +- Node: `12.22.1` +- NPM: `6.14.1` +- MongoDB: `3.6, 4.0, 4.2, 4.4, 5.0` +- Apps-Engine: `1.28.0` + +### 🐛 Bug fixes + + +- Queue error handling and unlocking behavior ([#23522](https://github.com/RocketChat/Rocket.Chat/pull/23522)) + +- SAML Users' roles being reset to default on login ([#23411](https://github.com/RocketChat/Rocket.Chat/pull/23411)) + + - Remove `roles` field update on `insertOrUpdateSAMLUser` function; + - Add SAML `syncRoles` event; + +
+🔍 Minor changes + + +- Release 4.0.4 ([#23532](https://github.com/RocketChat/Rocket.Chat/pull/23532)) + +
+ +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@KevLehman](https://github.com/KevLehman) +- [@matheusbsilva137](https://github.com/matheusbsilva137) +- [@pierre-lehnen-rc](https://github.com/pierre-lehnen-rc) +- [@sampaiodiego](https://github.com/sampaiodiego) + +# 4.0.3 +`2021-10-18 · 2 🐛 · 1 🔍 · 3 👩‍💻👨‍💻` + +### Engine versions +- Node: `12.22.1` +- NPM: `6.14.1` +- MongoDB: `3.6, 4.0, 4.2, 4.4, 5.0` +- Apps-Engine: `1.28.0` + +### 🐛 Bug fixes + + +- **APPS:** Communication problem when updating and uninstalling apps in cluster ([#23418](https://github.com/RocketChat/Rocket.Chat/pull/23418)) + + - Make the hook responsible for receiving app update events inside a cluster fetch the app's package (zip file) in the correct place. + - Also shows a warning message on uninstalls inside a cluster. As there are many servers writing to the same place, some race conditions may occur. This prevents problems related to terminating the process in the middle due to errors being thrown and leaving the server in a faulty state. + +- Server crashing when Routing method is not available at start ([#23473](https://github.com/RocketChat/Rocket.Chat/pull/23473)) + +
+🔍 Minor changes + + +- Release 4.0.3 ([#23496](https://github.com/RocketChat/Rocket.Chat/pull/23496)) + +
+ +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@KevLehman](https://github.com/KevLehman) +- [@sampaiodiego](https://github.com/sampaiodiego) +- [@thassiov](https://github.com/thassiov) + +# 4.0.2 +`2021-10-14 · 4 🐛 · 1 🔍 · 3 👩‍💻👨‍💻` + +### Engine versions +- Node: `12.22.1` +- NPM: `6.14.1` +- MongoDB: `3.6, 4.0, 4.2, 4.4, 5.0` +- Apps-Engine: `1.28.0` + +### 🐛 Bug fixes + + +- **ENTERPRISE:** Omnichannel agent is not leaving the room when a forwarded chat is queued ([#23404](https://github.com/RocketChat/Rocket.Chat/pull/23404)) + +- Attachment buttons overlap in mobile view ([#23377](https://github.com/RocketChat/Rocket.Chat/pull/23377) by [@Aman-Maheshwari](https://github.com/Aman-Maheshwari)) + +- Prevent starting Omni-Queue if Omnichannel is disabled ([#23396](https://github.com/RocketChat/Rocket.Chat/pull/23396)) + + Whenever the Routing system setting changes, and omnichannel is disabled, then we shouldn't start the queue. + +- user/agent upload not working via Apps Engine after 3.16.0 ([#23393](https://github.com/RocketChat/Rocket.Chat/pull/23393)) + + Fixes #22974 + +
+🔍 Minor changes + + +- Release 4.0.2 ([#23460](https://github.com/RocketChat/Rocket.Chat/pull/23460) by [@Aman-Maheshwari](https://github.com/Aman-Maheshwari)) + +
+ +### 👩‍💻👨‍💻 Contributors 😍 + +- [@Aman-Maheshwari](https://github.com/Aman-Maheshwari) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@murtaza98](https://github.com/murtaza98) +- [@sampaiodiego](https://github.com/sampaiodiego) + +# 4.0.1 +`2021-10-06 · 7 🐛 · 2 🔍 · 7 👩‍💻👨‍💻` + +### Engine versions +- Node: `12.22.1` +- NPM: `6.14.1` +- MongoDB: `3.6, 4.0, 4.2, 4.4, 5.0` +- Apps-Engine: `1.28.0` + +### 🐛 Bug fixes + + +- BigBlueButton integration error due to missing file import ([#23366](https://github.com/RocketChat/Rocket.Chat/pull/23366) by [@wolbernd](https://github.com/wolbernd)) + + Fixes BigBlueButton integration + +- imported migration v240 ([#23374](https://github.com/RocketChat/Rocket.Chat/pull/23374)) + +- LDAP not stoping after wrong password ([#23382](https://github.com/RocketChat/Rocket.Chat/pull/23382)) + +- MongoDB deprecation link ([#23381](https://github.com/RocketChat/Rocket.Chat/pull/23381)) + +- resumeToken not working ([#23379](https://github.com/RocketChat/Rocket.Chat/pull/23379)) + +- unwanted toastr error message when deleting user ([#23372](https://github.com/RocketChat/Rocket.Chat/pull/23372)) + +- Users' `roles` and `type` being reset to default on LDAP DataSync ([#23378](https://github.com/RocketChat/Rocket.Chat/pull/23378)) + + - Update `roles` and `type` fields only if they are specified in the data imported from LDAP (otherwise, no changes are applied). + +
+🔍 Minor changes + + +- Chore: Update Apps-Engine version ([#23375](https://github.com/RocketChat/Rocket.Chat/pull/23375)) + +- Release 4.0.1 ([#23386](https://github.com/RocketChat/Rocket.Chat/pull/23386) by [@wolbernd](https://github.com/wolbernd)) + +
+ +### 👩‍💻👨‍💻 Contributors 😍 + +- [@wolbernd](https://github.com/wolbernd) + +### 👩‍💻👨‍💻 Core Team 🤓 + +- [@d-gubert](https://github.com/d-gubert) +- [@matheusbsilva137](https://github.com/matheusbsilva137) +- [@ostjen](https://github.com/ostjen) +- [@rodrigok](https://github.com/rodrigok) +- [@sampaiodiego](https://github.com/sampaiodiego) +- [@tassoevan](https://github.com/tassoevan) + # 4.0.0 `2021-10-01 · 15 ️️️⚠️ · 4 🎉 · 11 🚀 · 24 🐛 · 67 🔍 · 26 👩‍💻👨‍💻` @@ -2007,15 +2808,15 @@ - **ENTERPRISE:** Omnichannel Monitors can't forward chats to departments that they are not supervising ([#22142](https://github.com/RocketChat/Rocket.Chat/pull/22142)) -- Adding Custom Fields to show on user info check ([#20955](https://github.com/RocketChat/Rocket.Chat/pull/20955) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Adding Custom Fields to show on user info check ([#20955](https://github.com/RocketChat/Rocket.Chat/pull/20955)) The setting custom fields to show under user info was not being used when rendering fields in user info. This pr adds those checks and only renders the fields mentioned under in admin -> accounts -> Custom Fields to Show in User Info. -- Adding permission 'add-team-channel' for Team Channels Contextual bar ([#21591](https://github.com/RocketChat/Rocket.Chat/pull/21591) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Adding permission 'add-team-channel' for Team Channels Contextual bar ([#21591](https://github.com/RocketChat/Rocket.Chat/pull/21591)) Added 'add-team-channel' permission to the 2 buttons in team channels contextual bar, for adding channels to teams. -- Adding retentionEnabledDefault check before showing warning message ([#20692](https://github.com/RocketChat/Rocket.Chat/pull/20692) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Adding retentionEnabledDefault check before showing warning message ([#20692](https://github.com/RocketChat/Rocket.Chat/pull/20692)) Added check for retentionEnabledDefault before showing prune warning message. @@ -2277,7 +3078,7 @@ } ``` -- Visibility of burger menu on certain width ([#20736](https://github.com/RocketChat/Rocket.Chat/pull/20736) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Visibility of burger menu on certain width ([#20736](https://github.com/RocketChat/Rocket.Chat/pull/20736)) Burger was not visible on a certain width, specifically between 600 to 780. if width is more than 780px sidebar is shown, if less than 600 then burger icon was shown. But it wasn't shown between 600px to 780 px. It was because for showing burger icon we were only checking for `isMobile` which is lenght only less than 600. So i added one more check for condition if length is less than 780 px. @@ -2460,7 +3261,6 @@ - [@siva2204](https://github.com/siva2204) - [@sumukhah](https://github.com/sumukhah) - [@umakantv](https://github.com/umakantv) -- [@yash-rajpal](https://github.com/yash-rajpal) ### 👩‍💻👨‍💻 Core Team 🤓 @@ -2481,6 +3281,7 @@ - [@tassoevan](https://github.com/tassoevan) - [@thassiov](https://github.com/thassiov) - [@tiagoevanp](https://github.com/tiagoevanp) +- [@yash-rajpal](https://github.com/yash-rajpal) # 3.14.5 `2021-06-06 · 1 🚀 · 1 🐛 · 1 👩‍💻👨‍💻` @@ -2830,7 +3631,7 @@ ![image](https://user-images.githubusercontent.com/17487063/113359447-2d1b5500-931e-11eb-81fa-86f60fcee3a9.png) -- Checking 'start-discussion' Permission for MessageBox Actions ([#21564](https://github.com/RocketChat/Rocket.Chat/pull/21564) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Checking 'start-discussion' Permission for MessageBox Actions ([#21564](https://github.com/RocketChat/Rocket.Chat/pull/21564)) Permissions 'start-discussion-other-user' and 'start-discussion' are checked everywhere before letting anyone start any discussions, this permission check was missing for message box actions, so added it. @@ -3094,7 +3895,6 @@ - [@sauravjoshi23](https://github.com/sauravjoshi23) - [@sumukhah](https://github.com/sumukhah) - [@wolbernd](https://github.com/wolbernd) -- [@yash-rajpal](https://github.com/yash-rajpal) ### 👩‍💻👨‍💻 Core Team 🤓 @@ -3115,6 +3915,7 @@ - [@tassoevan](https://github.com/tassoevan) - [@thassiov](https://github.com/thassiov) - [@tiagoevanp](https://github.com/tiagoevanp) +- [@yash-rajpal](https://github.com/yash-rajpal) # 3.13.5 `2021-05-27 · 1 🐛 · 1 👩‍💻👨‍💻` @@ -3437,7 +4238,7 @@ - Add missing `unreads` field to `users.info` REST endpoint ([#20905](https://github.com/RocketChat/Rocket.Chat/pull/20905)) -- Added hideUnreadStatus check before showing unread messages on roomList ([#20867](https://github.com/RocketChat/Rocket.Chat/pull/20867) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Added hideUnreadStatus check before showing unread messages on roomList ([#20867](https://github.com/RocketChat/Rocket.Chat/pull/20867)) Added hide unread counter check, if the show unread messages is turned off, now unread messages badge won't be shown to user. @@ -3560,7 +4361,7 @@ - Replace wrong field description on Room Information panel ([#21395](https://github.com/RocketChat/Rocket.Chat/pull/21395) by [@rafaelblink](https://github.com/rafaelblink)) -- Reply count of message is decreased after a message from thread is deleted ([#19977](https://github.com/RocketChat/Rocket.Chat/pull/19977) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Reply count of message is decreased after a message from thread is deleted ([#19977](https://github.com/RocketChat/Rocket.Chat/pull/19977)) The reply count now is decreased if a message from a thread is deleted. @@ -3777,7 +4578,7 @@ - Regression: When only 'teams' type is provided, show only rooms with teamMain on `rooms.adminRooms` endpoint ([#21322](https://github.com/RocketChat/Rocket.Chat/pull/21322)) -- Release 3.13.0 ([#21437](https://github.com/RocketChat/Rocket.Chat/pull/21437) by [@PriyaBihani](https://github.com/PriyaBihani) & [@cuonghuunguyen](https://github.com/cuonghuunguyen) & [@fcecagno](https://github.com/fcecagno) & [@lucassartor](https://github.com/lucassartor) & [@shrinish123](https://github.com/shrinish123) & [@yash-rajpal](https://github.com/yash-rajpal)) +- Release 3.13.0 ([#21437](https://github.com/RocketChat/Rocket.Chat/pull/21437) by [@PriyaBihani](https://github.com/PriyaBihani) & [@cuonghuunguyen](https://github.com/cuonghuunguyen) & [@fcecagno](https://github.com/fcecagno) & [@lucassartor](https://github.com/lucassartor) & [@shrinish123](https://github.com/shrinish123)) - Update Apps-Engine version ([#21398](https://github.com/RocketChat/Rocket.Chat/pull/21398)) @@ -3805,7 +4606,6 @@ - [@shrinish123](https://github.com/shrinish123) - [@sumukhah](https://github.com/sumukhah) - [@vova-zush](https://github.com/vova-zush) -- [@yash-rajpal](https://github.com/yash-rajpal) ### 👩‍💻👨‍💻 Core Team 🤓 @@ -3826,6 +4626,7 @@ - [@sampaiodiego](https://github.com/sampaiodiego) - [@tassoevan](https://github.com/tassoevan) - [@tiagoevanp](https://github.com/tiagoevanp) +- [@yash-rajpal](https://github.com/yash-rajpal) # 3.12.7 `2021-05-27 · 1 🐛 · 1 👩‍💻👨‍💻` @@ -3901,7 +4702,7 @@ ### 🚀 Improvements -- Close Call contextual bar after starting jitsi call. ([#21004](https://github.com/RocketChat/Rocket.Chat/pull/21004) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Close Call contextual bar after starting jitsi call. ([#21004](https://github.com/RocketChat/Rocket.Chat/pull/21004)) After jitsi call is started, if the call is started in a new window then we should close contextual tab bar. So, when 'YES' is pressed on modal, we call handleClose function if openNewWindow is true, as call doesn't starts on tab bar, it starts on new window. @@ -3911,19 +4712,16 @@ - Missing spaces on attachment ([#21020](https://github.com/RocketChat/Rocket.Chat/pull/21020)) -- Stopping Jitsi reload ([#20973](https://github.com/RocketChat/Rocket.Chat/pull/20973) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Stopping Jitsi reload ([#20973](https://github.com/RocketChat/Rocket.Chat/pull/20973)) The Function where Jitsi call is started gets called many times due to `room.usernames` dep of useMemo, this dep triggers reloading of this function many times. So removing this dep from useMemo dependencies -### 👩‍💻👨‍💻 Contributors 😍 - -- [@yash-rajpal](https://github.com/yash-rajpal) - ### 👩‍💻👨‍💻 Core Team 🤓 - [@dougfabris](https://github.com/dougfabris) - [@tassoevan](https://github.com/tassoevan) +- [@yash-rajpal](https://github.com/yash-rajpal) # 3.12.0 `2021-02-28 · 5 🎉 · 17 🚀 · 74 🐛 · 30 🔍 · 29 👩‍💻👨‍💻` @@ -3972,15 +4770,15 @@ - Added auto-focus for better user-experience. ([#19954](https://github.com/RocketChat/Rocket.Chat/pull/19954) by [@Darshilp326](https://github.com/Darshilp326)) -- Added disable button check for send invite button ([#20337](https://github.com/RocketChat/Rocket.Chat/pull/20337) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Added disable button check for send invite button ([#20337](https://github.com/RocketChat/Rocket.Chat/pull/20337)) Added Disable check for send invite button. If the text field is empty button would be disabled, and after any valid email is filled, button would get enabled -- Added key prop, removing unwanted warnings ([#20473](https://github.com/RocketChat/Rocket.Chat/pull/20473) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Added key prop, removing unwanted warnings ([#20473](https://github.com/RocketChat/Rocket.Chat/pull/20473)) Removes warnings listed on the issue -- Added Markdown links to custom status. ([#20470](https://github.com/RocketChat/Rocket.Chat/pull/20470) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Added Markdown links to custom status. ([#20470](https://github.com/RocketChat/Rocket.Chat/pull/20470)) Added markdown links to user's custom status. @@ -4006,7 +4804,7 @@ It brings more flexibility, allowing us to use different hooks and different components for each header -- Check Livechat message length through REST API endpoint ([#20366](https://github.com/RocketChat/Rocket.Chat/pull/20366) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Check Livechat message length through REST API endpoint ([#20366](https://github.com/RocketChat/Rocket.Chat/pull/20366)) Added checks for message length for livechat message api, it shouldn't exceed specified character limit. @@ -4055,21 +4853,21 @@ Added tooltips to "Expand" and "Follow Message"/"Unfollow Message" in ThreadView for coherency. -- Added Bio Structure for UserCard, rendering Skeleton View on loading Instead of [Object][Object] ([#20305](https://github.com/RocketChat/Rocket.Chat/pull/20305) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Added Bio Structure for UserCard, rendering Skeleton View on loading Instead of [Object][Object] ([#20305](https://github.com/RocketChat/Rocket.Chat/pull/20305)) Added Bio Structure for rendering Skeleton View on loading UserCard. -- Added check for view admin permission page ([#20403](https://github.com/RocketChat/Rocket.Chat/pull/20403) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Added check for view admin permission page ([#20403](https://github.com/RocketChat/Rocket.Chat/pull/20403)) Admin Permission page was visible to all, if you add admin/permissions after the base url. This should not be visible to all user, only people with certain permissions should be able to see this page. I am also able to see permissions page for open workspace of Rocket chat. ![image](https://user-images.githubusercontent.com/58601732/105829728-bfd00880-5fea-11eb-9121-6c53a752f140.png) -- Adding the accidentally deleted tag template, used by other templates ([#20772](https://github.com/RocketChat/Rocket.Chat/pull/20772) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Adding the accidentally deleted tag template, used by other templates ([#20772](https://github.com/RocketChat/Rocket.Chat/pull/20772)) Adding back accidentally deleted tag Template. -- Admin cannot clear user details like bio or nickname ([#20785](https://github.com/RocketChat/Rocket.Chat/pull/20785) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Admin cannot clear user details like bio or nickname ([#20785](https://github.com/RocketChat/Rocket.Chat/pull/20785)) When the API users.update is called to update user data, it passes data to saveUser function. Here before saving data like bio or nickname we are checking if they are available or not. If data is available then we are saving it, but we are not doing anything when data isn't available. @@ -4077,13 +4875,13 @@ - Admin Panel pages not visible in Safari ([#20912](https://github.com/RocketChat/Rocket.Chat/pull/20912)) -- Announcement with multiple lines fixed. ([#20381](https://github.com/RocketChat/Rocket.Chat/pull/20381) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Announcement with multiple lines fixed. ([#20381](https://github.com/RocketChat/Rocket.Chat/pull/20381)) Announcements with multiple lines used to break UI for announcements bar. Fixed it by replacing all break lines in announcement with empty space (" ") . The announcement modal would work as usual and show all break lines. - Atlassian Crowd login with 2FA enabled ([#20834](https://github.com/RocketChat/Rocket.Chat/pull/20834)) -- Attachment download from title fixed ([#20585](https://github.com/RocketChat/Rocket.Chat/pull/20585) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Attachment download from title fixed ([#20585](https://github.com/RocketChat/Rocket.Chat/pull/20585)) Added target = '_self' to attachment link, this seems to fix the problem, without this attribute, error page is displayed. @@ -4174,7 +4972,7 @@ ![image](https://user-images.githubusercontent.com/2493803/106494751-90f9dc80-6499-11eb-901b-5e4dbdc678ba.png) -- Fix Empty highlighted words field ([#20329](https://github.com/RocketChat/Rocket.Chat/pull/20329) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Fix Empty highlighted words field ([#20329](https://github.com/RocketChat/Rocket.Chat/pull/20329)) Able to Empty the highlighted text field in preferences @@ -4230,7 +5028,7 @@ - Add a new setting ("Add Reply-To header") in the Email settings' page to control when the Reply-To header is used in e-mail notifications; - The new setting is turned off (`false` value) by default. -- New Integration page was not being displayed ([#20670](https://github.com/RocketChat/Rocket.Chat/pull/20670) by [@yash-rajpal](https://github.com/yash-rajpal)) +- New Integration page was not being displayed ([#20670](https://github.com/RocketChat/Rocket.Chat/pull/20670)) - Notification worker stopping on error ([#20605](https://github.com/RocketChat/Rocket.Chat/pull/20605)) @@ -4444,7 +5242,6 @@ - [@paulobernardoaf](https://github.com/paulobernardoaf) - [@pierreozoux](https://github.com/pierreozoux) - [@rafaelblink](https://github.com/rafaelblink) -- [@yash-rajpal](https://github.com/yash-rajpal) ### 👩‍💻👨‍💻 Core Team 🤓 @@ -4463,6 +5260,7 @@ - [@sampaiodiego](https://github.com/sampaiodiego) - [@tassoevan](https://github.com/tassoevan) - [@tiagoevanp](https://github.com/tiagoevanp) +- [@yash-rajpal](https://github.com/yash-rajpal) # 3.11.5 `2021-04-20 · 1 🐛 · 1 👩‍💻👨‍💻` @@ -4525,7 +5323,7 @@ ### 🐛 Bug fixes -- Attachment download from title fixed ([#20585](https://github.com/RocketChat/Rocket.Chat/pull/20585) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Attachment download from title fixed ([#20585](https://github.com/RocketChat/Rocket.Chat/pull/20585)) Added target = '_self' to attachment link, this seems to fix the problem, without this attribute, error page is displayed. @@ -4544,7 +5342,6 @@ ### 👩‍💻👨‍💻 Contributors 😍 - [@lolimay](https://github.com/lolimay) -- [@yash-rajpal](https://github.com/yash-rajpal) ### 👩‍💻👨‍💻 Core Team 🤓 @@ -4552,6 +5349,7 @@ - [@renatobecker](https://github.com/renatobecker) - [@sampaiodiego](https://github.com/sampaiodiego) - [@tiagoevanp](https://github.com/tiagoevanp) +- [@yash-rajpal](https://github.com/yash-rajpal) # 3.11.0 `2021-01-31 · 8 🎉 · 9 🚀 · 52 🐛 · 44 🔍 · 32 👩‍💻👨‍💻` @@ -4657,7 +5455,7 @@ Made user avatar change buttons to be descriptive of what they do. -- Tooltip added for Kebab menu on chat header ([#20116](https://github.com/RocketChat/Rocket.Chat/pull/20116) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Tooltip added for Kebab menu on chat header ([#20116](https://github.com/RocketChat/Rocket.Chat/pull/20116)) Added the missing Tooltip for kebab menu on chat header. ![tooltip after](https://user-images.githubusercontent.com/58601732/104031406-b07f4b80-51f2-11eb-87a4-1e8da78a254f.gif) @@ -4679,12 +5477,12 @@ Users can be removed from channels without any error message. -- Added context check for closing active tabbar for member-list ([#20228](https://github.com/RocketChat/Rocket.Chat/pull/20228) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Added context check for closing active tabbar for member-list ([#20228](https://github.com/RocketChat/Rocket.Chat/pull/20228)) When we click on a username and then click on see user's full profile, a tab gets active and shows us the user's profile, the problem occurs when the tab is still active and we try to see another user's profile. In this case, tabbar gets closed. To resolve this, added context check for closing action of active tabbar. -- Added Margin between status bullet and status label ([#20199](https://github.com/RocketChat/Rocket.Chat/pull/20199) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Added Margin between status bullet and status label ([#20199](https://github.com/RocketChat/Rocket.Chat/pull/20199)) Added Margins between status bullet and status label @@ -4749,7 +5547,7 @@ After changes made on https://github.com/RocketChat/Rocket.Chat/pull/19931, the `Livechat.RegisterGuest` method started removing properties from the visitor inappropriately. The properties that did not receive value were removed from the object. Those changes were made to support the new Contact Form, but now the form has its own method to deal with Contact data so those changes are no longer necessary. -- Markdown added for Header Room topic ([#20021](https://github.com/RocketChat/Rocket.Chat/pull/20021) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Markdown added for Header Room topic ([#20021](https://github.com/RocketChat/Rocket.Chat/pull/20021)) With the new 3.10.0 version update the Links in topic section below room name were not working, for more info refer issue #20018 @@ -4829,7 +5627,7 @@ ![image](https://user-images.githubusercontent.com/27704687/106056093-0a29b600-60cd-11eb-8038-eabbc0d8fb03.png) -- Status circle in profile section ([#20016](https://github.com/RocketChat/Rocket.Chat/pull/20016) by [@yash-rajpal](https://github.com/yash-rajpal)) +- Status circle in profile section ([#20016](https://github.com/RocketChat/Rocket.Chat/pull/20016)) The Status Circle in status message text input is now centered vertically. @@ -5033,7 +5831,6 @@ - [@sushant52](https://github.com/sushant52) - [@tlskinneriv](https://github.com/tlskinneriv) - [@wggdeveloper](https://github.com/wggdeveloper) -- [@yash-rajpal](https://github.com/yash-rajpal) - [@zdumitru](https://github.com/zdumitru) ### 👩‍💻👨‍💻 Core Team 🤓 @@ -5051,6 +5848,7 @@ - [@tassoevan](https://github.com/tassoevan) - [@thassiov](https://github.com/thassiov) - [@tiagoevanp](https://github.com/tiagoevanp) +- [@yash-rajpal](https://github.com/yash-rajpal) # 3.10.5 `2021-01-27 · 1 🐛 · 1 👩‍💻👨‍💻` @@ -8733,7 +9531,7 @@ - Slash command preview: Wrong item being selected, Horizontal scroll ([#16750](https://github.com/RocketChat/Rocket.Chat/pull/16750)) -- Text formatted to remain within button even on screen resize ([#14136](https://github.com/RocketChat/Rocket.Chat/pull/14136) by [@Rodriq](https://github.com/Rodriq)) +- Text formatted to remain within button even on screen resize ([#14136](https://github.com/RocketChat/Rocket.Chat/pull/14136)) - There is no option to pin a thread message by admin ([#16457](https://github.com/RocketChat/Rocket.Chat/pull/16457) by [@ashwaniYDV](https://github.com/ashwaniYDV)) @@ -8939,7 +9737,6 @@ - [@GOVINDDIXIT](https://github.com/GOVINDDIXIT) - [@MarcosSpessatto](https://github.com/MarcosSpessatto) - [@Nikhil713](https://github.com/Nikhil713) -- [@Rodriq](https://github.com/Rodriq) - [@aKn1ghtOut](https://github.com/aKn1ghtOut) - [@antkaz](https://github.com/antkaz) - [@aryamanpuri](https://github.com/aryamanpuri) @@ -8967,6 +9764,7 @@ ### 👩‍💻👨‍💻 Core Team 🤓 - [@PrajvalRaval](https://github.com/PrajvalRaval) +- [@Rodriq](https://github.com/Rodriq) - [@Sing-Li](https://github.com/Sing-Li) - [@d-gubert](https://github.com/d-gubert) - [@engelgabriel](https://github.com/engelgabriel) @@ -14727,7 +15525,7 @@ 🔍 Minor changes -- Add reetp to the issues' bot whitelist ([#12227](https://github.com/RocketChat/Rocket.Chat/pull/12227)) +- Add reetp to the issues' bot whitelist ([#12227](https://github.com/RocketChat/Rocket.Chat/pull/12227) by [@theorenck](https://github.com/theorenck)) - Fix: Remove semver satisfies from Apps details that is already done my marketplace ([#12268](https://github.com/RocketChat/Rocket.Chat/pull/12268)) @@ -14735,7 +15533,7 @@ - Regression: fix modal submit ([#12233](https://github.com/RocketChat/Rocket.Chat/pull/12233)) -- Release 0.70.1 ([#12270](https://github.com/RocketChat/Rocket.Chat/pull/12270) by [@Hudell](https://github.com/Hudell) & [@edzluhan](https://github.com/edzluhan)) +- Release 0.70.1 ([#12270](https://github.com/RocketChat/Rocket.Chat/pull/12270) by [@Hudell](https://github.com/Hudell) & [@edzluhan](https://github.com/edzluhan) & [@theorenck](https://github.com/theorenck)) @@ -14745,6 +15543,7 @@ - [@cardoso](https://github.com/cardoso) - [@edzluhan](https://github.com/edzluhan) - [@kaiiiiiiiii](https://github.com/kaiiiiiiiii) +- [@theorenck](https://github.com/theorenck) - [@timkinnane](https://github.com/timkinnane) ### 👩‍💻👨‍💻 Core Team 🤓 @@ -14754,7 +15553,6 @@ - [@rodrigok](https://github.com/rodrigok) - [@sampaiodiego](https://github.com/sampaiodiego) - [@tassoevan](https://github.com/tassoevan) -- [@theorenck](https://github.com/theorenck) # 0.70.0 `2018-09-28 · 2 ️️️⚠️ · 18 🎉 · 3 🚀 · 35 🐛 · 19 🔍 · 32 👩‍💻👨‍💻` diff --git a/app/api/server/api.d.ts b/app/api/server/api.d.ts index 9e968448bbc0c..b66da40e43947 100644 --- a/app/api/server/api.d.ts +++ b/app/api/server/api.d.ts @@ -1,6 +1,179 @@ -import { APIClass } from '.'; +import type { JoinPathPattern, Method, MethodOf, OperationParams, OperationResult, PathPattern, UrlParams } from '../../../definition/rest'; +import type { IUser } from '../../../definition/IUser'; +import { IMethodConnection } from '../../../definition/IMethodThisType'; +import { ITwoFactorOptions } from '../../2fa/server/code'; + +type SuccessResult = { + statusCode: 200; + body: + T extends object + ? { success: true } & T + : T; +}; + +type FailureResult = { + statusCode: 400; + body: + T extends object + ? { success: false } & T + : ({ + success: false; + error: T; + stack: TStack; + errorType: TErrorType; + details: TErrorDetails; + }) & ( + undefined extends TErrorType + ? {} + : { errorType: TErrorType } + ) & ( + undefined extends TErrorDetails + ? {} + : { details: TErrorDetails extends string ? unknown : TErrorDetails } + ); +}; + +type UnauthorizedResult = { + statusCode: 403; + body: { + success: false; + error: T | 'unauthorized'; + }; +} + +export type NonEnterpriseTwoFactorOptions = { + authRequired: true; + forceTwoFactorAuthenticationForNonEnterprise: true; + twoFactorRequired: true; + permissionsRequired?: string[]; + twoFactorOptions: ITwoFactorOptions; +} + +type Options = { + permissionsRequired?: string[]; + authRequired?: boolean; + forceTwoFactorAuthenticationForNonEnterprise?: boolean; +} | { + authRequired: true; + twoFactorRequired: true; + twoFactorOptions?: ITwoFactorOptions; +} + +type Request = { + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + url: string; + headers: Record; + body: any; +} + +type ActionThis = { + urlParams: UrlParams; + // TODO make it unsafe + readonly queryParams: TMethod extends 'GET' ? Partial> : Record; + // TODO make it unsafe + readonly bodyParams: TMethod extends 'GET' ? Record : Partial>; + requestParams(): OperationParams; + getPaginationItems(): { + readonly offset: number; + readonly count: number; + }; + parseJsonQuery(): { + sort: Record; + fields: Record; + query: Record; + }; + getUserFromParams(): IUser; +} & ( + TOptions extends { authRequired: true } + ? { + readonly user: IUser; + readonly userId: string; + } + : { + readonly user: null; + readonly userId: null; + } +); + +export type ResultFor< + TMethod extends Method, + TPathPattern extends PathPattern +> = SuccessResult> | FailureResult | UnauthorizedResult; + +type Action = + ((this: ActionThis) => Promise>) + | ((this: ActionThis) => ResultFor); + +type Operation = Action | { + action: Action; +} & ({ twoFactorRequired: boolean }); + +type Operations = { + [M in MethodOf as Lowercase]: Operation, TPathPattern, TOptions>; +}; + +declare class APIClass { + processTwoFactor({ userId, request, invocation, options, connection }: { userId: string; request: Request; invocation: {twoFactorChecked: boolean}; options?: Options; connection: IMethodConnection }): void; + + addRoute< + TSubPathPattern extends string + >(subpath: TSubPathPattern, operations: Operations>): void; + + addRoute< + TSubPathPattern extends string, + TPathPattern extends JoinPathPattern + >(subpaths: TSubPathPattern[], operations: Operations): void; + + addRoute< + TSubPathPattern extends string, + TOptions extends Options + >( + subpath: TSubPathPattern, + options: TOptions, + operations: Operations, TOptions> + ): void; + + addRoute< + TSubPathPattern extends string, + TPathPattern extends JoinPathPattern, + TOptions extends Options + >( + subpaths: TSubPathPattern[], + options: TOptions, + operations: Operations + ): void; + + success(result: T): SuccessResult; + + success(): SuccessResult; + + failure< + T, + TErrorType extends string, + TStack extends string, + TErrorDetails + >( + result: T, + errorType?: TErrorType, + stack?: TStack, + error?: { details: TErrorDetails } + ): FailureResult; + + failure(result: T): FailureResult; + + failure(): FailureResult; + + unauthorized(msg?: T): UnauthorizedResult; + + defaultFieldsToExclude: { + joinCode: 0; + members: 0; + importIds: 0; + e2e: 0; + } +} export declare const API: { - v1: APIClass; + v1: APIClass<'/v1'>; default: APIClass; }; diff --git a/app/api/server/api.js b/app/api/server/api.js index 3b1d6bdfbb9ed..43241eda28219 100644 --- a/app/api/server/api.js +++ b/app/api/server/api.js @@ -4,8 +4,8 @@ import { DDPCommon } from 'meteor/ddp-common'; import { DDP } from 'meteor/ddp'; import { Accounts } from 'meteor/accounts-base'; import { Restivus } from 'meteor/nimble:restivus'; -import { RateLimiter } from 'meteor/rate-limit'; import _ from 'underscore'; +import { RateLimiter } from 'meteor/rate-limit'; import { Logger } from '../../../server/lib/logger/Logger'; import { getRestPayload } from '../../../server/lib/logger/logPayloads'; @@ -273,10 +273,13 @@ export class APIClass extends Restivus { } processTwoFactor({ userId, request, invocation, options, connection }) { + if (!options.twoFactorRequired) { + return; + } const code = request.headers['x-2fa-code']; const method = request.headers['x-2fa-method']; - checkCodeForUser({ user: userId, code, method, options, connection }); + checkCodeForUser({ user: userId, code, method, options: options.twoFactorOptions, connection }); invocation.twoFactorChecked = true; } @@ -399,11 +402,9 @@ export class APIClass extends Restivus { }; Accounts._setAccountData(connection.id, 'loginToken', this.token); - if (_options.twoFactorRequired) { - api.processTwoFactor({ userId: this.userId, request: this.request, invocation, options: _options.twoFactorOptions, connection }); - } + api.processTwoFactor({ userId: this.userId, request: this.request, invocation, options: _options, connection }); - result = DDP._CurrentInvocation.withValue(invocation, () => originalAction.apply(this)) || API.v1.success(); + result = DDP._CurrentInvocation.withValue(invocation, () => Promise.await(originalAction.apply(this))) || API.v1.success(); log.http({ status: result.statusCode, @@ -447,6 +448,14 @@ export class APIClass extends Restivus { }); } + updateRateLimiterDictionaryForRoute(route, numRequestsAllowed, intervalTimeInMS) { + if (rateLimiterDictionary[route]) { + rateLimiterDictionary[route].options.numRequestsAllowed = numRequestsAllowed ?? rateLimiterDictionary[route].options.numRequestsAllowed; + rateLimiterDictionary[route].options.intervalTimeInMS = intervalTimeInMS ?? rateLimiterDictionary[route].options.intervalTimeInMS; + API.v1.reloadRoutesToRefreshRateLimiter(); + } + } + _initAuth() { const loginCompatibility = (bodyParams, request) => { // Grab the username or email that the user is logging in with @@ -771,6 +780,7 @@ settings.watch('API_Enable_Rate_Limiter_Limit_Calls_Default', (value) => { API.v1.reloadRoutesToRefreshRateLimiter(); }); + settings.watch('Prometheus_API_User_Agent', (value) => { prometheusAPIUserAgent = value; }); diff --git a/app/api/server/helpers/deprecationWarning.ts b/app/api/server/helpers/deprecationWarning.ts index edb347cd33b30..bfee0827733d4 100644 --- a/app/api/server/helpers/deprecationWarning.ts +++ b/app/api/server/helpers/deprecationWarning.ts @@ -1,7 +1,7 @@ import { API } from '../api'; import { apiDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; -(API as any).helperMethods.set('deprecationWarning', function _deprecationWarning({ endpoint, versionWillBeRemoved, response }: { endpoint: string; versionWillBeRemoved: string; response: any }) { +export function deprecationWarning({ endpoint, versionWillBeRemoved = '5.0', response }: { endpoint: string; versionWillBeRemoved?: string; response: T }): T { const warningMessage = `The endpoint "${ endpoint }" is deprecated and will be removed after version ${ versionWillBeRemoved }`; apiDeprecationLogger.warn(warningMessage); if (process.env.NODE_ENV === 'development') { @@ -12,4 +12,6 @@ import { apiDeprecationLogger } from '../../../lib/server/lib/deprecationWarning } return response; -}); +} + +(API as any).helperMethods.set('deprecationWarning', deprecationWarning); diff --git a/app/api/server/helpers/getPaginationItems.js b/app/api/server/helpers/getPaginationItems.js index 0cff491a97636..259f79a1191a3 100644 --- a/app/api/server/helpers/getPaginationItems.js +++ b/app/api/server/helpers/getPaginationItems.js @@ -10,7 +10,7 @@ API.helperMethods.set('getPaginationItems', function _getPaginationItems() { const offset = this.queryParams.offset ? parseInt(this.queryParams.offset) : 0; let count = defaultCount; - // Ensure count is an appropiate amount + // Ensure count is an appropriate amount if (typeof this.queryParams.count !== 'undefined') { count = parseInt(this.queryParams.count); } else { diff --git a/app/api/server/lib/integrations.js b/app/api/server/lib/integrations.ts similarity index 65% rename from app/api/server/lib/integrations.js rename to app/api/server/lib/integrations.ts index 55db33a636a5e..ef5cab57ed942 100644 --- a/app/api/server/lib/integrations.js +++ b/app/api/server/lib/integrations.ts @@ -1,7 +1,9 @@ import { Integrations } from '../../../models/server/raw'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { IIntegration } from '../../../../definition/IIntegration'; +import { IUser } from '../../../../definition/IUser'; -const hasIntegrationsPermission = async (userId, integration) => { +const hasIntegrationsPermission = async (userId: string, integration: IIntegration): Promise => { const type = integration.type === 'webhook-incoming' ? 'incoming' : 'outgoing'; if (await hasPermissionAsync(userId, `manage-${ type }-integrations`)) { @@ -15,7 +17,15 @@ const hasIntegrationsPermission = async (userId, integration) => { return false; }; -export const findOneIntegration = async ({ userId, integrationId, createdBy }) => { +export const findOneIntegration = async ({ + userId, + integrationId, + createdBy, +}: { + userId: string; + integrationId: string; + createdBy: IUser; +}): Promise => { const integration = await Integrations.findOneByIdAndCreatedByIfExists({ _id: integrationId, createdBy }); if (!integration) { throw new Error('The integration does not exists.'); diff --git a/app/api/server/lib/rooms.js b/app/api/server/lib/rooms.js index ea184b1d2fa20..a841973e02dbc 100644 --- a/app/api/server/lib/rooms.js +++ b/app/api/server/lib/rooms.js @@ -1,4 +1,4 @@ -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { hasPermissionAsync, hasAtLeastOnePermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { Rooms } from '../../../models/server/raw'; import { Subscriptions } from '../../../models/server'; @@ -119,6 +119,31 @@ export async function findChannelAndPrivateAutocomplete({ uid, selector }) { }; } +export async function findAdminRoomsAutocomplete({ uid, selector }) { + if (!await hasAtLeastOnePermissionAsync(uid, ['view-room-administration', 'can-audit'])) { + throw new Error('error-not-authorized'); + } + const options = { + fields: { + _id: 1, + fname: 1, + name: 1, + t: 1, + avatarETag: 1, + }, + limit: 10, + sort: { + name: 1, + }, + }; + + const rooms = await Rooms.findRoomsByNameOrFnameStarting(selector.name, options).toArray(); + + return { + items: rooms, + }; +} + export async function findChannelAndPrivateAutocompleteWithPagination({ uid, selector, pagination: { offset, count, sort } }) { const userRoomsIds = Subscriptions.cachedFindByUserId(uid, { fields: { rid: 1 } }) .fetch() diff --git a/app/api/server/lib/webdav.js b/app/api/server/lib/webdav.js deleted file mode 100644 index cf5a3c8ea8f1d..0000000000000 --- a/app/api/server/lib/webdav.js +++ /dev/null @@ -1,14 +0,0 @@ -import { WebdavAccounts } from '../../../models/server/raw'; - -export async function findWebdavAccountsByUserId({ uid }) { - return { - accounts: await WebdavAccounts.findWithUserId(uid, { - fields: { - _id: 1, - username: 1, - server_url: 1, - name: 1, - }, - }).toArray(), - }; -} diff --git a/app/api/server/lib/webdav.ts b/app/api/server/lib/webdav.ts new file mode 100644 index 0000000000000..fe2f17185bb6d --- /dev/null +++ b/app/api/server/lib/webdav.ts @@ -0,0 +1,16 @@ +import { WebdavAccounts } from '../../../models/server/raw'; +import { IWebdavAccount } from '../../../../definition/IWebdavAccount'; + +export async function findWebdavAccountsByUserId({ uid }: { uid: string }): Promise<{ accounts: IWebdavAccount[] }> { + return { + accounts: await WebdavAccounts.findWithUserId(uid, { + projection: { + _id: 1, + username: 1, + // eslint-disable-next-line @typescript-eslint/camelcase + server_url: 1, + name: 1, + }, + }).toArray(), + }; +} diff --git a/app/api/server/v1/banners.ts b/app/api/server/v1/banners.ts index e56b6a5178cb9..4e740cdf2c901 100644 --- a/app/api/server/v1/banners.ts +++ b/app/api/server/v1/banners.ts @@ -1,5 +1,3 @@ -import { Promise } from 'meteor/promise'; -import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; import { API } from '../api'; @@ -53,22 +51,15 @@ import { BannerPlatform } from '../../../../definition/IBanner'; * $ref: '#/components/schemas/ApiFailureV1' */ API.v1.addRoute('banners.getNew', { authRequired: true }, { // deprecated - get() { + async get() { check(this.queryParams, Match.ObjectIncluding({ - platform: String, + platform: Match.OneOf(...Object.values(BannerPlatform)), bid: Match.Maybe(String), })); const { platform, bid: bannerId } = this.queryParams; - if (!platform) { - throw new Meteor.Error('error-missing-param', 'The required "platform" param is missing.'); - } - if (!Object.values(BannerPlatform).includes(platform)) { - throw new Meteor.Error('error-unknown-platform', 'Platform is unknown.'); - } - - const banners = Promise.await(Banner.getBannersForUser(this.userId, platform, bannerId)); + const banners = await Banner.getBannersForUser(this.userId, platform, bannerId ?? undefined); return API.v1.success({ banners }); }, @@ -120,23 +111,19 @@ API.v1.addRoute('banners.getNew', { authRequired: true }, { // deprecated * schema: * $ref: '#/components/schemas/ApiFailureV1' */ -API.v1.addRoute('banners/:id', { authRequired: true }, { - get() { +API.v1.addRoute('banners/:id', { authRequired: true }, { // TODO: move to users/:id/banners + async get() { check(this.urlParams, Match.ObjectIncluding({ - id: String, + id: Match.Where((id: unknown): id is string => typeof id === 'string' && Boolean(id.trim())), + })); + check(this.queryParams, Match.ObjectIncluding({ + platform: Match.OneOf(...Object.values(BannerPlatform)), })); const { platform } = this.queryParams; - if (!platform) { - throw new Meteor.Error('error-missing-param', 'The required "platform" param is missing.'); - } - const { id } = this.urlParams; - if (!id) { - throw new Meteor.Error('error-missing-param', 'The required "id" param is missing.'); - } - const banners = Promise.await(Banner.getBannersForUser(this.userId, platform, id)); + const banners = await Banner.getBannersForUser(this.userId, platform, id); return API.v1.success({ banners }); }, @@ -180,21 +167,14 @@ API.v1.addRoute('banners/:id', { authRequired: true }, { * $ref: '#/components/schemas/ApiFailureV1' */ API.v1.addRoute('banners', { authRequired: true }, { - get() { + async get() { check(this.queryParams, Match.ObjectIncluding({ - platform: String, + platform: Match.OneOf(...Object.values(BannerPlatform)), })); const { platform } = this.queryParams; - if (!platform) { - throw new Meteor.Error('error-missing-param', 'The required "platform" param is missing.'); - } - - if (!Object.values(BannerPlatform).includes(platform)) { - throw new Meteor.Error('error-unknown-platform', 'Platform is unknown.'); - } - const banners = Promise.await(Banner.getBannersForUser(this.userId, platform)); + const banners = await Banner.getBannersForUser(this.userId, platform); return API.v1.success({ banners }); }, @@ -234,18 +214,14 @@ API.v1.addRoute('banners', { authRequired: true }, { * $ref: '#/components/schemas/ApiFailureV1' */ API.v1.addRoute('banners.dismiss', { authRequired: true }, { - post() { + async post() { check(this.bodyParams, Match.ObjectIncluding({ - bannerId: String, + bannerId: Match.Where((id: unknown): id is string => typeof id === 'string' && Boolean(id.trim())), })); const { bannerId } = this.bodyParams; - if (!bannerId || !bannerId.trim()) { - throw new Meteor.Error('error-missing-param', 'The required "bannerId" param is missing.'); - } - - Promise.await(Banner.dismiss(this.userId, bannerId)); + await Banner.dismiss(this.userId, bannerId); return API.v1.success(); }, }); diff --git a/app/api/server/v1/channels.js b/app/api/server/v1/channels.js index 8d360a5de5056..3e0139ec80b05 100644 --- a/app/api/server/v1/channels.js +++ b/app/api/server/v1/channels.js @@ -2,7 +2,8 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; import _ from 'underscore'; -import { Rooms, Subscriptions, Messages, Uploads, Integrations, Users } from '../../../models/server'; +import { Rooms, Subscriptions, Messages, Users } from '../../../models/server'; +import { Integrations, Uploads } from '../../../models/server/raw'; import { canAccessRoom, hasPermission, hasAtLeastOnePermission, hasAllPermission } from '../../../authorization/server'; import { mountIntegrationQueryBasedOnPermissions } from '../../../integrations/server/lib/mountQueriesBasedOnPermission'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; @@ -285,28 +286,28 @@ API.v1.addRoute('channels.files', { authRequired: true }, { return file; }; - Meteor.runAsUser(this.userId, () => { - Meteor.call('canAccessRoom', findResult._id, this.userId); - }); + if (!canAccessRoom(findResult, { _id: this.userId })) { + return API.v1.unauthorized(); + } const { offset, count } = this.getPaginationItems(); const { sort, fields, query } = this.parseJsonQuery(); const ourQuery = Object.assign({}, query, { rid: findResult._id }); - const files = Uploads.find(ourQuery, { + const files = Promise.await(Uploads.find(ourQuery, { sort: sort || { name: 1 }, skip: offset, limit: count, fields, - }).fetch(); + }).toArray()); return API.v1.success({ files: files.map(addUserObjectToEveryObject), count: files.length, offset, - total: Uploads.find(ourQuery).count(), + total: Promise.await(Uploads.find(ourQuery).count()), }); }, }); @@ -340,21 +341,24 @@ API.v1.addRoute('channels.getIntegrations', { authRequired: true }, { } const { offset, count } = this.getPaginationItems(); - const { sort, fields, query } = this.parseJsonQuery(); + const { sort, fields: projection, query } = this.parseJsonQuery(); ourQuery = Object.assign(mountIntegrationQueryBasedOnPermissions(this.userId), query, ourQuery); - const integrations = Integrations.find(ourQuery, { + const cursor = Integrations.find(ourQuery, { sort: sort || { _createdAt: 1 }, skip: offset, limit: count, - fields, - }).fetch(); + projection, + }); + + const integrations = Promise.await(cursor.toArray()); + const total = Promise.await(cursor.count()); return API.v1.success({ integrations, count: integrations.length, offset, - total: Integrations.find(ourQuery).count(), + total, }); }, }); diff --git a/app/api/server/v1/chat.js b/app/api/server/v1/chat.js index db41290a16f31..fa3e917665fc1 100644 --- a/app/api/server/v1/chat.js +++ b/app/api/server/v1/chat.js @@ -3,7 +3,7 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; import { Messages } from '../../../models'; -import { canAccessRoom, hasPermission } from '../../../authorization'; +import { canAccessRoom, hasPermission } from '../../../authorization/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; import { processWebhookMessage } from '../../../lib/server'; import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; @@ -404,12 +404,12 @@ API.v1.addRoute('chat.getPinnedMessages', { authRequired: true }, { if (!roomId) { throw new Meteor.Error('error-roomId-param-not-provided', 'The required "roomId" query param is missing.'); } - const room = Meteor.call('canAccessRoom', roomId, this.userId); - if (!room) { + + if (!canAccessRoom({ _id: roomId }, { _id: this.userId })) { throw new Meteor.Error('error-not-allowed', 'Not allowed'); } - const cursor = Messages.findPinnedByRoom(room._id, { + const cursor = Messages.findPinnedByRoom(roomId, { skip: offset, limit: count, }); @@ -697,7 +697,7 @@ API.v1.addRoute('chat.getSnippetedMessages', { authRequired: true }, { }); API.v1.addRoute('chat.getDiscussions', { authRequired: true }, { - get() { + async get() { const { roomId, text } = this.queryParams; const { sort } = this.parseJsonQuery(); const { offset, count } = this.getPaginationItems(); @@ -705,7 +705,7 @@ API.v1.addRoute('chat.getDiscussions', { authRequired: true }, { if (!roomId) { throw new Meteor.Error('error-invalid-params', 'The required "roomId" query param is missing.'); } - const messages = Promise.await(findDiscussionsFromRoom({ + const messages = await findDiscussionsFromRoom({ uid: this.userId, roomId, text, @@ -714,7 +714,7 @@ API.v1.addRoute('chat.getDiscussions', { authRequired: true }, { count, sort, }, - })); + }); return API.v1.success(messages); }, }); diff --git a/app/api/server/v1/commands.js b/app/api/server/v1/commands.js index 51059b11221e3..a9f6c290d8dd4 100644 --- a/app/api/server/v1/commands.js +++ b/app/api/server/v1/commands.js @@ -2,8 +2,9 @@ import { Meteor } from 'meteor/meteor'; import { Random } from 'meteor/random'; import objectPath from 'object-path'; -import { slashCommands } from '../../../utils'; -import { Messages } from '../../../models'; +import { slashCommands } from '../../../utils/server'; +import { Messages } from '../../../models/server'; +import { canAccessRoom } from '../../../authorization/server'; import { API } from '../api'; API.v1.addRoute('commands.get', { authRequired: true }, { @@ -189,8 +190,9 @@ API.v1.addRoute('commands.run', { authRequired: true }, { return API.v1.failure('The command provided does not exist (or is disabled).'); } - // This will throw an error if they can't or the room is invalid - Meteor.call('canAccessRoom', body.roomId, user._id); + if (!canAccessRoom({ _id: body.roomId }, user)) { + return API.v1.unauthorized(); + } const params = body.params ? body.params : ''; const message = { @@ -238,8 +240,9 @@ API.v1.addRoute('commands.preview', { authRequired: true }, { return API.v1.failure('The command provided does not exist (or is disabled).'); } - // This will throw an error if they can't or the room is invalid - Meteor.call('canAccessRoom', query.roomId, user._id); + if (!canAccessRoom({ _id: query.roomId }, user)) { + return API.v1.unauthorized(); + } const params = query.params ? query.params : ''; @@ -288,8 +291,9 @@ API.v1.addRoute('commands.preview', { authRequired: true }, { return API.v1.failure('The command provided does not exist (or is disabled).'); } - // This will throw an error if they can't or the room is invalid - Meteor.call('canAccessRoom', body.roomId, user._id); + if (!canAccessRoom({ _id: body.roomId }, user)) { + return API.v1.unauthorized(); + } const params = body.params ? body.params : ''; const message = { diff --git a/app/api/server/v1/dns.ts b/app/api/server/v1/dns.ts index 902ef90d47821..a0b0fa5788e6f 100644 --- a/app/api/server/v1/dns.ts +++ b/app/api/server/v1/dns.ts @@ -48,7 +48,7 @@ import { resolveSRV, resolveTXT } from '../../../federation/server/functions/res * $ref: '#/components/schemas/ApiFailureV1' */ API.v1.addRoute('dns.resolve.srv', { authRequired: true }, { - get() { + async get() { check(this.queryParams, Match.ObjectIncluding({ url: String, })); @@ -58,7 +58,7 @@ API.v1.addRoute('dns.resolve.srv', { authRequired: true }, { throw new Meteor.Error('error-missing-param', 'The required "url" param is missing.'); } - const resolved = Promise.await(resolveSRV(url)); + const resolved = await resolveSRV(url); return API.v1.success({ resolved }); }, @@ -99,7 +99,7 @@ API.v1.addRoute('dns.resolve.srv', { authRequired: true }, { * $ref: '#/components/schemas/ApiFailureV1' */ API.v1.addRoute('dns.resolve.txt', { authRequired: true }, { - post() { + async post() { check(this.queryParams, Match.ObjectIncluding({ url: String, })); @@ -109,7 +109,7 @@ API.v1.addRoute('dns.resolve.txt', { authRequired: true }, { throw new Meteor.Error('error-missing-param', 'The required "url" param is missing.'); } - const resolved = Promise.await(resolveTXT(url)); + const resolved = await resolveTXT(url); return API.v1.success({ resolved }); }, diff --git a/app/api/server/v1/email-inbox.js b/app/api/server/v1/email-inbox.js index e7452fc5ffe1e..61368a2d0a8ab 100644 --- a/app/api/server/v1/email-inbox.js +++ b/app/api/server/v1/email-inbox.js @@ -3,7 +3,7 @@ import { check, Match } from 'meteor/check'; import { API } from '../api'; import { findEmailInboxes, findOneEmailInbox, insertOneOrUpdateEmailInbox } from '../lib/emailInbox'; import { hasPermission } from '../../../authorization/server/functions/hasPermission'; -import { EmailInbox } from '../../../models'; +import { EmailInbox } from '../../../models/server/raw'; import Users from '../../../models/server/models/Users'; import { sendTestEmailToInbox } from '../../../../server/features/EmailInbox/EmailInbox_Outgoing'; @@ -79,12 +79,12 @@ API.v1.addRoute('email-inbox/:_id', { authRequired: true }, { const { _id } = this.urlParams; if (!_id) { throw new Error('error-invalid-param'); } - const emailInboxes = EmailInbox.findOneById(_id); + const emailInboxes = Promise.await(EmailInbox.findOneById(_id)); if (!emailInboxes) { return API.v1.notFound(); } - EmailInbox.removeById(_id); + Promise.await(EmailInbox.removeById(_id)); return API.v1.success({ _id }); }, }); diff --git a/app/api/server/v1/emoji-custom.js b/app/api/server/v1/emoji-custom.js index 403cca1d189ca..092e41c1de973 100644 --- a/app/api/server/v1/emoji-custom.js +++ b/app/api/server/v1/emoji-custom.js @@ -1,6 +1,6 @@ import { Meteor } from 'meteor/meteor'; -import { EmojiCustom } from '../../../models/server'; +import { EmojiCustom } from '../../../models/server/raw'; import { API } from '../api'; import { getUploadFormData } from '../lib/getUploadFormData'; import { findEmojisCustom } from '../lib/emoji-custom'; @@ -19,15 +19,15 @@ API.v1.addRoute('emoji-custom.list', { authRequired: true }, { } return API.v1.success({ emojis: { - update: EmojiCustom.find({ ...query, _updatedAt: { $gt: updatedSinceDate } }).fetch(), - remove: EmojiCustom.trashFindDeletedAfter(updatedSinceDate).fetch(), + update: Promise.await(EmojiCustom.find({ ...query, _updatedAt: { $gt: updatedSinceDate } }).toArray()), + remove: Promise.await(EmojiCustom.trashFindDeletedAfter(updatedSinceDate).toArray()), }, }); } return API.v1.success({ emojis: { - update: EmojiCustom.find(query).fetch(), + update: Promise.await(EmojiCustom.find(query).toArray()), remove: [], }, }); @@ -88,7 +88,7 @@ API.v1.addRoute('emoji-custom.update', { authRequired: true }, { throw new Meteor.Error('The required "_id" query param is missing.'); } - const emojiToUpdate = EmojiCustom.findOneById(fields._id); + const emojiToUpdate = Promise.await(EmojiCustom.findOneById(fields._id)); if (!emojiToUpdate) { throw new Meteor.Error('Emoji not found.'); } diff --git a/app/api/server/v1/groups.js b/app/api/server/v1/groups.js index 4cf5d029ad6b9..141bb94d49d61 100644 --- a/app/api/server/v1/groups.js +++ b/app/api/server/v1/groups.js @@ -3,7 +3,8 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; import { mountIntegrationQueryBasedOnPermissions } from '../../../integrations/server/lib/mountQueriesBasedOnPermission'; -import { Subscriptions, Rooms, Messages, Uploads, Integrations, Users } from '../../../models/server'; +import { Subscriptions, Rooms, Messages, Users } from '../../../models/server'; +import { Integrations, Uploads } from '../../../models/server/raw'; import { hasPermission, hasAtLeastOnePermission, canAccessRoom, hasAllPermission } from '../../../authorization/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; import { API } from '../api'; @@ -272,18 +273,18 @@ API.v1.addRoute('groups.files', { authRequired: true }, { const ourQuery = Object.assign({}, query, { rid: findResult.rid }); - const files = Uploads.find(ourQuery, { + const files = Promise.await(Uploads.find(ourQuery, { sort: sort || { name: 1 }, skip: offset, limit: count, fields, - }).fetch(); + }).toArray()); return API.v1.success({ files: files.map(addUserObjectToEveryObject), count: files.length, offset, - total: Uploads.find(ourQuery).count(), + total: Promise.await(Uploads.find(ourQuery).count()), }); }, }); @@ -312,21 +313,24 @@ API.v1.addRoute('groups.getIntegrations', { authRequired: true }, { } const { offset, count } = this.getPaginationItems(); - const { sort, fields, query } = this.parseJsonQuery(); + const { sort, fields: projection, query } = this.parseJsonQuery(); const ourQuery = Object.assign(mountIntegrationQueryBasedOnPermissions(this.userId), query, { channel: { $in: channelsToSearch } }); - const integrations = Integrations.find(ourQuery, { + const cursor = Integrations.find(ourQuery, { sort: sort || { _createdAt: 1 }, skip: offset, limit: count, - fields, - }).fetch(); + projection, + }); + + const integrations = Promise.await(cursor.toArray()); + const total = Promise.await(cursor.count()); return API.v1.success({ integrations, count: integrations.length, offset, - total: Integrations.find(ourQuery).count(), + total, }); }, }); diff --git a/app/api/server/v1/im.js b/app/api/server/v1/im.js index 21d164ee862b0..41d3d5dfb273c 100644 --- a/app/api/server/v1/im.js +++ b/app/api/server/v1/im.js @@ -1,8 +1,9 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; -import { Subscriptions, Uploads, Users, Messages, Rooms } from '../../../models/server'; -import { hasPermission } from '../../../authorization/server'; +import { Subscriptions, Users, Messages, Rooms } from '../../../models/server'; +import { Uploads } from '../../../models/server/raw'; +import { canAccessRoom, hasPermission } from '../../../authorization/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; import { settings } from '../../../settings/server'; import { API } from '../api'; @@ -19,7 +20,7 @@ function findDirectMessageRoom(params, user, allowAdminOverride) { nameOrId: params.username || params.roomId, }); - const canAccess = Meteor.call('canAccessRoom', room._id, user._id) + const canAccess = canAccessRoom(room, user) || (allowAdminOverride && hasPermission(user._id, 'view-room-administration')); if (!canAccess || !room || room.t !== 'd') { throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "username" param provided does not match any direct message'); @@ -148,18 +149,18 @@ API.v1.addRoute(['dm.files', 'im.files'], { authRequired: true }, { const ourQuery = Object.assign({}, query, { rid: findResult.room._id }); - const files = Uploads.find(ourQuery, { + const files = Promise.await(Uploads.find(ourQuery, { sort: sort || { name: 1 }, skip: offset, limit: count, fields, - }).fetch(); + }).toArray()); return API.v1.success({ files: files.map(addUserObjectToEveryObject), count: files.length, offset, - total: Uploads.find(ourQuery).count(), + total: Promise.await(Uploads.find(ourQuery).count()), }); }, }); diff --git a/app/api/server/v1/instances.ts b/app/api/server/v1/instances.ts index e6586a7c12a73..54bd2a563d14f 100644 --- a/app/api/server/v1/instances.ts +++ b/app/api/server/v1/instances.ts @@ -1,16 +1,16 @@ import { getInstanceConnection } from '../../../../server/stream/streamBroadcast'; import { hasPermission } from '../../../authorization/server'; import { API } from '../api'; -import InstanceStatus from '../../../models/server/models/InstanceStatus'; +import { InstanceStatus } from '../../../models/server/raw'; import { IInstanceStatus } from '../../../../definition/IInstanceStatus'; API.v1.addRoute('instances.get', { authRequired: true }, { - get() { + async get() { if (!hasPermission(this.userId, 'view-statistics')) { return API.v1.unauthorized(); } - const instances = InstanceStatus.find().fetch(); + const instances = await InstanceStatus.find().toArray(); return API.v1.success({ instances: instances.map((instance: IInstanceStatus) => { diff --git a/app/api/server/v1/integrations.js b/app/api/server/v1/integrations.js index 480c3e8743eb1..c05544eb4b821 100644 --- a/app/api/server/v1/integrations.js +++ b/app/api/server/v1/integrations.js @@ -2,7 +2,7 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; import { hasAtLeastOnePermission } from '../../../authorization/server'; -import { IntegrationHistory, Integrations } from '../../../models'; +import { Integrations, IntegrationHistory } from '../../../models/server/raw'; import { API } from '../api'; import { mountIntegrationHistoryQueryBasedOnPermissions, mountIntegrationQueryBasedOnPermissions } from '../../../integrations/server/lib/mountQueriesBasedOnPermission'; import { findOneIntegration } from '../lib/integrations'; @@ -63,21 +63,24 @@ API.v1.addRoute('integrations.history', { authRequired: true }, { const { id } = this.queryParams; const { offset, count } = this.getPaginationItems(); - const { sort, fields, query } = this.parseJsonQuery(); + const { sort, fields: projection, query } = this.parseJsonQuery(); const ourQuery = Object.assign(mountIntegrationHistoryQueryBasedOnPermissions(this.userId, id), query); - const history = IntegrationHistory.find(ourQuery, { + const cursor = IntegrationHistory.find(ourQuery, { sort: sort || { _updatedAt: -1 }, skip: offset, limit: count, - fields, - }).fetch(); + projection, + }); + + const history = Promise.await(cursor.toArray()); + const total = Promise.await(cursor.count()); return API.v1.success({ history, offset, items: history.length, - total: IntegrationHistory.find(ourQuery).count(), + total, }); }, }); @@ -94,21 +97,25 @@ API.v1.addRoute('integrations.list', { authRequired: true }, { } const { offset, count } = this.getPaginationItems(); - const { sort, fields, query } = this.parseJsonQuery(); + const { sort, fields: projection, query } = this.parseJsonQuery(); const ourQuery = Object.assign(mountIntegrationQueryBasedOnPermissions(this.userId), query); - const integrations = Integrations.find(ourQuery, { + const cursor = Integrations.find(ourQuery, { sort: sort || { ts: -1 }, skip: offset, limit: count, - fields, - }).fetch(); + projection, + }); + + const total = Promise.await(cursor.count()); + + const integrations = Promise.await(cursor.toArray()); return API.v1.success({ integrations, offset, items: integrations.length, - total: Integrations.find(ourQuery).count(), + total, }); }, }); @@ -138,9 +145,9 @@ API.v1.addRoute('integrations.remove', { authRequired: true }, { switch (this.bodyParams.type) { case 'webhook-outgoing': if (this.bodyParams.target_url) { - integration = Integrations.findOne({ urls: this.bodyParams.target_url }); + integration = Promise.await(Integrations.findOne({ urls: this.bodyParams.target_url })); } else if (this.bodyParams.integrationId) { - integration = Integrations.findOne({ _id: this.bodyParams.integrationId }); + integration = Promise.await(Integrations.findOne({ _id: this.bodyParams.integrationId })); } if (!integration) { @@ -155,7 +162,7 @@ API.v1.addRoute('integrations.remove', { authRequired: true }, { integration, }); case 'webhook-incoming': - integration = Integrations.findOne({ _id: this.bodyParams.integrationId }); + integration = Promise.await(Integrations.findOne({ _id: this.bodyParams.integrationId })); if (!integration) { return API.v1.failure('No integration found.'); @@ -217,9 +224,9 @@ API.v1.addRoute('integrations.update', { authRequired: true }, { switch (this.bodyParams.type) { case 'webhook-outgoing': if (this.bodyParams.target_url) { - integration = Integrations.findOne({ urls: this.bodyParams.target_url }); + integration = Promise.await(Integrations.findOne({ urls: this.bodyParams.target_url })); } else if (this.bodyParams.integrationId) { - integration = Integrations.findOne({ _id: this.bodyParams.integrationId }); + integration = Promise.await(Integrations.findOne({ _id: this.bodyParams.integrationId })); } if (!integration) { @@ -229,10 +236,10 @@ API.v1.addRoute('integrations.update', { authRequired: true }, { Meteor.call('updateOutgoingIntegration', integration._id, this.bodyParams); return API.v1.success({ - integration: Integrations.findOne({ _id: integration._id }), + integration: Promise.await(Integrations.findOne({ _id: integration._id })), }); case 'webhook-incoming': - integration = Integrations.findOne({ _id: this.bodyParams.integrationId }); + integration = Promise.await(Integrations.findOne({ _id: this.bodyParams.integrationId })); if (!integration) { return API.v1.failure('No integration found.'); @@ -241,7 +248,7 @@ API.v1.addRoute('integrations.update', { authRequired: true }, { Meteor.call('updateIncomingIntegration', integration._id, this.bodyParams); return API.v1.success({ - integration: Integrations.findOne({ _id: integration._id }), + integration: Promise.await(Integrations.findOne({ _id: integration._id })), }); default: return API.v1.failure('Invalid integration type.'); diff --git a/app/api/server/v1/invites.js b/app/api/server/v1/invites.js index fd17ec3661908..f901247547db3 100644 --- a/app/api/server/v1/invites.js +++ b/app/api/server/v1/invites.js @@ -7,7 +7,7 @@ import { validateInviteToken } from '../../../invites/server/functions/validateI API.v1.addRoute('listInvites', { authRequired: true }, { get() { - const result = listInvites(this.userId); + const result = Promise.await(listInvites(this.userId)); return API.v1.success(result); }, }); @@ -15,7 +15,7 @@ API.v1.addRoute('listInvites', { authRequired: true }, { API.v1.addRoute('findOrCreateInvite', { authRequired: true }, { post() { const { rid, days, maxUses } = this.bodyParams; - const result = findOrCreateInvite(this.userId, { rid, days, maxUses }); + const result = Promise.await(findOrCreateInvite(this.userId, { rid, days, maxUses })); return API.v1.success(result); }, @@ -24,7 +24,7 @@ API.v1.addRoute('findOrCreateInvite', { authRequired: true }, { API.v1.addRoute('removeInvite/:_id', { authRequired: true }, { delete() { const { _id } = this.urlParams; - const result = removeInvite(this.userId, { _id }); + const result = Promise.await(removeInvite(this.userId, { _id })); return API.v1.success(result); }, @@ -34,7 +34,7 @@ API.v1.addRoute('useInviteToken', { authRequired: true }, { post() { const { token } = this.bodyParams; // eslint-disable-next-line react-hooks/rules-of-hooks - const result = useInviteToken(this.userId, token); + const result = Promise.await(useInviteToken(this.userId, token)); return API.v1.success(result); }, @@ -46,7 +46,7 @@ API.v1.addRoute('validateInviteToken', { authRequired: false }, { let valid = true; try { - validateInviteToken(token); + Promise.await(validateInviteToken(token)); } catch (e) { valid = false; } diff --git a/app/api/server/v1/ldap.ts b/app/api/server/v1/ldap.ts index ee98484d17915..c424342d97128 100644 --- a/app/api/server/v1/ldap.ts +++ b/app/api/server/v1/ldap.ts @@ -7,7 +7,7 @@ import { SystemLogger } from '../../../../server/lib/logger/system'; import { LDAP } from '../../../../server/sdk'; API.v1.addRoute('ldap.testConnection', { authRequired: true }, { - post() { + async post() { if (!this.userId) { throw new Error('error-invalid-user'); } @@ -21,20 +21,20 @@ API.v1.addRoute('ldap.testConnection', { authRequired: true }, { } try { - Promise.await(LDAP.testConnection()); + await LDAP.testConnection(); } catch (error) { SystemLogger.error(error); throw new Error('Connection_failed'); } return API.v1.success({ - message: 'Connection_success', + message: 'Connection_success' as const, }); }, }); API.v1.addRoute('ldap.testSearch', { authRequired: true }, { - post() { + async post() { check(this.bodyParams, Match.ObjectIncluding({ username: String, })); @@ -51,10 +51,10 @@ API.v1.addRoute('ldap.testSearch', { authRequired: true }, { throw new Error('LDAP_disabled'); } - Promise.await(LDAP.testSearch(this.bodyParams.username)); + await LDAP.testSearch(this.bodyParams.username); return API.v1.success({ - message: 'LDAP_User_Found', + message: 'LDAP_User_Found' as const, }); }, }); diff --git a/app/api/server/v1/permissions.js b/app/api/server/v1/permissions.js deleted file mode 100644 index 4ac1661f07864..0000000000000 --- a/app/api/server/v1/permissions.js +++ /dev/null @@ -1,86 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Match, check } from 'meteor/check'; - -import { hasPermission } from '../../../authorization'; -import { Permissions, Roles } from '../../../models/server'; -import { API } from '../api'; - -API.v1.addRoute('permissions.listAll', { authRequired: true }, { - get() { - const { updatedSince } = this.queryParams; - - let updatedSinceDate; - if (updatedSince) { - if (isNaN(Date.parse(updatedSince))) { - throw new Meteor.Error('error-roomId-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); - } else { - updatedSinceDate = new Date(updatedSince); - } - } - - let result; - Meteor.runAsUser(this.userId, () => { result = Meteor.call('permissions/get', updatedSinceDate); }); - - if (Array.isArray(result)) { - result = { - update: result, - remove: [], - }; - } - - return API.v1.success(result); - }, -}); - -API.v1.addRoute('permissions.update', { authRequired: true }, { - post() { - if (!hasPermission(this.userId, 'access-permissions')) { - return API.v1.failure('Editing permissions is not allowed', 'error-edit-permissions-not-allowed'); - } - - check(this.bodyParams, { - permissions: [ - Match.ObjectIncluding({ - _id: String, - roles: [String], - }), - ], - }); - - let permissionNotFound = false; - let roleNotFound = false; - Object.keys(this.bodyParams.permissions).forEach((key) => { - const element = this.bodyParams.permissions[key]; - - if (!Permissions.findOneById(element._id)) { - permissionNotFound = true; - } - - Object.keys(element.roles).forEach((key) => { - const subelement = element.roles[key]; - - if (!Roles.findOneById(subelement)) { - roleNotFound = true; - } - }); - }); - - if (permissionNotFound) { - return API.v1.failure('Invalid permission', 'error-invalid-permission'); - } if (roleNotFound) { - return API.v1.failure('Invalid role', 'error-invalid-role'); - } - - Object.keys(this.bodyParams.permissions).forEach((key) => { - const element = this.bodyParams.permissions[key]; - - Permissions.createOrUpdate(element._id, element.roles); - }); - - const result = Meteor.runAsUser(this.userId, () => Meteor.call('permissions/get')); - - return API.v1.success({ - permissions: result, - }); - }, -}); diff --git a/app/api/server/v1/permissions.ts b/app/api/server/v1/permissions.ts new file mode 100644 index 0000000000000..988f4907e351f --- /dev/null +++ b/app/api/server/v1/permissions.ts @@ -0,0 +1,74 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization/server'; +import { API } from '../api'; +import { Permissions, Roles } from '../../../models/server/raw'; +import { IPermission } from '../../../../definition/IPermission'; +import { isBodyParamsValidPermissionUpdate } from '../../../../definition/rest/v1/permissions'; + +API.v1.addRoute('permissions.listAll', { authRequired: true }, { + async get() { + const { updatedSince } = this.queryParams; + + let updatedSinceDate: Date | undefined; + if (updatedSince) { + if (isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-roomId-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); + } + updatedSinceDate = new Date(updatedSince); + } + + const result = await Meteor.call('permissions/get', updatedSinceDate) as { + update: IPermission[]; + remove: IPermission[]; + }; + + if (Array.isArray(result)) { + return API.v1.success({ + update: result, + remove: [], + }); + } + + return API.v1.success(result); + }, +}); + +API.v1.addRoute('permissions.update', { authRequired: true }, { + async post() { + if (!hasPermission(this.userId, 'access-permissions')) { + return API.v1.failure('Editing permissions is not allowed', 'error-edit-permissions-not-allowed'); + } + + const { bodyParams } = this; + + if (!isBodyParamsValidPermissionUpdate(bodyParams)) { + return API.v1.failure('Invalid body params', 'error-invalid-body-params'); + } + + const permissionKeys = bodyParams.permissions.map(({ _id }) => _id); + const permissions = await Permissions.find({ _id: { $in: permissionKeys } }).toArray(); + + if (permissions.length !== bodyParams.permissions.length) { + return API.v1.failure('Invalid permission', 'error-invalid-permission'); + } + + const roleKeys = [...new Set(bodyParams.permissions.flatMap((p) => p.roles))]; + + const roles = await Roles.find({ _id: { $in: roleKeys } }).toArray(); + + if (roles.length !== roleKeys.length) { + return API.v1.failure('Invalid role', 'error-invalid-role'); + } + + for await (const permission of bodyParams.permissions) { + await Permissions.setRoles(permission._id, permission.roles); + } + + const result = await Meteor.call('permissions/get') as IPermission[]; + + return API.v1.success({ + permissions: result, + }); + }, +}); diff --git a/app/api/server/v1/roles.js b/app/api/server/v1/roles.js deleted file mode 100644 index 39d89164e4ab7..0000000000000 --- a/app/api/server/v1/roles.js +++ /dev/null @@ -1,281 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Match, check } from 'meteor/check'; - -import { Roles, Users } from '../../../models'; -import { API } from '../api'; -import { getUsersInRole, hasPermission, hasRole } from '../../../authorization/server'; -import { settings } from '../../../settings/server/index'; -import { api } from '../../../../server/sdk/api'; - -API.v1.addRoute('roles.list', { authRequired: true }, { - get() { - const roles = Roles.find({}, { fields: { _updatedAt: 0 } }).fetch(); - - return API.v1.success({ roles }); - }, -}); - -API.v1.addRoute('roles.sync', { authRequired: true }, { - get() { - const { updatedSince } = this.queryParams; - - if (isNaN(Date.parse(updatedSince))) { - throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); - } - - return API.v1.success({ - roles: { - update: Roles.findByUpdatedDate(new Date(updatedSince), { fields: API.v1.defaultFieldsToExclude }).fetch(), - remove: Roles.trashFindDeletedAfter(new Date(updatedSince)).fetch(), - }, - }); - }, -}); - -API.v1.addRoute('roles.create', { authRequired: true }, { - post() { - check(this.bodyParams, { - name: String, - scope: Match.Maybe(String), - description: Match.Maybe(String), - mandatory2fa: Match.Maybe(Boolean), - }); - - const roleData = { - name: this.bodyParams.name, - scope: this.bodyParams.scope, - description: this.bodyParams.description, - mandatory2fa: this.bodyParams.mandatory2fa, - }; - - if (!hasPermission(Meteor.userId(), 'access-permissions')) { - throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed'); - } - - if (Roles.findOneByIdOrName(roleData.name)) { - throw new Meteor.Error('error-duplicate-role-names-not-allowed', 'Role name already exists'); - } - - if (['Users', 'Subscriptions'].includes(roleData.scope) === false) { - roleData.scope = 'Users'; - } - - const roleId = Roles.createWithRandomId(roleData.name, roleData.scope, roleData.description, false, roleData.mandatory2fa); - - if (settings.get('UI_DisplayRoles')) { - api.broadcast('user.roleUpdate', { - type: 'changed', - _id: roleId, - }); - } - - return API.v1.success({ - role: Roles.findOneByIdOrName(roleId, { fields: API.v1.defaultFieldsToExclude }), - }); - }, -}); - -API.v1.addRoute('roles.addUserToRole', { authRequired: true }, { - post() { - check(this.bodyParams, { - roleName: String, - username: String, - roomId: Match.Maybe(String), - }); - - const user = this.getUserFromParams(); - const { roleName, roomId } = this.bodyParams; - - if (hasRole(user._id, roleName, roomId)) { - throw new Meteor.Error('error-user-already-in-role', 'User already in role'); - } - - Meteor.runAsUser(this.userId, () => { - Meteor.call('authorization:addUserToRole', roleName, user.username, roomId); - }); - - return API.v1.success({ - role: Roles.findOneByIdOrName(this.bodyParams.roleName, { fields: API.v1.defaultFieldsToExclude }), - }); - }, -}); - -API.v1.addRoute('roles.getUsersInRole', { authRequired: true }, { - get() { - const { roomId, role } = this.queryParams; - const { offset, count = 50 } = this.getPaginationItems(); - - const fields = { - name: 1, - username: 1, - emails: 1, - avatarETag: 1, - }; - - if (!role) { - throw new Meteor.Error('error-param-not-provided', 'Query param "role" is required'); - } - if (!hasPermission(this.userId, 'access-permissions')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } - if (roomId && !hasPermission(this.userId, 'view-other-user-channels')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } - const users = getUsersInRole(role, roomId, { - limit: count, - sort: { username: 1 }, - skip: offset, - fields, - }); - return API.v1.success({ users: users.fetch(), total: users.count() }); - }, -}); - -API.v1.addRoute('roles.update', { authRequired: true }, { - post() { - check(this.bodyParams, { - roleId: String, - name: Match.Maybe(String), - scope: Match.Maybe(String), - description: Match.Maybe(String), - mandatory2fa: Match.Maybe(Boolean), - }); - - const roleData = { - roleId: this.bodyParams.roleId, - name: this.bodyParams.name, - scope: this.bodyParams.scope, - description: this.bodyParams.description, - mandatory2fa: this.bodyParams.mandatory2fa, - }; - - const role = Roles.findOneByIdOrName(roleData.roleId); - - if (!role) { - throw new Meteor.Error('error-invalid-roleId', 'This role does not exist'); - } - - if (role.protected && ((roleData.name && roleData.name !== role.name) || (roleData.scope && roleData.scope !== role.scope))) { - throw new Meteor.Error('error-role-protected', 'Role is protected'); - } - - if (roleData.name) { - const otherRole = Roles.findOneByIdOrName(roleData.name); - if (otherRole && otherRole._id !== role._id) { - throw new Meteor.Error('error-duplicate-role-names-not-allowed', 'Role name already exists'); - } - } - - if (roleData.scope) { - if (['Users', 'Subscriptions'].includes(roleData.scope) === false) { - roleData.scope = 'Users'; - } - } - - Roles.updateById(roleData.roleId, roleData.name, roleData.scope, roleData.description, roleData.mandatory2fa); - - if (settings.get('UI_DisplayRoles')) { - api.broadcast('user.roleUpdate', { - type: 'changed', - _id: roleData.roleId, - }); - } - - return API.v1.success({ - role: Roles.findOneByIdOrName(roleData.roleId, { fields: API.v1.defaultFieldsToExclude }), - }); - }, -}); - -API.v1.addRoute('roles.delete', { authRequired: true }, { - post() { - check(this.bodyParams, { - roleId: String, - }); - - if (!hasPermission(this.userId, 'access-permissions')) { - throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed'); - } - - const role = Roles.findOneByIdOrName(this.bodyParams.roleId); - - if (!role) { - throw new Meteor.Error('error-invalid-roleId', 'This role does not exist'); - } - - if (role.protected) { - throw new Meteor.Error('error-role-protected', 'Cannot delete a protected role'); - } - - const existingUsers = Roles.findUsersInRole(role.name, role.scope); - - if (existingUsers && existingUsers.count() > 0) { - throw new Meteor.Error('error-role-in-use', 'Cannot delete role because it\'s in use'); - } - - Roles.remove(role._id); - - return API.v1.success(); - }, -}); - -API.v1.addRoute('roles.removeUserFromRole', { authRequired: true }, { - post() { - check(this.bodyParams, { - roleName: String, - username: String, - scope: Match.Maybe(String), - }); - - const data = { - roleName: this.bodyParams.roleName, - username: this.bodyParams.username, - scope: this.bodyParams.scope, - }; - - if (!hasPermission(this.userId, 'access-permissions')) { - throw new Meteor.Error('error-not-allowed', 'Accessing permissions is not allowed'); - } - - const user = Users.findOneByUsername(data.username); - - if (!user) { - throw new Meteor.Error('error-invalid-user', 'There is no user with this username'); - } - - const role = Roles.findOneByIdOrName(data.roleName); - - if (!role) { - throw new Meteor.Error('error-invalid-roleId', 'This role does not exist'); - } - - if (!hasRole(user._id, role.name, data.scope)) { - throw new Meteor.Error('error-user-not-in-role', 'User is not in this role'); - } - - if (role._id === 'admin') { - const adminCount = Roles.findUsersInRole('admin').count(); - if (adminCount === 1) { - throw new Meteor.Error('error-admin-required', 'You need to have at least one admin'); - } - } - - Roles.removeUserRoles(user._id, role.name, data.scope); - - if (settings.get('UI_DisplayRoles')) { - api.broadcast('user.roleUpdate', { - type: 'removed', - _id: role._id, - u: { - _id: user._id, - username: user.username, - }, - scope: data.scope, - }); - } - - return API.v1.success({ - role, - }); - }, -}); diff --git a/app/api/server/v1/roles.ts b/app/api/server/v1/roles.ts new file mode 100644 index 0000000000000..ef92c3547c2d5 --- /dev/null +++ b/app/api/server/v1/roles.ts @@ -0,0 +1,285 @@ +import { Meteor } from 'meteor/meteor'; +import { check, Match } from 'meteor/check'; + +import { Users } from '../../../models/server'; +import { API } from '../api'; +import { getUsersInRole, hasRole } from '../../../authorization/server'; +import { settings } from '../../../settings/server/index'; +import { api } from '../../../../server/sdk/api'; +import { Roles } from '../../../models/server/raw'; +import { hasRoleAsync } from '../../../authorization/server/functions/hasRole'; +import { isRoleAddUserToRoleProps, isRoleCreateProps, isRoleDeleteProps, isRoleRemoveUserFromRoleProps, isRoleUpdateProps } from '../../../../definition/rest/v1/roles'; +import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; + +API.v1.addRoute('roles.list', { authRequired: true }, { + async get() { + const roles = await Roles.find({}, { projection: { _updatedAt: 0 } }).toArray(); + + return API.v1.success({ roles }); + }, +}); + +API.v1.addRoute('roles.sync', { authRequired: true }, { + async get() { + check(this.queryParams, Match.ObjectIncluding({ + updatedSince: Match.Where((value: unknown): value is string => typeof value === 'string' && !Number.isNaN(Date.parse(value))), + })); + + const { updatedSince } = this.queryParams; + + return API.v1.success({ + roles: { + update: await Roles.findByUpdatedDate(new Date(updatedSince)).toArray(), + remove: await Roles.trashFindDeletedAfter(new Date(updatedSince)).toArray(), + }, + }); + }, +}); + +API.v1.addRoute('roles.create', { authRequired: true }, { + async post() { + if (!isRoleCreateProps(this.bodyParams)) { + throw new Meteor.Error('error-invalid-role-properties', 'The role properties are invalid.'); + } + + const { name, scope, description, mandatory2fa } = this.bodyParams; + + if (!await hasPermissionAsync(Meteor.userId(), 'access-permissions')) { + throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed'); + } + + if (await Roles.findOneByIdOrName(name)) { + throw new Meteor.Error('error-duplicate-role-names-not-allowed', 'Role name already exists'); + } + + const roleId = (await Roles.createWithRandomId( + name, + scope && ['Users', 'Subscriptions'].includes(scope) ? scope : 'Users', + description, + false, + mandatory2fa, + )).insertedId; + + if (settings.get('UI_DisplayRoles')) { + api.broadcast('user.roleUpdate', { + type: 'changed', + _id: roleId, + }); + } + + const role = await Roles.findOneByIdOrName(roleId); + + if (!role) { + return API.v1.failure('error-role-not-found', 'Role not found'); + } + + return API.v1.success({ + role, + }); + }, +}); + +API.v1.addRoute('roles.addUserToRole', { authRequired: true }, { + async post() { + if (!isRoleAddUserToRoleProps(this.bodyParams)) { + throw new Meteor.Error('error-invalid-role-properties', isRoleAddUserToRoleProps.errors?.map((error) => error.message).join('\n')); + } + + const user = this.getUserFromParams(); + const { roleName, roomId } = this.bodyParams; + + if (hasRole(user._id, roleName, roomId)) { + throw new Meteor.Error('error-user-already-in-role', 'User already in role'); + } + + await Meteor.call('authorization:addUserToRole', roleName, user.username, roomId); + + const role = await Roles.findOneByIdOrName(roleName); + + if (!role) { + return API.v1.failure('error-role-not-found', 'Role not found'); + } + + return API.v1.success({ + role, + }); + }, +}); + +API.v1.addRoute('roles.getUsersInRole', { authRequired: true }, { + async get() { + const { roomId, role } = this.queryParams; + const { offset, count = 50 } = this.getPaginationItems(); + + const projection = { + name: 1, + username: 1, + emails: 1, + avatarETag: 1, + }; + + if (!role) { + throw new Meteor.Error('error-param-not-provided', 'Query param "role" is required'); + } + if (!await hasPermissionAsync(this.userId, 'access-permissions')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + if (roomId && !await hasPermissionAsync(this.userId, 'view-other-user-channels')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + const users = await getUsersInRole(role, roomId, { + limit: count as number, + sort: { username: 1 }, + skip: offset as number, + projection, + }); + + return API.v1.success({ users: await users.toArray(), total: await users.count() }); + }, +}); + +API.v1.addRoute('roles.update', { authRequired: true }, { + async post() { + const { bodyParams } = this; + if (!isRoleUpdateProps(bodyParams)) { + throw new Meteor.Error('error-invalid-role-properties', 'The role properties are invalid.'); + } + + const roleData = { + roleId: bodyParams.roleId, + name: bodyParams.name, + scope: bodyParams.scope || 'Users', + description: bodyParams.description, + mandatory2fa: bodyParams.mandatory2fa, + }; + + const role = await Roles.findOneByIdOrName(roleData.roleId); + + if (!role) { + throw new Meteor.Error('error-invalid-roleId', 'This role does not exist'); + } + + if (role.protected && ((roleData.name && roleData.name !== role.name) || (roleData.scope && roleData.scope !== role.scope))) { + throw new Meteor.Error('error-role-protected', 'Role is protected'); + } + + if (roleData.name) { + const otherRole = await Roles.findOneByIdOrName(roleData.name); + if (otherRole && otherRole._id !== role._id) { + throw new Meteor.Error('error-duplicate-role-names-not-allowed', 'Role name already exists'); + } + } + + if (['Users', 'Subscriptions'].includes(roleData.scope) === false) { + throw new Meteor.Error('error-invalid-scope', 'Invalid scope'); + } + + await Roles.updateById(roleData.roleId, roleData.name, roleData.scope, roleData.description, roleData.mandatory2fa); + + if (settings.get('UI_DisplayRoles')) { + api.broadcast('user.roleUpdate', { + type: 'changed', + _id: roleData.roleId, + }); + } + + const updatedRole = await Roles.findOneByIdOrName(roleData.roleId); + + if (!updatedRole) { + return API.v1.failure(); + } + + return API.v1.success({ + role: updatedRole, + }); + }, +}); + +API.v1.addRoute('roles.delete', { authRequired: true }, { + async post() { + const { bodyParams } = this; + if (!isRoleDeleteProps(bodyParams)) { + throw new Meteor.Error('error-invalid-role-properties', 'The role properties are invalid.'); + } + + if (!await hasPermissionAsync(this.userId, 'access-permissions')) { + throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed'); + } + + const role = await Roles.findOneByIdOrName(bodyParams.roleId); + + if (!role) { + throw new Meteor.Error('error-invalid-roleId', 'This role does not exist'); + } + + if (role.protected) { + throw new Meteor.Error('error-role-protected', 'Cannot delete a protected role'); + } + + const existingUsers = await Roles.findUsersInRole(role.name, role.scope); + + if (existingUsers && await existingUsers.count() > 0) { + throw new Meteor.Error('error-role-in-use', 'Cannot delete role because it\'s in use'); + } + + await Roles.removeById(role._id); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('roles.removeUserFromRole', { authRequired: true }, { + async post() { + const { bodyParams } = this; + if (!isRoleRemoveUserFromRoleProps(bodyParams)) { + throw new Meteor.Error('error-invalid-role-properties', 'The role properties are invalid.'); + } + + const { roleName, username, scope } = bodyParams; + + if (!await hasPermissionAsync(this.userId, 'access-permissions')) { + throw new Meteor.Error('error-not-allowed', 'Accessing permissions is not allowed'); + } + + const user = Users.findOneByUsername(username); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'There is no user with this username'); + } + + const role = await Roles.findOneByIdOrName(roleName); + + if (!role) { + throw new Meteor.Error('error-invalid-roleId', 'This role does not exist'); + } + + if (!await hasRoleAsync(user._id, role.name, scope)) { + throw new Meteor.Error('error-user-not-in-role', 'User is not in this role'); + } + + if (role._id === 'admin') { + const adminCount = await (await Roles.findUsersInRole('admin')).count(); + if (adminCount === 1) { + throw new Meteor.Error('error-admin-required', 'You need to have at least one admin'); + } + } + + await Roles.removeUserRoles(user._id, [role.name], scope); + + if (settings.get('UI_DisplayRoles')) { + api.broadcast('user.roleUpdate', { + type: 'removed', + _id: role._id, + u: { + _id: user._id, + username: user.username, + }, + scope, + }); + } + + return API.v1.success({ + role, + }); + }, +}); diff --git a/app/api/server/v1/rooms.js b/app/api/server/v1/rooms.js index eee7230a0bbfe..df793f68226be 100644 --- a/app/api/server/v1/rooms.js +++ b/app/api/server/v1/rooms.js @@ -3,7 +3,7 @@ import { Meteor } from 'meteor/meteor'; import { FileUpload } from '../../../file-upload'; import { Rooms, Messages } from '../../../models'; import { API } from '../api'; -import { findAdminRooms, findChannelAndPrivateAutocomplete, findAdminRoom, findRoomsAvailableForTeams, findChannelAndPrivateAutocompleteWithPagination } from '../lib/rooms'; +import { findAdminRooms, findChannelAndPrivateAutocomplete, findAdminRoom, findAdminRoomsAutocomplete, findRoomsAvailableForTeams, findChannelAndPrivateAutocompleteWithPagination } from '../lib/rooms'; import { sendFile, sendViaEmail } from '../../../../server/lib/channelExport'; import { canAccessRoom, hasPermission } from '../../../authorization/server'; import { Media } from '../../../../server/sdk'; @@ -65,9 +65,7 @@ API.v1.addRoute('rooms.get', { authRequired: true }, { API.v1.addRoute('rooms.upload/:rid', { authRequired: true }, { post() { - const room = Meteor.call('canAccessRoom', this.urlParams.rid, this.userId); - - if (!room) { + if (!canAccessRoom({ _id: this.urlParams.rid }, { _id: this.userId })) { return API.v1.unauthorized(); } @@ -191,9 +189,11 @@ API.v1.addRoute('rooms.info', { authRequired: true }, { get() { const room = findRoomByIdOrName({ params: this.requestParams() }); const { fields } = this.parseJsonQuery(); - if (!Meteor.call('canAccessRoom', room._id, this.userId, {})) { + + if (!room || !canAccessRoom(room, { _id: this.userId })) { return API.v1.failure('not-allowed', 'Not Allowed'); } + return API.v1.success({ room: Rooms.findOneByIdOrName(room._id, { fields }) }); }, }); @@ -244,9 +244,11 @@ API.v1.addRoute('rooms.getDiscussions', { authRequired: true }, { const room = findRoomByIdOrName({ params: this.requestParams() }); const { offset, count } = this.getPaginationItems(); const { sort, fields, query } = this.parseJsonQuery(); - if (!Meteor.call('canAccessRoom', room._id, this.userId, {})) { + + if (!room || !canAccessRoom(room, { _id: this.userId })) { return API.v1.failure('not-allowed', 'Not Allowed'); } + const ourQuery = Object.assign(query, { prid: room._id }); const discussions = Rooms.find(ourQuery, { @@ -284,6 +286,20 @@ API.v1.addRoute('rooms.adminRooms', { authRequired: true }, { }, }); +API.v1.addRoute('rooms.autocomplete.adminRooms', { authRequired: true }, { + get() { + const { selector } = this.queryParams; + if (!selector) { + return API.v1.failure('The \'selector\' param is required'); + } + + return API.v1.success(Promise.await(findAdminRoomsAutocomplete({ + uid: this.userId, + selector: JSON.parse(selector), + }))); + }, +}); + API.v1.addRoute('rooms.adminRooms.getRoom', { authRequired: true }, { get() { const { rid } = this.requestParams(); diff --git a/app/api/server/v1/settings.js b/app/api/server/v1/settings.js deleted file mode 100644 index b9c720f3522a5..0000000000000 --- a/app/api/server/v1/settings.js +++ /dev/null @@ -1,166 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Match, check } from 'meteor/check'; -import { ServiceConfiguration } from 'meteor/service-configuration'; -import _ from 'underscore'; - -import { Settings } from '../../../models/server'; -import { hasPermission } from '../../../authorization'; -import { API } from '../api'; -import { SettingsEvents, settings } from '../../../settings/server'; -import { setValue } from '../../../settings/server/raw'; - -const fetchSettings = (query, sort, offset, count, fields) => { - const settings = Settings.find(query, { - sort: sort || { _id: 1 }, - skip: offset, - limit: count, - fields: Object.assign({ _id: 1, value: 1, enterprise: 1, invalidValue: 1, modules: 1 }, fields), - }).fetch(); - - SettingsEvents.emit('fetch-settings', settings); - return settings; -}; - -// settings endpoints -API.v1.addRoute('settings.public', { authRequired: false }, { - get() { - const { offset, count } = this.getPaginationItems(); - const { sort, fields, query } = this.parseJsonQuery(); - - let ourQuery = { - hidden: { $ne: true }, - public: true, - }; - - ourQuery = Object.assign({}, query, ourQuery); - - const settings = fetchSettings(ourQuery, sort, offset, count, fields); - - return API.v1.success({ - settings, - count: settings.length, - offset, - total: Settings.find(ourQuery).count(), - }); - }, -}); - -API.v1.addRoute('settings.oauth', { authRequired: false }, { - get() { - const mountOAuthServices = () => { - const oAuthServicesEnabled = ServiceConfiguration.configurations.find({}, { fields: { secret: 0 } }).fetch(); - - return oAuthServicesEnabled.map((service) => { - if (service.custom || ['saml', 'cas', 'wordpress'].includes(service.service)) { - return { ...service }; - } - - return { - _id: service._id, - name: service.service, - clientId: service.appId || service.clientId || service.consumerKey, - buttonLabelText: service.buttonLabelText || '', - buttonColor: service.buttonColor || '', - buttonLabelColor: service.buttonLabelColor || '', - custom: false, - }; - }); - }; - - return API.v1.success({ - services: mountOAuthServices(), - }); - }, -}); - -API.v1.addRoute('settings.addCustomOAuth', { authRequired: true, twoFactorRequired: true }, { - post() { - if (!this.requestParams().name || !this.requestParams().name.trim()) { - throw new Meteor.Error('error-name-param-not-provided', 'The parameter "name" is required'); - } - - Meteor.runAsUser(this.userId, () => { - Meteor.call('addOAuthService', this.requestParams().name, this.userId); - }); - - - return API.v1.success(); - }, -}); - -API.v1.addRoute('settings', { authRequired: true }, { - get() { - const { offset, count } = this.getPaginationItems(); - const { sort, fields, query } = this.parseJsonQuery(); - - let ourQuery = { - hidden: { $ne: true }, - }; - - if (!hasPermission(this.userId, 'view-privileged-setting')) { - ourQuery.public = true; - } - - ourQuery = Object.assign({}, query, ourQuery); - - const settings = fetchSettings(ourQuery, sort, offset, count, fields); - - return API.v1.success({ - settings, - count: settings.length, - offset, - total: Settings.find(ourQuery).count(), - }); - }, -}); - -API.v1.addRoute('settings/:_id', { authRequired: true }, { - get() { - if (!hasPermission(this.userId, 'view-privileged-setting')) { - return API.v1.unauthorized(); - } - - return API.v1.success(_.pick(Settings.findOneNotHiddenById(this.urlParams._id), '_id', 'value')); - }, - post: { - twoFactorRequired: true, - action() { - if (!hasPermission(this.userId, 'edit-privileged-setting')) { - return API.v1.unauthorized(); - } - - // allow special handling of particular setting types - const setting = Settings.findOneNotHiddenById(this.urlParams._id); - if (setting.type === 'action' && this.bodyParams && this.bodyParams.execute) { - // execute the configured method - Meteor.call(setting.value); - return API.v1.success(); - } - - if (setting.type === 'color' && this.bodyParams && this.bodyParams.editor && this.bodyParams.value) { - Settings.updateOptionsById(this.urlParams._id, { editor: this.bodyParams.editor }); - Settings.updateValueNotHiddenById(this.urlParams._id, this.bodyParams.value); - return API.v1.success(); - } - - check(this.bodyParams, { - value: Match.Any, - }); - if (Settings.updateValueNotHiddenById(this.urlParams._id, this.bodyParams.value)) { - settings.set(Settings.findOneNotHiddenById(this.urlParams._id)); - setValue(this.urlParams._id, this.bodyParams.value); - return API.v1.success(); - } - - return API.v1.failure(); - }, - }, -}); - -API.v1.addRoute('service.configurations', { authRequired: false }, { - get() { - return API.v1.success({ - configurations: ServiceConfiguration.configurations.find({}, { fields: { secret: 0 } }).fetch(), - }); - }, -}); diff --git a/app/api/server/v1/settings.ts b/app/api/server/v1/settings.ts new file mode 100644 index 0000000000000..ca0a8ac5178da --- /dev/null +++ b/app/api/server/v1/settings.ts @@ -0,0 +1,178 @@ +import { Meteor } from 'meteor/meteor'; +import { ServiceConfiguration } from 'meteor/service-configuration'; +import _ from 'underscore'; + +import { Settings } from '../../../models/server/raw'; +import { hasPermission } from '../../../authorization/server'; +import { API, ResultFor } from '../api'; +import { SettingsEvents, settings } from '../../../settings/server'; +import { setValue } from '../../../settings/server/raw'; +import { ISetting, ISettingColor, isSettingAction, isSettingColor } from '../../../../definition/ISetting'; +import { isOauthCustomConfiguration, isSettingsUpdatePropDefault, isSettingsUpdatePropsActions, isSettingsUpdatePropsColor } from '../../../../definition/rest/v1/settings'; + + +const fetchSettings = async (query: Parameters[0], sort: Parameters[1]['sort'], offset: Parameters[1]['skip'], count: Parameters[1]['limit'], fields: Parameters[1]['projection']): Promise => { + const settings = await Settings.find(query, { + sort: sort || { _id: 1 }, + skip: offset, + limit: count, + projection: { _id: 1, value: 1, enterprise: 1, invalidValue: 1, modules: 1, ...fields }, + }).toArray() as unknown as ISetting[]; + + + SettingsEvents.emit('fetch-settings', settings); + return settings; +}; + +// settings endpoints +API.v1.addRoute('settings.public', { authRequired: false }, { + async get() { + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + const ourQuery = { + ...query, + hidden: { $ne: true }, + public: true, + }; + + const settings = await fetchSettings(ourQuery, sort, offset, count, fields); + + return API.v1.success({ + settings, + count: settings.length, + offset, + total: await Settings.find(ourQuery).count(), + }); + }, +}); + +API.v1.addRoute('settings.oauth', { authRequired: false }, { + get() { + const oAuthServicesEnabled = ServiceConfiguration.configurations.find({}, { fields: { secret: 0 } }).fetch(); + + return API.v1.success({ + services: oAuthServicesEnabled.map((service) => { + if (!isOauthCustomConfiguration(service)) { + return service; + } + + if (service.custom || (service.service && ['saml', 'cas', 'wordpress'].includes(service.service))) { + return { ...service }; + } + + return { + _id: service._id, + name: service.service, + clientId: service.appId || service.clientId || service.consumerKey, + buttonLabelText: service.buttonLabelText || '', + buttonColor: service.buttonColor || '', + buttonLabelColor: service.buttonLabelColor || '', + custom: false, + }; + }), + }); + }, +}); + +API.v1.addRoute('settings.addCustomOAuth', { authRequired: true, twoFactorRequired: true }, { + async post() { + if (!this.bodyParams.name || !this.bodyParams.name.trim()) { + throw new Meteor.Error('error-name-param-not-provided', 'The parameter "name" is required'); + } + + await Meteor.call('addOAuthService', this.bodyParams.name, this.userId); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('settings', { authRequired: true }, { + async get() { + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + let ourQuery: Parameters[0] = { + hidden: { $ne: true }, + }; + + if (!hasPermission(this.userId, 'view-privileged-setting')) { + ourQuery.public = true; + } + + ourQuery = Object.assign({}, query, ourQuery); + + const settings = await fetchSettings(ourQuery, sort, offset, count, fields); + + return API.v1.success({ + settings, + count: settings.length, + offset, + total: Settings.find(ourQuery).count(), + }); + }, +}); + +API.v1.addRoute('settings/:_id', { authRequired: true }, { + async get() { + if (!hasPermission(this.userId, 'view-privileged-setting')) { + return API.v1.unauthorized(); + } + const setting = await Settings.findOneNotHiddenById(this.urlParams._id); + if (!setting) { + return API.v1.failure(); + } + return API.v1.success(_.pick(setting, '_id', 'value')); + }, + post: { + twoFactorRequired: true, + async action(): Promise> { + if (!hasPermission(this.userId, 'edit-privileged-setting')) { + return API.v1.unauthorized(); + } + + if (typeof this.urlParams._id !== 'string') { + throw new Meteor.Error('error-id-param-not-provided', 'The parameter "id" is required'); + } + + // allow special handling of particular setting types + const setting = await Settings.findOneNotHiddenById(this.urlParams._id); + + if (!setting) { + return API.v1.failure(); + } + + if (isSettingAction(setting) && isSettingsUpdatePropsActions(this.bodyParams) && this.bodyParams.execute) { + // execute the configured method + Meteor.call(setting.value); + return API.v1.success(); + } + + if (isSettingColor(setting) && isSettingsUpdatePropsColor(this.bodyParams)) { + Settings.updateOptionsById(this.urlParams._id, { editor: this.bodyParams.editor }); + Settings.updateValueNotHiddenById(this.urlParams._id, this.bodyParams.value); + return API.v1.success(); + } + + if (isSettingsUpdatePropDefault(this.bodyParams) && await Settings.updateValueNotHiddenById(this.urlParams._id, this.bodyParams.value)) { + const s = await Settings.findOneNotHiddenById(this.urlParams._id); + if (!s) { + return API.v1.failure(); + } + settings.set(s); + setValue(this.urlParams._id, this.bodyParams.value); + return API.v1.success(); + } + + return API.v1.failure(); + }, + }, +}); + +API.v1.addRoute('service.configurations', { authRequired: false }, { + get() { + return API.v1.success({ + configurations: ServiceConfiguration.configurations.find({}, { fields: { secret: 0 } }).fetch(), + }); + }, +}); diff --git a/app/api/server/v1/teams.ts b/app/api/server/v1/teams.ts index c3235e703e35c..4f3a655aa7d43 100644 --- a/app/api/server/v1/teams.ts +++ b/app/api/server/v1/teams.ts @@ -1,6 +1,5 @@ import { FilterQuery } from 'mongodb'; import { Meteor } from 'meteor/meteor'; -import { Promise } from 'meteor/promise'; import { Match, check } from 'meteor/check'; import { escapeRegExp } from '@rocket.chat/string-helpers'; @@ -10,13 +9,22 @@ import { hasAtLeastOnePermission, hasPermission } from '../../../authorization/s import { Users } from '../../../models/server'; import { removeUserFromRoom } from '../../../lib/server/functions/removeUserFromRoom'; import { IUser } from '../../../../definition/IUser'; +import { isTeamsConvertToChannelProps } from '../../../../definition/rest/v1/teams/TeamsConvertToChannelProps'; +import { isTeamsRemoveRoomProps } from '../../../../definition/rest/v1/teams/TeamsRemoveRoomProps'; +import { isTeamsUpdateMemberProps } from '../../../../definition/rest/v1/teams/TeamsUpdateMemberProps'; +import { isTeamsRemoveMemberProps } from '../../../../definition/rest/v1/teams/TeamsRemoveMemberProps'; +import { isTeamsAddMembersProps } from '../../../../definition/rest/v1/teams/TeamsAddMembersProps'; +import { isTeamsDeleteProps } from '../../../../definition/rest/v1/teams/TeamsDeleteProps'; +import { isTeamsLeaveProps } from '../../../../definition/rest/v1/teams/TeamsLeaveProps'; +import { isTeamsUpdateProps } from '../../../../definition/rest/v1/teams/TeamsUpdateProps'; +import { ITeam, TEAM_TYPE } from '../../../../definition/ITeam'; API.v1.addRoute('teams.list', { authRequired: true }, { - get() { + async get() { const { offset, count } = this.getPaginationItems(); const { sort, query } = this.parseJsonQuery(); - const { records, total } = Promise.await(Team.list(this.userId, { offset, count }, { sort, query })); + const { records, total } = await Team.list(this.userId, { offset, count }, { sort, query }); return API.v1.success({ teams: records, @@ -28,14 +36,14 @@ API.v1.addRoute('teams.list', { authRequired: true }, { }); API.v1.addRoute('teams.listAll', { authRequired: true }, { - get() { + async get() { if (!hasPermission(this.userId, 'view-all-teams')) { return API.v1.unauthorized(); } const { offset, count } = this.getPaginationItems(); - const { records, total } = Promise.await(Team.listAll({ offset, count })); + const { records, total } = await Team.listAll({ offset, count }); return API.v1.success({ teams: records, @@ -47,17 +55,22 @@ API.v1.addRoute('teams.listAll', { authRequired: true }, { }); API.v1.addRoute('teams.create', { authRequired: true }, { - post() { + async post() { if (!hasPermission(this.userId, 'create-team')) { return API.v1.unauthorized(); } - const { name, type, members, room, owner } = this.bodyParams; - if (!name) { - return API.v1.failure('Body param "name" is required'); - } + check(this.bodyParams, Match.ObjectIncluding({ + name: String, + type: Match.OneOf(TEAM_TYPE.PRIVATE, TEAM_TYPE.PUBLIC), + members: Match.Maybe([String]), + room: Match.Maybe(Match.Any), + owner: Match.Maybe(String), + })); - const team = Promise.await(Team.create(this.userId, { + const { name, type, members, room, owner } = this.bodyParams; + + const team = await Team.create(this.userId, { team: { name, type, @@ -65,26 +78,34 @@ API.v1.addRoute('teams.create', { authRequired: true }, { room, members, owner, - })); + }); return API.v1.success({ team }); }, }); -API.v1.addRoute('teams.convertToChannel', { authRequired: true }, { - post() { - check(this.bodyParams, Match.ObjectIncluding({ - teamId: Match.Maybe(String), - teamName: Match.Maybe(String), - roomsToRemove: Match.Maybe([String]), - })); - const { roomsToRemove, teamId, teamName } = this.bodyParams; +const getTeamByIdOrName = async (params: { teamId: string } | { teamName: string }): Promise => { + if ('teamId' in params && params.teamId) { + return Team.getOneById(params.teamId); + } + + if ('teamName' in params && params.teamName) { + return Team.getOneByName(params.teamName); + } + + return null; +}; - if (!teamId && !teamName) { - return API.v1.failure('missing-teamId-or-teamName'); +API.v1.addRoute('teams.convertToChannel', { authRequired: true }, { + async post() { + if (!isTeamsConvertToChannelProps(this.bodyParams)) { + return API.v1.failure('invalid-body-params', isTeamsConvertToChannelProps.errors?.map((e) => e.message).join('\n ')); } - const team = teamId ? Promise.await(Team.getOneById(teamId)) : Promise.await(Team.getOneByName(teamName)); + const { roomsToRemove = [] } = this.bodyParams; + + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { return API.v1.failure('team-does-not-exist'); } @@ -93,7 +114,7 @@ API.v1.addRoute('teams.convertToChannel', { authRequired: true }, { return API.v1.unauthorized(); } - const rooms: string[] = Promise.await(Team.getMatchingTeamRooms(team._id, roomsToRemove)); + const rooms = await Team.getMatchingTeamRooms(team._id, roomsToRemove); if (rooms.length) { rooms.forEach((room) => { @@ -101,7 +122,7 @@ API.v1.addRoute('teams.convertToChannel', { authRequired: true }, { }); } - Promise.all([ + await Promise.all([ Team.unsetTeamIdOfRooms(team._id), Team.removeAllMembersFromTeam(team._id), Team.deleteById(team._id), @@ -112,14 +133,21 @@ API.v1.addRoute('teams.convertToChannel', { authRequired: true }, { }); API.v1.addRoute('teams.addRooms', { authRequired: true }, { - post() { - const { rooms, teamId, teamName } = this.bodyParams; + async post() { + check(this.bodyParams, Match.OneOf( + Match.ObjectIncluding({ + teamId: String, + }), + Match.ObjectIncluding({ + teamName: String, + }), + )); - if (!teamId && !teamName) { - return API.v1.failure('missing-teamId-or-teamName'); - } + check(this.bodyParams, Match.ObjectIncluding({ + rooms: [String], + })); - const team = teamId ? Promise.await(Team.getOneById(teamId)) : Promise.await(Team.getOneByName(teamName)); + const team = await getTeamByIdOrName(this.bodyParams); if (!team) { return API.v1.failure('team-does-not-exist'); } @@ -128,17 +156,21 @@ API.v1.addRoute('teams.addRooms', { authRequired: true }, { return API.v1.unauthorized('error-no-permission-team-channel'); } - const validRooms = Promise.await(Team.addRooms(this.userId, rooms, team._id)); + const { rooms } = this.bodyParams; + + const validRooms = await Team.addRooms(this.userId, rooms, team._id); return API.v1.success({ rooms: validRooms }); }, }); API.v1.addRoute('teams.removeRoom', { authRequired: true }, { - post() { - const { roomId, teamId, teamName } = this.bodyParams; + async post() { + if (!isTeamsRemoveRoomProps(this.bodyParams)) { + return API.v1.failure('body-params-invalid', isTeamsRemoveRoomProps.errors?.map((error) => error.message).join('\n ')); + } - const team = teamId ? Promise.await(Team.getOneById(teamId)) : Promise.await(Team.getOneByName(teamName)); + const team = await getTeamByIdOrName(this.bodyParams); if (!team) { return API.v1.failure('team-does-not-exist'); } @@ -149,40 +181,64 @@ API.v1.addRoute('teams.removeRoom', { authRequired: true }, { const canRemoveAny = !!hasPermission(this.userId, 'view-all-team-channels', team.roomId); - const room = Promise.await(Team.removeRoom(this.userId, roomId, team._id, canRemoveAny)); + const { roomId } = this.bodyParams; + + const room = await Team.removeRoom(this.userId, roomId, team._id, canRemoveAny); return API.v1.success({ room }); }, }); API.v1.addRoute('teams.updateRoom', { authRequired: true }, { - post() { + async post() { + check(this.bodyParams, Match.ObjectIncluding({ + roomId: String, + isDefault: Boolean, + })); + const { roomId, isDefault } = this.bodyParams; - const team = Promise.await(Team.getOneByRoomId(roomId)); + const team = await Team.getOneByRoomId(roomId); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } if (!hasPermission(this.userId, 'edit-team-channel', team.roomId)) { return API.v1.unauthorized(); } const canUpdateAny = !!hasPermission(this.userId, 'view-all-team-channels', team.roomId); - const room = Promise.await(Team.updateRoom(this.userId, roomId, isDefault, canUpdateAny)); + const room = await Team.updateRoom(this.userId, roomId, isDefault, canUpdateAny); return API.v1.success({ room }); }, }); API.v1.addRoute('teams.listRooms', { authRequired: true }, { - get() { - const { teamId, teamName, filter, type } = this.queryParams; + async get() { + check(this.queryParams, Match.OneOf( + Match.ObjectIncluding({ + teamId: String, + }), + Match.ObjectIncluding({ + teamName: String, + }), + )); + + check(this.queryParams, Match.ObjectIncluding({ + filter: Match.Maybe(String), + type: Match.Maybe(String), + })); + + const { filter, type } = this.queryParams; const { offset, count } = this.getPaginationItems(); - const team = teamId ? Promise.await(Team.getOneById(teamId)) : Promise.await(Team.getOneByName(teamName)); + const team = await getTeamByIdOrName(this.queryParams); if (!team) { return API.v1.failure('team-does-not-exist'); } - const allowPrivateTeam = hasPermission(this.userId, 'view-all-teams', team.roomId); + const allowPrivateTeam: boolean = hasPermission(this.userId, 'view-all-teams', team.roomId); let getAllRooms = false; if (hasPermission(this.userId, 'view-all-team-channels', team.roomId)) { @@ -190,13 +246,13 @@ API.v1.addRoute('teams.listRooms', { authRequired: true }, { } const listFilter = { - name: filter, + name: filter ?? undefined, isDefault: type === 'autoJoin', getAllRooms, allowPrivateTeam, }; - const { records, total } = Promise.await(Team.listRooms(this.userId, team._id, listFilter, { offset, count })); + const { records, total } = await Team.listRooms(this.userId, team._id, listFilter, { offset, count }); return API.v1.success({ rooms: records, @@ -208,22 +264,37 @@ API.v1.addRoute('teams.listRooms', { authRequired: true }, { }); API.v1.addRoute('teams.listRoomsOfUser', { authRequired: true }, { - get() { + async get() { + check(this.queryParams, Match.OneOf( + Match.ObjectIncluding({ + teamId: String, + }), + Match.ObjectIncluding({ + teamName: String, + }), + )); + + check(this.queryParams, Match.ObjectIncluding({ + userId: String, + canUserDelete: Match.Maybe(Boolean), + })); + const { offset, count } = this.getPaginationItems(); - const { teamId, teamName, userId, canUserDelete = false } = this.queryParams; - const team = teamId ? Promise.await(Team.getOneById(teamId)) : Promise.await(Team.getOneByName(teamName)); + const team = await getTeamByIdOrName(this.queryParams); if (!team) { return API.v1.failure('team-does-not-exist'); } const allowPrivateTeam = hasPermission(this.userId, 'view-all-teams', team.roomId); + const { userId, canUserDelete } = this.queryParams; + if (!(this.userId === userId || hasPermission(this.userId, 'view-all-team-channels', team.roomId))) { return API.v1.unauthorized(); } - const { records, total } = Promise.await(Team.listRoomsOfUser(this.userId, team._id, userId, allowPrivateTeam, canUserDelete, { offset, count })); + const { records, total } = await Team.listRoomsOfUser(this.userId, team._id, userId, allowPrivateTeam, canUserDelete ?? false, { offset, count }); return API.v1.success({ rooms: records, @@ -235,26 +306,31 @@ API.v1.addRoute('teams.listRoomsOfUser', { authRequired: true }, { }); API.v1.addRoute('teams.members', { authRequired: true }, { - get() { + async get() { const { offset, count } = this.getPaginationItems(); + check(this.queryParams, Match.OneOf( + Match.ObjectIncluding({ + teamId: String, + }), + Match.ObjectIncluding({ + teamName: String, + }), + )); + check(this.queryParams, Match.ObjectIncluding({ - teamId: Match.Maybe(String), - teamName: Match.Maybe(String), status: Match.Maybe([String]), username: Match.Maybe(String), name: Match.Maybe(String), })); - const { teamId, teamName, status, username, name } = this.queryParams; - if (!teamId && !teamName) { - return API.v1.failure('missing-teamId-or-teamName'); - } + const { status, username, name } = this.queryParams; - const team = teamId ? Promise.await(Team.getOneById(teamId)) : Promise.await(Team.getOneByName(teamName)); + const team = await getTeamByIdOrName(this.queryParams); if (!team) { return API.v1.failure('team-does-not-exist'); } + const canSeeAllMembers = hasPermission(this.userId, 'view-all-teams', team.roomId); const query = { @@ -263,7 +339,7 @@ API.v1.addRoute('teams.members', { authRequired: true }, { status: status ? { $in: status } : undefined, } as FilterQuery; - const { records, total } = Promise.await(Team.members(this.userId, team._id, canSeeAllMembers, { offset, count }, query)); + const { records, total } = await Team.members(this.userId, team._id, canSeeAllMembers, { offset, count }, query); return API.v1.success({ members: records, @@ -275,10 +351,15 @@ API.v1.addRoute('teams.members', { authRequired: true }, { }); API.v1.addRoute('teams.addMembers', { authRequired: true }, { - post() { - const { teamId, teamName, members } = this.bodyParams; + async post() { + if (!isTeamsAddMembersProps(this.bodyParams)) { + return API.v1.failure('invalid-params'); + } + + const { bodyParams } = this; + const { members } = bodyParams; - const team = teamId ? Promise.await(Team.getOneById(teamId)) : Promise.await(Team.getOneByName(teamName)); + const team = await getTeamByIdOrName(this.bodyParams); if (!team) { return API.v1.failure('team-does-not-exist'); } @@ -287,17 +368,22 @@ API.v1.addRoute('teams.addMembers', { authRequired: true }, { return API.v1.unauthorized(); } - Promise.await(Team.addMembers(this.userId, team._id, members)); + await Team.addMembers(this.userId, team._id, members); return API.v1.success(); }, }); API.v1.addRoute('teams.updateMember', { authRequired: true }, { - post() { - const { teamId, teamName, member } = this.bodyParams; + async post() { + if (!isTeamsUpdateMemberProps(this.bodyParams)) { + return API.v1.failure('invalid-params', isTeamsUpdateMemberProps.errors?.map((e) => e.message).join('\n ')); + } + + const { bodyParams } = this; + const { member } = bodyParams; - const team = teamId ? Promise.await(Team.getOneById(teamId)) : Promise.await(Team.getOneByName(teamName)); + const team = await getTeamByIdOrName(this.bodyParams); if (!team) { return API.v1.failure('team-does-not-exist'); } @@ -306,17 +392,22 @@ API.v1.addRoute('teams.updateMember', { authRequired: true }, { return API.v1.unauthorized(); } - Promise.await(Team.updateMember(team._id, member)); + await Team.updateMember(team._id, member); return API.v1.success(); }, }); API.v1.addRoute('teams.removeMember', { authRequired: true }, { - post() { - const { teamId, teamName, userId, rooms } = this.bodyParams; + async post() { + if (!isTeamsRemoveMemberProps(this.bodyParams)) { + return API.v1.failure('invalid-params', isTeamsRemoveMemberProps.errors?.map((e) => e.message).join('\n ')); + } + + const { bodyParams } = this; + const { userId, rooms } = bodyParams; - const team = teamId ? Promise.await(Team.getOneById(teamId)) : Promise.await(Team.getOneByName(teamName)); + const team = await getTeamByIdOrName(this.bodyParams); if (!team) { return API.v1.failure('team-does-not-exist'); } @@ -330,12 +421,12 @@ API.v1.addRoute('teams.removeMember', { authRequired: true }, { return API.v1.failure('invalid-user'); } - if (!Promise.await(Team.removeMembers(this.userId, team._id, [{ userId }]))) { + if (!await Team.removeMembers(this.userId, team._id, [{ userId }])) { return API.v1.failure(); } if (rooms?.length) { - const roomsFromTeam: string[] = Promise.await(Team.getMatchingTeamRooms(team._id, rooms)); + const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms); roomsFromTeam.forEach((rid) => { removeUserFromRoom(rid, user, { @@ -348,17 +439,24 @@ API.v1.addRoute('teams.removeMember', { authRequired: true }, { }); API.v1.addRoute('teams.leave', { authRequired: true }, { - post() { - const { teamId, teamName, rooms } = this.bodyParams; + async post() { + if (!isTeamsLeaveProps(this.bodyParams)) { + return API.v1.failure('invalid-params', isTeamsLeaveProps.errors?.map((e) => e.message).join('\n ')); + } - const team = teamId ? Promise.await(Team.getOneById(teamId)) : Promise.await(Team.getOneByName(teamName)); + const { rooms = [] } = this.bodyParams; - Promise.await(Team.removeMembers(this.userId, team._id, [{ + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + await Team.removeMembers(this.userId, team._id, [{ userId: this.userId, - }])); + }]); - if (rooms?.length) { - const roomsFromTeam: string[] = Promise.await(Team.getMatchingTeamRooms(team._id, rooms)); + if (rooms.length) { + const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms); roomsFromTeam.forEach((rid) => { removeUserFromRoom(rid, this.user); @@ -370,17 +468,17 @@ API.v1.addRoute('teams.leave', { authRequired: true }, { }); API.v1.addRoute('teams.info', { authRequired: true }, { - get() { - const { teamId, teamName } = this.queryParams; - - if (!teamId && !teamName) { - return API.v1.failure('Provide either the "teamId" or "teamName"'); - } - - const teamInfo = teamId - ? Promise.await(Team.getInfoById(teamId)) - : Promise.await(Team.getInfoByName(teamName)); - + async get() { + check(this.queryParams, Match.OneOf( + Match.ObjectIncluding({ + teamId: String, + }), + Match.ObjectIncluding({ + teamName: String, + }), + )); + + const teamInfo = await getTeamByIdOrName(this.queryParams); if (!teamInfo) { return API.v1.failure('Team not found'); } @@ -390,27 +488,23 @@ API.v1.addRoute('teams.info', { authRequired: true }, { }); API.v1.addRoute('teams.delete', { authRequired: true }, { - post() { - const { teamId, teamName, roomsToRemove } = this.bodyParams; - - if (!teamId && !teamName) { - return API.v1.failure('Provide either the "teamId" or "teamName"'); - } + async post() { + const { roomsToRemove = [] } = this.bodyParams; - if (roomsToRemove && !Array.isArray(roomsToRemove)) { - return API.v1.failure('The list of rooms to remove is invalid.'); + if (!isTeamsDeleteProps(this.bodyParams)) { + return API.v1.failure('invalid-params', isTeamsDeleteProps.errors?.map((e) => e.message).join('\n ')); } - const team = teamId ? Promise.await(Team.getOneById(teamId)) : Promise.await(Team.getOneByName(teamName)); + const team = await getTeamByIdOrName(this.bodyParams); if (!team) { - return API.v1.failure('Team not found.'); + return API.v1.failure('team-does-not-exist'); } if (!hasPermission(this.userId, 'delete-team', team.roomId)) { return API.v1.unauthorized(); } - const rooms: string[] = Promise.await(Team.getMatchingTeamRooms(team._id, roomsToRemove)); + const rooms: string[] = await Team.getMatchingTeamRooms(team._id, roomsToRemove); // Remove the team's main room Meteor.call('eraseRoom', team.roomId); @@ -423,41 +517,41 @@ API.v1.addRoute('teams.delete', { authRequired: true }, { } // Move every other room back to the workspace - Promise.await(Team.unsetTeamIdOfRooms(team._id)); + await Team.unsetTeamIdOfRooms(team._id); // Delete all team memberships - Team.removeAllMembersFromTeam(teamId); + Team.removeAllMembersFromTeam(team._id); // And finally delete the team itself - Promise.await(Team.deleteById(team._id)); + await Team.deleteById(team._id); return API.v1.success(); }, }); API.v1.addRoute('teams.autocomplete', { authRequired: true }, { - get() { + async get() { + check(this.queryParams, Match.ObjectIncluding({ + name: String, + })); + const { name } = this.queryParams; - const teams = Promise.await(Team.autocomplete(this.userId, name)); + const teams = await Team.autocomplete(this.userId, name); return API.v1.success({ teams }); }, }); API.v1.addRoute('teams.update', { authRequired: true }, { - post() { - check(this.bodyParams, { - teamId: String, - data: { - name: Match.Maybe(String), - type: Match.Maybe(Number), - }, - }); + async post() { + if (!isTeamsUpdateProps(this.bodyParams)) { + return API.v1.failure('invalid-params', isTeamsUpdateProps.errors?.map((e) => e.message).join('\n ')); + } - const { teamId, data } = this.bodyParams; + const { data } = this.bodyParams; - const team = teamId && Promise.await(Team.getOneById(teamId)); + const team = await getTeamByIdOrName(this.bodyParams); if (!team) { return API.v1.failure('team-does-not-exist'); } @@ -466,7 +560,7 @@ API.v1.addRoute('teams.update', { authRequired: true }, { return API.v1.unauthorized(); } - Promise.await(Team.update(this.userId, teamId, { name: data.name, type: data.type })); + await Team.update(this.userId, team._id, data); return API.v1.success(); }, diff --git a/app/api/server/v1/users.js b/app/api/server/v1/users.js index 714fc5e9265ff..a6c514a7c8eca 100644 --- a/app/api/server/v1/users.js +++ b/app/api/server/v1/users.js @@ -27,6 +27,7 @@ import { setUserStatus } from '../../../../imports/users-presence/server/activeU import { resetTOTP } from '../../../2fa/server/functions/resetTOTP'; import { Team } from '../../../../server/sdk'; + API.v1.addRoute('users.create', { authRequired: true }, { post() { check(this.bodyParams, { @@ -283,7 +284,11 @@ API.v1.addRoute('users.list', { authRequired: true }, { }, }); -API.v1.addRoute('users.register', { authRequired: false }, { +API.v1.addRoute('users.register', { authRequired: false, + rateLimiterOptions: { + numRequestsAllowed: settings.get('Rate_Limiter_Limit_RegisterUser'), + intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), + } }, { post() { if (this.userId) { return API.v1.failure('Logged in users can not register again.'); @@ -593,7 +598,7 @@ API.v1.addRoute('users.setPreferences', { authRequired: true }, { unreadAlert: Match.Maybe(Boolean), notificationsSoundVolume: Match.Maybe(Number), desktopNotifications: Match.Maybe(String), - mobileNotifications: Match.Maybe(String), + pushNotifications: Match.Maybe(String), enableAutoAway: Match.Maybe(Boolean), highlights: Match.Maybe(Array), desktopNotificationRequireInteraction: Match.Maybe(Boolean), @@ -944,3 +949,9 @@ API.v1.addRoute('users.logout', { authRequired: true }, { }); }, }); + +settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { + const userRegisterRoute = '/api/v1/users.registerpost'; + + API.v1.updateRateLimiterDictionaryForRoute(userRegisterRoute, value); +}); diff --git a/app/apps/client/orchestrator.js b/app/apps/client/orchestrator.js index 8b73afc4350ab..4c30b31460cf0 100644 --- a/app/apps/client/orchestrator.js +++ b/app/apps/client/orchestrator.js @@ -69,11 +69,12 @@ class AppClientOrchestrator { getAppsFromMarketplace = async () => { const appsOverviews = await APIClient.get('apps', { marketplace: 'true' }); - return appsOverviews.map(({ latest, price, pricingPlans, purchaseType }) => ({ + return appsOverviews.map(({ latest, price, pricingPlans, purchaseType, isEnterpriseOnly }) => ({ ...latest, price, pricingPlans, purchaseType, + isEnterpriseOnly, })); } diff --git a/app/apps/server/bridges/commands.ts b/app/apps/server/bridges/commands.ts index aed3d2cf72b26..fe57d2ff8bad8 100644 --- a/app/apps/server/bridges/commands.ts +++ b/app/apps/server/bridges/commands.ts @@ -1,5 +1,4 @@ import { Meteor } from 'meteor/meteor'; -import { Promise as MeteorPromise } from 'meteor/promise'; import { SlashCommandContext, ISlashCommand, ISlashCommandPreviewItem } from '@rocket.chat/apps-engine/definition/slashcommands'; import { CommandBridge } from '@rocket.chat/apps-engine/server/bridges/CommandBridge'; @@ -167,7 +166,7 @@ export class AppCommandsBridge extends CommandBridge { triggerId, ); - MeteorPromise.await(this.orch.getManager()?.getCommandManager().executeCommand(command, context)); + Promise.await(this.orch.getManager()?.getCommandManager().executeCommand(command, context)); } private _appCommandPreviewer(command: string, parameters: any, message: IMessage): any { @@ -182,7 +181,7 @@ export class AppCommandsBridge extends CommandBridge { Object.freeze(params), threadId, ); - return MeteorPromise.await(this.orch.getManager()?.getCommandManager().getPreviews(command, context)); + return Promise.await(this.orch.getManager()?.getCommandManager().getPreviews(command, context)); } private async _appCommandPreviewExecutor(command: string, parameters: any, message: IMessage, preview: ISlashCommandPreviewItem, triggerId: string): Promise { @@ -199,6 +198,6 @@ export class AppCommandsBridge extends CommandBridge { triggerId, ); - MeteorPromise.await(this.orch.getManager()?.getCommandManager().executePreview(command, preview, context)); + Promise.await(this.orch.getManager()?.getCommandManager().executePreview(command, preview, context)); } } diff --git a/app/apps/server/bridges/internal.ts b/app/apps/server/bridges/internal.ts index 0d09646f38938..154adbdeb1040 100644 --- a/app/apps/server/bridges/internal.ts +++ b/app/apps/server/bridges/internal.ts @@ -2,8 +2,9 @@ import { InternalBridge } from '@rocket.chat/apps-engine/server/bridges/Internal import { ISetting } from '@rocket.chat/apps-engine/definition/settings'; import { AppServerOrchestrator } from '../orchestrator'; -import { Subscriptions, Settings } from '../../../models/server'; +import { Subscriptions } from '../../../models/server'; import { ISubscription } from '../../../../definition/ISubscription'; +import { Settings } from '../../../models/server/raw'; export class AppInternalBridge extends InternalBridge { // eslint-disable-next-line no-empty-function @@ -30,7 +31,7 @@ export class AppInternalBridge extends InternalBridge { } protected async getWorkspacePublicKey(): Promise { - const publicKeySetting = Settings.findById('Cloud_Workspace_PublicKey').fetch()[0]; + const publicKeySetting = await Settings.findOneById('Cloud_Workspace_PublicKey'); return this.orch.getConverters()?.get('settings').convertToApp(publicKeySetting); } diff --git a/app/apps/server/bridges/scheduler.ts b/app/apps/server/bridges/scheduler.ts index 698931a25be22..721c3a8c8aa07 100644 --- a/app/apps/server/bridges/scheduler.ts +++ b/app/apps/server/bridges/scheduler.ts @@ -198,7 +198,7 @@ export class AppSchedulerBridge extends SchedulerBridge { } } - private async startScheduler(): Promise { + public async startScheduler(): Promise { if (!this.isConnected) { await this.scheduler.start(); this.isConnected = true; diff --git a/app/apps/server/bridges/settings.ts b/app/apps/server/bridges/settings.ts index ad1c234b0af27..ba0626434ff92 100644 --- a/app/apps/server/bridges/settings.ts +++ b/app/apps/server/bridges/settings.ts @@ -1,7 +1,7 @@ import { ISetting } from '@rocket.chat/apps-engine/definition/settings'; import { ServerSettingBridge } from '@rocket.chat/apps-engine/server/bridges/ServerSettingBridge'; -import { Settings } from '../../../models/server'; +import { Settings } from '../../../models/server/raw'; import { AppServerOrchestrator } from '../orchestrator'; export class AppSettingBridge extends ServerSettingBridge { @@ -13,9 +13,8 @@ export class AppSettingBridge extends ServerSettingBridge { protected async getAll(appId: string): Promise> { this.orch.debugLog(`The App ${ appId } is getting all the settings.`); - return Settings.find({ secret: false }) - .fetch() - .map((s: ISetting) => this.orch.getConverters()?.get('settings').convertToApp(s)); + const settings = await Settings.find({ secret: false }).toArray(); + return settings.map((s) => this.orch.getConverters()?.get('settings').convertToApp(s)); } protected async getOneById(id: string, appId: string): Promise { @@ -46,8 +45,8 @@ export class AppSettingBridge extends ServerSettingBridge { protected async isReadableById(id: string, appId: string): Promise { this.orch.debugLog(`The App ${ appId } is checking if they can read the setting ${ id }.`); - - return !Settings.findOneById(id).secret; + const setting = await Settings.findOneById(id); + return Boolean(setting && !setting.secret); } protected async updateOne(setting: ISetting & { id: string }, appId: string): Promise { diff --git a/app/apps/server/communication/rest.js b/app/apps/server/communication/rest.js index 6d84f3797b95b..1386ba39cfa89 100644 --- a/app/apps/server/communication/rest.js +++ b/app/apps/server/communication/rest.js @@ -6,9 +6,10 @@ import { getUploadFormData } from '../../../api/server/lib/getUploadFormData'; import { getWorkspaceAccessToken, getUserCloudAccessToken } from '../../../cloud/server'; import { settings } from '../../../settings/server'; import { Info } from '../../../utils'; -import { Settings, Users } from '../../../models/server'; +import { Users } from '../../../models/server'; import { Apps } from '../orchestrator'; import { formatAppInstanceForRest } from '../../lib/misc/formatAppInstanceForRest'; +import { Settings } from '../../../models/server/raw'; const appsEngineVersionForMarketplace = Info.marketplaceApiVersion.replace(/-.*/g, ''); const getDefaultHeaders = () => ({ @@ -67,7 +68,7 @@ export class AppsRestApi { // Gets the Apps from the marketplace if (this.queryParams.marketplace) { const headers = getDefaultHeaders(); - const token = getWorkspaceAccessToken(); + const token = Promise.await(getWorkspaceAccessToken()); if (token) { headers.Authorization = `Bearer ${ token }`; } @@ -91,7 +92,7 @@ export class AppsRestApi { if (this.queryParams.categories) { const headers = getDefaultHeaders(); - const token = getWorkspaceAccessToken(); + const token = Promise.await(getWorkspaceAccessToken()); if (token) { headers.Authorization = `Bearer ${ token }`; } @@ -187,7 +188,7 @@ export class AppsRestApi { }); const marketplacePromise = new Promise((resolve, reject) => { - const token = getWorkspaceAccessToken(); + const token = Promise.await(getWorkspaceAccessToken()); HTTP.get(`${ baseUrl }/v1/apps/${ this.bodyParams.appId }?appVersion=${ this.bodyParams.version }`, { headers: { @@ -307,7 +308,7 @@ export class AppsRestApi { const baseUrl = orchestrator.getMarketplaceUrl(); const headers = {}; - const token = getWorkspaceAccessToken(); + const token = Promise.await(getWorkspaceAccessToken()); if (token) { headers.Authorization = `Bearer ${ token }`; } @@ -337,7 +338,7 @@ export class AppsRestApi { const baseUrl = orchestrator.getMarketplaceUrl(); const headers = {}; // DO NOT ATTACH THE FRAMEWORK/ENGINE VERSION HERE. - const token = getWorkspaceAccessToken(); + const token = Promise.await(getWorkspaceAccessToken()); if (token) { headers.Authorization = `Bearer ${ token }`; } @@ -363,7 +364,7 @@ export class AppsRestApi { const baseUrl = orchestrator.getMarketplaceUrl(); const headers = getDefaultHeaders(); - const token = getWorkspaceAccessToken(); + const token = Promise.await(getWorkspaceAccessToken()); if (token) { headers.Authorization = `Bearer ${ token }`; } @@ -507,12 +508,12 @@ export class AppsRestApi { const baseUrl = orchestrator.getMarketplaceUrl(); const headers = getDefaultHeaders(); - const token = getWorkspaceAccessToken(); + const token = Promise.await(getWorkspaceAccessToken()); if (token) { headers.Authorization = `Bearer ${ token }`; } - const [workspaceIdSetting] = Settings.findById('Cloud_Workspace_Id').fetch(); + const workspaceIdSetting = Promise.await(Settings.findOneById('Cloud_Workspace_Id')); let result; try { diff --git a/app/apps/server/communication/websockets.js b/app/apps/server/communication/websockets.js index 8be3edc38e0e0..dd8da2b64c89e 100644 --- a/app/apps/server/communication/websockets.js +++ b/app/apps/server/communication/websockets.js @@ -71,7 +71,8 @@ export class AppServerListener { const appPackage = await this.orch.getAppSourceStorage().fetch(storageItem); - await this.orch.getManager().update(appPackage); + await this.orch.getManager().updateLocal(storageItem, appPackage); + this.clientStreamer.emitWithoutBroadcast(AppEvents.APP_UPDATED, appId); } diff --git a/app/apps/server/converters/settings.js b/app/apps/server/converters/settings.js index 82ffcd2b2f0f1..bc5949bc7ccd1 100644 --- a/app/apps/server/converters/settings.js +++ b/app/apps/server/converters/settings.js @@ -1,14 +1,14 @@ import { SettingType } from '@rocket.chat/apps-engine/definition/settings'; -import { Settings } from '../../../models'; +import { Settings } from '../../../models/server/raw'; export class AppSettingsConverter { constructor(orch) { this.orch = orch; } - convertById(settingId) { - const setting = Settings.findOneNotHiddenById(settingId); + async convertById(settingId) { + const setting = await Settings.findOneNotHiddenById(settingId); return this.convertToApp(setting); } diff --git a/app/apps/server/converters/uploads.js b/app/apps/server/converters/uploads.js index d95f5d10067f4..efbda7ae5fd1b 100644 --- a/app/apps/server/converters/uploads.js +++ b/app/apps/server/converters/uploads.js @@ -1,5 +1,5 @@ import { transformMappedData } from '../../lib/misc/transformMappedData'; -import Uploads from '../../../models/server/models/Uploads'; +import { Uploads } from '../../../models/server/raw'; export class AppUploadsConverter { constructor(orch) { @@ -7,7 +7,7 @@ export class AppUploadsConverter { } convertById(id) { - const upload = Uploads.findOneById(id); + const upload = Promise.await(Uploads.findOneById(id)); return this.convertToApp(upload); } diff --git a/app/apps/server/cron.js b/app/apps/server/cron.js index 3201612ea6b60..d38eebe060b29 100644 --- a/app/apps/server/cron.js +++ b/app/apps/server/cron.js @@ -6,8 +6,9 @@ import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; import { Apps } from './orchestrator'; import { getWorkspaceAccessToken } from '../../cloud/server'; -import { Settings, Users } from '../../models/server'; +import { Users } from '../../models/server'; import { sendMessagesToAdmins } from '../../../server/lib/sendMessagesToAdmins'; +import { Settings } from '../../models/server/raw'; const notifyAdminsAboutInvalidApps = Meteor.bindEnvironment(function _notifyAdminsAboutInvalidApps(apps) { @@ -27,7 +28,7 @@ const notifyAdminsAboutInvalidApps = Meteor.bindEnvironment(function _notifyAdmi const rocketCatMessage = 'There is one or more apps in an invalid state. Go to Administration > Apps to review.'; const link = '/admin/apps'; - sendMessagesToAdmins({ + Promise.await(sendMessagesToAdmins({ msgs: ({ adminUser }) => ({ msg: `*${ TAPi18n.__(title, adminUser.language) }*\n${ TAPi18n.__(rocketCatMessage, adminUser.language) }` }), banners: ({ adminUser }) => { Users.removeBannerById(adminUser._id, { id }); @@ -41,7 +42,7 @@ const notifyAdminsAboutInvalidApps = Meteor.bindEnvironment(function _notifyAdmi link, }]; }, - }); + })); return apps; }); @@ -59,19 +60,19 @@ const notifyAdminsAboutRenewedApps = Meteor.bindEnvironment(function _notifyAdmi const rocketCatMessage = 'There is one or more disabled apps with valid licenses. Go to Administration > Apps to review.'; - sendMessagesToAdmins({ + Promise.await(sendMessagesToAdmins({ msgs: ({ adminUser }) => ({ msg: `${ TAPi18n.__(rocketCatMessage, adminUser.language) }` }), - }); + })); }); export const appsUpdateMarketplaceInfo = Meteor.bindEnvironment(function _appsUpdateMarketplaceInfo() { - const token = getWorkspaceAccessToken(); + const token = Promise.await(getWorkspaceAccessToken()); const baseUrl = Apps.getMarketplaceUrl(); - const [workspaceIdSetting] = Settings.findById('Cloud_Workspace_Id').fetch(); + const workspaceIdSetting = Promise.await(Settings.getValueById('Cloud_Workspace_Id')); const currentSeats = Users.getActiveLocalUserCount(); - const fullUrl = `${ baseUrl }/v1/workspaces/${ workspaceIdSetting.value }/apps?seats=${ currentSeats }`; + const fullUrl = `${ baseUrl }/v1/workspaces/${ workspaceIdSetting }/apps?seats=${ currentSeats }`; const options = { headers: { Authorization: `Bearer ${ token }`, diff --git a/app/apps/server/orchestrator.js b/app/apps/server/orchestrator.js index a68d24197f56b..81eaad8110d8b 100644 --- a/app/apps/server/orchestrator.js +++ b/app/apps/server/orchestrator.js @@ -158,7 +158,8 @@ export class AppServerOrchestrator { return this._manager.load() .then((affs) => console.log(`Loaded the Apps Framework and loaded a total of ${ affs.length } Apps!`)) - .catch((err) => console.warn('Failed to load the Apps Framework and Apps!', err)); + .catch((err) => console.warn('Failed to load the Apps Framework and Apps!', err)) + .then(() => this.getBridges().getSchedulerBridge().startScheduler()); } async unload() { diff --git a/app/apps/server/tests/messages.tests.js b/app/apps/server/tests/messages.tests.js index 9dee0c68bcdae..9e4919731e8c1 100644 --- a/app/apps/server/tests/messages.tests.js +++ b/app/apps/server/tests/messages.tests.js @@ -1,7 +1,5 @@ -/* eslint-env mocha */ -import 'babel-polyfill'; import mock from 'mock-require'; -import chai from 'chai'; +import { expect } from 'chai'; import { AppServerOrchestratorMock } from './mocks/orchestrator.mock'; import { appMessageMock, appMessageInvalidRoomMock } from './mocks/data/messages.data'; @@ -9,10 +7,6 @@ import { MessagesMock } from './mocks/models/Messages.mock'; import { RoomsMock } from './mocks/models/Rooms.mock'; import { UsersMock } from './mocks/models/Users.mock'; -chai.use(require('chai-datetime')); - -const { expect } = chai; - mock('../../../models', './mocks/models'); mock('meteor/random', { id: () => 1, diff --git a/app/authentication/server/lib/restrictLoginAttempts.ts b/app/authentication/server/lib/restrictLoginAttempts.ts index d3ca8f78e5ca7..2b63656d87e13 100644 --- a/app/authentication/server/lib/restrictLoginAttempts.ts +++ b/app/authentication/server/lib/restrictLoginAttempts.ts @@ -1,12 +1,10 @@ import moment from 'moment'; import { ILoginAttempt } from '../ILoginAttempt'; -import { ServerEvents, Users, Rooms } from '../../../models/server/raw'; -import { IServerEventType } from '../../../../definition/IServerEvent'; -import { IUser } from '../../../../definition/IUser'; +import { ServerEvents, Users, Rooms, Sessions } from '../../../models/server/raw'; +import { IServerEventType, IServerEvent } from '../../../../definition/IServerEvent'; import { settings } from '../../../settings/server'; import { addMinutesToADate } from '../../../../lib/utils/addMinutesToADate'; -import Sessions from '../../../models/server/raw/Sessions'; import { getClientAddress } from '../../../../server/lib/getClientAddress'; import { sendMessage } from '../../../lib/server/functions'; import { Logger } from '../../../logger/server'; @@ -52,7 +50,7 @@ export const isValidLoginAttemptByIp = async (ip: string): Promise => { return true; } - const lastLogin = await Sessions.findLastLoginByIp(ip) as {loginAt?: Date} | undefined; + const lastLogin = await Sessions.findLastLoginByIp(ip); let failedAttemptsSinceLastLogin; if (!lastLogin || !lastLogin.loginAt) { @@ -92,7 +90,7 @@ export const isValidAttemptByUser = async (login: ILoginAttempt): Promise => { - const user: Partial = { + const user: IServerEvent['u'] = { _id: login.user?._id, username: login.user?.username || login.methodArguments[0].user?.username, }; @@ -142,10 +140,15 @@ export const saveFailedLoginAttempts = async (login: ILoginAttempt): Promise => { + const user: IServerEvent['u'] = { + _id: login.user?._id, + username: login.user?.username || login.methodArguments[0].user?.username, + }; + await ServerEvents.insertOne({ ip: getClientAddress(login.connection), t: IServerEventType.LOGIN, ts: new Date(), - u: login.user, + u: user, }); }; diff --git a/app/authentication/server/startup/index.js b/app/authentication/server/startup/index.js index ff9e6b02f431c..3f3b5acc5f8d6 100644 --- a/app/authentication/server/startup/index.js +++ b/app/authentication/server/startup/index.js @@ -8,8 +8,8 @@ import { escapeRegExp, escapeHTML } from '@rocket.chat/string-helpers'; import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; import { callbacks } from '../../../callbacks/server'; -import { Roles, Users, Settings } from '../../../models/server'; -import { Users as UsersRaw } from '../../../models/server/raw'; +import { Users, Settings } from '../../../models/server'; +import { Roles, Users as UsersRaw } from '../../../models/server/raw'; import { addUserRoles } from '../../../authorization/server'; import { getAvatarSuggestionForUser } from '../../../lib/server/functions'; import { @@ -186,8 +186,8 @@ Accounts.onCreateUser(function(options, user = {}) { if (!user.active) { const destinations = []; - - Roles.findUsersInRole('admin').forEach((adminUser) => { + const usersInRole = Promise.await(Roles.findUsersInRole('admin')); + Promise.await(usersInRole.toArray()).forEach((adminUser) => { if (Array.isArray(adminUser.emails)) { adminUser.emails.forEach((email) => { destinations.push(`${ adminUser.name }<${ email.address }>`); diff --git a/app/authorization/client/hasPermission.ts b/app/authorization/client/hasPermission.ts index 744903bc50629..23a0aeeca8a61 100644 --- a/app/authorization/client/hasPermission.ts +++ b/app/authorization/client/hasPermission.ts @@ -7,11 +7,11 @@ import { IUser } from '../../../definition/IUser'; import { IRole } from '../../../definition/IRole'; import { IPermission } from '../../../definition/IPermission'; -const isValidScope = (scope: IRole['scope']): scope is keyof typeof Models => +const isValidScope = (scope: IRole['scope']): boolean => typeof scope === 'string' && scope in Models; const createPermissionValidator = (quantifier: (predicate: (permissionId: IPermission['_id']) => boolean) => boolean) => - (permissionIds: IPermission['_id'][], scope: IRole['scope'], userId: IUser['_id']): boolean => { + (permissionIds: IPermission['_id'][], scope: string | undefined, userId: IUser['_id']): boolean => { const user: IUser | null = Models.Users.findOneById(userId, { fields: { roles: 1 } }); const checkEachPermission = quantifier.bind(permissionIds); @@ -34,7 +34,7 @@ const createPermissionValidator = (quantifier: (predicate: (permissionId: IPermi return false; } - const model = Models[roleScope]; + const model = Models[roleScope as keyof typeof Models]; return model.isUserInRole && model.isUserInRole(userId, roleName, scope); }); }); @@ -46,8 +46,8 @@ const all = createPermissionValidator(Array.prototype.every); const validatePermissions = ( permissions: IPermission['_id'] | IPermission['_id'][], - scope: IRole['scope'], - predicate: (permissionIds: IPermission['_id'][], scope: IRole['scope'], userId: IUser['_id']) => boolean, + scope: string | undefined, + predicate: (permissionIds: IPermission['_id'][], scope: string | undefined, userId: IUser['_id']) => boolean, userId?: IUser['_id'] | null, ): boolean => { userId = userId ?? Meteor.userId(); @@ -65,17 +65,17 @@ const validatePermissions = ( export const hasAllPermission = ( permissions: IPermission['_id'] | IPermission['_id'][], - scope?: IRole['scope'], + scope?: string, ): boolean => validatePermissions(permissions, scope, all); export const hasAtLeastOnePermission = ( permissions: IPermission['_id'] | IPermission['_id'][], - scope?: IRole['scope'], + scope?: string, ): boolean => validatePermissions(permissions, scope, atLeastOne); export const userHasAllPermission = ( permissions: IPermission['_id'] | IPermission['_id'][], - scope?: IRole['scope'], + scope?: string, userId?: IUser['_id'] | null, ): boolean => validatePermissions(permissions, scope, all, userId); diff --git a/app/authorization/server/functions/addUserRoles.js b/app/authorization/server/functions/addUserRoles.ts similarity index 54% rename from app/authorization/server/functions/addUserRoles.js rename to app/authorization/server/functions/addUserRoles.ts index 46302e81eb8d7..dda983ff6a3cb 100644 --- a/app/authorization/server/functions/addUserRoles.js +++ b/app/authorization/server/functions/addUserRoles.ts @@ -2,9 +2,11 @@ import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; import { getRoles } from './getRoles'; -import { Users, Roles } from '../../../models'; +import { Users } from '../../../models/server'; +import { IRole, IUser } from '../../../../definition/IUser'; +import { Roles } from '../../../models/server/raw'; -export const addUserRoles = (userId, roleNames, scope) => { +export const addUserRoles = (userId: IUser['_id'], roleNames: IRole['name'][], scope?: string): boolean => { if (!userId || !roleNames) { return false; } @@ -16,17 +18,19 @@ export const addUserRoles = (userId, roleNames, scope) => { }); } - roleNames = [].concat(roleNames); + if (!Array.isArray(roleNames)) { // TODO: remove this check + roleNames = [roleNames]; + } + const existingRoleNames = _.pluck(getRoles(), '_id'); const invalidRoleNames = _.difference(roleNames, existingRoleNames); if (!_.isEmpty(invalidRoleNames)) { for (const role of invalidRoleNames) { - Roles.createOrUpdate(role); + Promise.await(Roles.createOrUpdate(role)); } } - Roles.addUserRoles(userId, roleNames, scope); - + Promise.await(Roles.addUserRoles(userId, roleNames, scope)); return true; }; diff --git a/app/authorization/server/functions/canAccessRoom.ts b/app/authorization/server/functions/canAccessRoom.ts index 5a943ec031a66..d232f890af2ea 100644 --- a/app/authorization/server/functions/canAccessRoom.ts +++ b/app/authorization/server/functions/canAccessRoom.ts @@ -1,5 +1,3 @@ -import { Promise } from 'meteor/promise'; - import { Authorization } from '../../../../server/sdk'; import { IAuthorization } from '../../../../server/sdk/types/IAuthorization'; diff --git a/app/authorization/server/functions/getRoles.js b/app/authorization/server/functions/getRoles.js deleted file mode 100644 index 9d20c72d29a94..0000000000000 --- a/app/authorization/server/functions/getRoles.js +++ /dev/null @@ -1,3 +0,0 @@ -import { Roles } from '../../../models'; - -export const getRoles = () => Roles.find().fetch(); diff --git a/app/authorization/server/functions/getRoles.ts b/app/authorization/server/functions/getRoles.ts new file mode 100644 index 0000000000000..27de1000bb0a5 --- /dev/null +++ b/app/authorization/server/functions/getRoles.ts @@ -0,0 +1,4 @@ +import { IRole } from '../../../../definition/IUser'; +import { Roles } from '../../../models/server/raw'; + +export const getRoles = (): IRole[] => Promise.await(Roles.find().toArray()); diff --git a/app/authorization/server/functions/getUsersInRole.js b/app/authorization/server/functions/getUsersInRole.js deleted file mode 100644 index 27c369acf9ffd..0000000000000 --- a/app/authorization/server/functions/getUsersInRole.js +++ /dev/null @@ -1,3 +0,0 @@ -import { Roles } from '../../../models'; - -export const getUsersInRole = (roleName, scope, options) => Roles.findUsersInRole(roleName, scope, options); diff --git a/app/authorization/server/functions/getUsersInRole.ts b/app/authorization/server/functions/getUsersInRole.ts new file mode 100644 index 0000000000000..740431af3f068 --- /dev/null +++ b/app/authorization/server/functions/getUsersInRole.ts @@ -0,0 +1,14 @@ + + +import { Cursor, FindOneOptions, WithoutProjection } from 'mongodb'; + +import { IRole, IUser } from '../../../../definition/IUser'; +import { Roles } from '../../../models/server/raw'; + +export function getUsersInRole(name: IRole['name'], scope?: string): Promise>; + +export function getUsersInRole(name: IRole['name'], scope: string | undefined, options: WithoutProjection>): Promise>; + +export function getUsersInRole

(name: IRole['name'], scope: string | undefined, options: FindOneOptions

): Promise>; + +export function getUsersInRole

(name: IRole['name'], scope: string | undefined, options?: any | undefined): Promise> { return Roles.findUsersInRole(name, scope, options); } diff --git a/app/authorization/server/functions/removeUserFromRoles.js b/app/authorization/server/functions/removeUserFromRoles.js index b08c2778addba..a55d722bb891b 100644 --- a/app/authorization/server/functions/removeUserFromRoles.js +++ b/app/authorization/server/functions/removeUserFromRoles.js @@ -2,7 +2,8 @@ import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; import { getRoles } from './getRoles'; -import { Users, Roles } from '../../../models'; +import { Users } from '../../../models/server'; +import { Roles } from '../../../models/server/raw'; export const removeUserFromRoles = (userId, roleNames, scope) => { if (!userId || !roleNames) { @@ -27,7 +28,7 @@ export const removeUserFromRoles = (userId, roleNames, scope) => { }); } - Roles.removeUserRoles(userId, roleNames, scope); + Promise.await(Roles.removeUserRoles(userId, roleNames, scope)); return true; }; diff --git a/app/authorization/server/functions/upsertPermissions.js b/app/authorization/server/functions/upsertPermissions.ts similarity index 87% rename from app/authorization/server/functions/upsertPermissions.js rename to app/authorization/server/functions/upsertPermissions.ts index 2a7a052564f3b..571912b7e5699 100644 --- a/app/authorization/server/functions/upsertPermissions.js +++ b/app/authorization/server/functions/upsertPermissions.ts @@ -1,11 +1,11 @@ /* eslint no-multi-spaces: 0 */ -import Roles from '../../../models/server/models/Roles'; -import Permissions from '../../../models/server/models/Permissions'; -import Settings from '../../../models/server/models/Settings'; import { settings } from '../../../settings/server'; import { getSettingPermissionId, CONSTANTS } from '../../lib'; +import { Permissions, Roles, Settings } from '../../../models/server/raw'; +import { IPermission } from '../../../../definition/IPermission'; +import { ISetting } from '../../../../definition/ISetting'; -export const upsertPermissions = () => { +export const upsertPermissions = async (): Promise => { // Note: // 1.if we need to create a role that can only edit channel message, but not edit group message // then we can define edit--message instead of edit-message @@ -94,6 +94,7 @@ export const upsertPermissions = () => { { _id: 'create-invite-links', roles: ['admin', 'owner', 'moderator'] }, { _id: 'view-l-room', roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'] }, { _id: 'view-livechat-manager', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { _id: 'view-omnichannel-contact-center', roles: ['livechat-manager', 'livechat-agent', 'livechat-monitor', 'admin'] }, { _id: 'edit-omnichannel-contact', roles: ['livechat-manager', 'livechat-agent', 'admin'] }, { _id: 'view-livechat-rooms', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, { _id: 'close-livechat-room', roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'] }, @@ -157,11 +158,13 @@ export const upsertPermissions = () => { { _id: 'access-mailer', roles: ['admin'] }, { _id: 'pin-message', roles: ['owner', 'moderator', 'admin'] }, { _id: 'snippet-message', roles: ['owner', 'moderator', 'admin'] }, + { _id: 'mobile-upload-file', roles: ['user', 'admin'] }, + { _id: 'mobile-download-file', roles: ['user', 'admin'] }, ]; - for (const permission of permissions) { - Permissions.create(permission._id, permission.roles); + for await (const permission of permissions) { + await Permissions.create(permission._id, permission.roles); } const defaultRoles = [ @@ -178,29 +181,30 @@ export const upsertPermissions = () => { { name: 'livechat-manager', scope: 'Users', description: 'Livechat Manager' }, ]; - for (const role of defaultRoles) { - Roles.createOrUpdate(role.name, role.scope, role.description, true, false); + for await (const role of defaultRoles) { + await Roles.createOrUpdate(role.name, role.scope as 'Users' | 'Subscriptions', role.description, true, false); } - const getPreviousPermissions = function(settingId) { - const previousSettingPermissions = {}; + const getPreviousPermissions = async function(settingId?: string): Promise> { + const previousSettingPermissions: { + [key: string]: IPermission; + } = {}; - const selector = { level: CONSTANTS.SETTINGS_LEVEL }; - if (settingId) { - selector.settingId = settingId; - } + const selector = { level: 'settings' as const, ...settingId && { settingId } }; - Permissions.find(selector).forEach( - function(permission) { + await Permissions.find(selector).forEach( + function(permission: IPermission) { previousSettingPermissions[permission._id] = permission; }); return previousSettingPermissions; }; - const createSettingPermission = function(setting, previousSettingPermissions) { + const createSettingPermission = async function(setting: ISetting, previousSettingPermissions: { + [key: string]: IPermission; + }): Promise { const permissionId = getSettingPermissionId(setting._id); - const permission = { - level: CONSTANTS.SETTINGS_LEVEL, + const permission: Omit = { + level: CONSTANTS.SETTINGS_LEVEL as 'settings' | undefined, // copy those setting-properties which are needed to properly publish the setting-based permissions settingId: setting._id, group: setting.group, @@ -219,19 +223,19 @@ export const upsertPermissions = () => { permission.sectionPermissionId = getSettingPermissionId(setting.section); } - const existent = Permissions.findOne({ + const existent = await Permissions.findOne({ _id: permissionId, ...permission, }, { fields: { _id: 1 } }); if (!existent) { try { - Permissions.upsert({ _id: permissionId }, { $set: permission }); + await Permissions.update({ _id: permissionId }, { $set: permission }, { upsert: true }); } catch (e) { if (!e.message.includes('E11000')) { // E11000 refers to a MongoDB error that can occur when using unique indexes for upserts // https://docs.mongodb.com/manual/reference/method/db.collection.update/#use-unique-indexes - Permissions.upsert({ _id: permissionId }, { $set: permission }); + await Permissions.update({ _id: permissionId }, { $set: permission }, { upsert: true }); } } } @@ -239,17 +243,17 @@ export const upsertPermissions = () => { delete previousSettingPermissions[permissionId]; }; - const createPermissionsForExistingSettings = function() { - const previousSettingPermissions = getPreviousPermissions(); + const createPermissionsForExistingSettings = async function(): Promise { + const previousSettingPermissions = await getPreviousPermissions(); - Settings.findNotHidden().fetch().forEach((setting) => { + (await Settings.findNotHidden().toArray()).forEach((setting) => { createSettingPermission(setting, previousSettingPermissions); }); // remove permissions for non-existent settings - for (const obsoletePermission in previousSettingPermissions) { + for await (const obsoletePermission of Object.keys(previousSettingPermissions)) { if (previousSettingPermissions.hasOwnProperty(obsoletePermission)) { - Permissions.remove({ _id: obsoletePermission }); + await Permissions.deleteOne({ _id: obsoletePermission }); } } }; @@ -258,9 +262,9 @@ export const upsertPermissions = () => { createPermissionsForExistingSettings(); // register a callback for settings for be create in higher-level-packages - settings.on('*', function([settingId]) { - const previousSettingPermissions = getPreviousPermissions(settingId); - const setting = Settings.findOneById(settingId); + settings.on('*', async function([settingId]) { + const previousSettingPermissions = await getPreviousPermissions(settingId); + const setting = await Settings.findOneById(settingId); if (setting) { if (!setting.hidden) { createSettingPermission(setting, previousSettingPermissions); diff --git a/app/authorization/server/methods/addPermissionToRole.js b/app/authorization/server/methods/addPermissionToRole.ts similarity index 72% rename from app/authorization/server/methods/addPermissionToRole.js rename to app/authorization/server/methods/addPermissionToRole.ts index 5ca74ed3dbc9b..42990b114437c 100644 --- a/app/authorization/server/methods/addPermissionToRole.js +++ b/app/authorization/server/methods/addPermissionToRole.ts @@ -1,11 +1,12 @@ import { Meteor } from 'meteor/meteor'; -import { Permissions } from '../../../models/server'; + import { hasPermission } from '../functions/hasPermission'; import { CONSTANTS, AuthorizationUtils } from '../../lib'; +import { Permissions } from '../../../models/server/raw'; Meteor.methods({ - 'authorization:addPermissionToRole'(permissionId, role) { + async 'authorization:addPermissionToRole'(permissionId, role) { if (AuthorizationUtils.isPermissionRestrictedForRole(permissionId, role)) { throw new Meteor.Error('error-action-not-allowed', 'Permission is restricted', { method: 'authorization:addPermissionToRole', @@ -14,7 +15,14 @@ Meteor.methods({ } const uid = Meteor.userId(); - const permission = Permissions.findOneById(permissionId); + const permission = await Permissions.findOneById(permissionId); + + if (!permission) { + throw new Meteor.Error('error-invalid-permission', 'Permission does not exist', { + method: 'authorization:addPermissionToRole', + action: 'Adding_permission', + }); + } if (!uid || !hasPermission(uid, 'access-permissions') || (permission.level === CONSTANTS.SETTINGS_LEVEL && !hasPermission(uid, 'access-setting-permissions'))) { throw new Meteor.Error('error-action-not-allowed', 'Adding permission is not allowed', { diff --git a/app/authorization/server/methods/addUserToRole.js b/app/authorization/server/methods/addUserToRole.ts similarity index 84% rename from app/authorization/server/methods/addUserToRole.js rename to app/authorization/server/methods/addUserToRole.ts index a7fdd21ec24dc..3182d327ff476 100644 --- a/app/authorization/server/methods/addUserToRole.js +++ b/app/authorization/server/methods/addUserToRole.ts @@ -1,13 +1,14 @@ import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; -import { Users, Roles } from '../../../models/server'; +import { Users } from '../../../models/server'; import { settings } from '../../../settings/server'; import { hasPermission } from '../functions/hasPermission'; import { api } from '../../../../server/sdk/api'; +import { Roles } from '../../../models/server/raw'; Meteor.methods({ - 'authorization:addUserToRole'(roleName, username, scope) { + async 'authorization:addUserToRole'(roleName, username, scope) { if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'access-permissions')) { throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed', { method: 'authorization:addUserToRole', @@ -41,13 +42,13 @@ Meteor.methods({ } // verify if user can be added to given scope - if (scope && !Roles.canAddUserToRole(user._id, roleName, scope)) { + if (scope && !await Roles.canAddUserToRole(user._id, roleName, scope)) { throw new Meteor.Error('error-invalid-user', 'User is not part of given room', { method: 'authorization:addUserToRole', }); } - const add = Roles.addUserRoles(user._id, roleName, scope); + const add = await Roles.addUserRoles(user._id, [roleName], scope); if (settings.get('UI_DisplayRoles')) { api.broadcast('user.roleUpdate', { diff --git a/app/authorization/server/methods/deleteRole.js b/app/authorization/server/methods/deleteRole.ts similarity index 67% rename from app/authorization/server/methods/deleteRole.js rename to app/authorization/server/methods/deleteRole.ts index 8613e1761b0a5..8925942b23f3d 100644 --- a/app/authorization/server/methods/deleteRole.js +++ b/app/authorization/server/methods/deleteRole.ts @@ -1,10 +1,10 @@ import { Meteor } from 'meteor/meteor'; -import * as Models from '../../../models/server'; +import { Roles } from '../../../models/server/raw'; import { hasPermission } from '../functions/hasPermission'; Meteor.methods({ - 'authorization:deleteRole'(roleName) { + async 'authorization:deleteRole'(roleName) { if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'access-permissions')) { throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed', { method: 'authorization:deleteRole', @@ -12,7 +12,7 @@ Meteor.methods({ }); } - const role = Models.Roles.findOne(roleName); + const role = await Roles.findOne(roleName); if (!role) { throw new Meteor.Error('error-invalid-role', 'Invalid role', { method: 'authorization:deleteRole', @@ -25,16 +25,14 @@ Meteor.methods({ }); } - const roleScope = role.scope || 'Users'; - const model = Models[roleScope]; - const existingUsers = model && model.findUsersInRoles && model.findUsersInRoles(roleName); + const users = await(await Roles.findUsersInRole(roleName)).count(); - if (existingUsers && existingUsers.count() > 0) { + if (users > 0) { throw new Meteor.Error('error-role-in-use', 'Cannot delete role because it\'s in use', { method: 'authorization:deleteRole', }); } - return Models.Roles.remove(role.name); + return Roles.removeById(role.name); }, }); diff --git a/app/authorization/server/methods/removeRoleFromPermission.js b/app/authorization/server/methods/removeRoleFromPermission.ts similarity index 70% rename from app/authorization/server/methods/removeRoleFromPermission.js rename to app/authorization/server/methods/removeRoleFromPermission.ts index e0aa20ed34dbb..c31592a0ceca6 100644 --- a/app/authorization/server/methods/removeRoleFromPermission.js +++ b/app/authorization/server/methods/removeRoleFromPermission.ts @@ -1,13 +1,18 @@ import { Meteor } from 'meteor/meteor'; -import { Permissions } from '../../../models/server'; import { hasPermission } from '../functions/hasPermission'; import { CONSTANTS } from '../../lib'; +import { Permissions } from '../../../models/server/raw'; Meteor.methods({ - 'authorization:removeRoleFromPermission'(permissionId, role) { + async 'authorization:removeRoleFromPermission'(permissionId, role) { const uid = Meteor.userId(); - const permission = Permissions.findOneById(permissionId); + const permission = await Permissions.findOneById(permissionId); + + + if (!permission) { + throw new Meteor.Error('error-permission-not-found', 'Permission not found', { method: 'authorization:removeRoleFromPermission' }); + } if (!uid || !hasPermission(uid, 'access-permissions') || (permission.level === CONSTANTS.SETTINGS_LEVEL && !hasPermission(uid, 'access-setting-permissions'))) { throw new Meteor.Error('error-action-not-allowed', 'Removing permission is not allowed', { diff --git a/app/authorization/server/methods/removeUserFromRole.js b/app/authorization/server/methods/removeUserFromRole.js index 9a36a8895870c..d98ff825af9b8 100644 --- a/app/authorization/server/methods/removeUserFromRole.js +++ b/app/authorization/server/methods/removeUserFromRole.js @@ -1,13 +1,13 @@ import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; -import { Roles } from '../../../models/server'; import { settings } from '../../../settings/server'; import { hasPermission } from '../functions/hasPermission'; import { api } from '../../../../server/sdk/api'; +import { Roles } from '../../../models/server/raw'; Meteor.methods({ - 'authorization:removeUserFromRole'(roleName, username, scope) { + async 'authorization:removeUserFromRole'(roleName, username, scope) { if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'access-permissions')) { throw new Meteor.Error('error-action-not-allowed', 'Access permissions is not allowed', { method: 'authorization:removeUserFromRole', @@ -44,7 +44,7 @@ Meteor.methods({ }, }).count(); - const userIsAdmin = user.roles.indexOf('admin') > -1; + const userIsAdmin = user.roles?.indexOf('admin') > -1; if (adminCount === 1 && userIsAdmin) { throw new Meteor.Error('error-action-not-allowed', 'Leaving the app without admins is not allowed', { method: 'removeUserFromRole', @@ -53,7 +53,7 @@ Meteor.methods({ } } - const remove = Roles.removeUserRoles(user._id, roleName, scope); + const remove = await Roles.removeUserRoles(user._id, [roleName], scope); if (settings.get('UI_DisplayRoles')) { api.broadcast('user.roleUpdate', { type: 'removed', diff --git a/app/authorization/server/methods/saveRole.js b/app/authorization/server/methods/saveRole.ts similarity index 80% rename from app/authorization/server/methods/saveRole.js rename to app/authorization/server/methods/saveRole.ts index 5e09f211240d7..04f431ba9906e 100644 --- a/app/authorization/server/methods/saveRole.js +++ b/app/authorization/server/methods/saveRole.ts @@ -1,12 +1,12 @@ import { Meteor } from 'meteor/meteor'; -import { Roles } from '../../../models/server'; import { settings } from '../../../settings/server'; import { hasPermission } from '../functions/hasPermission'; import { api } from '../../../../server/sdk/api'; +import { Roles } from '../../../models/server/raw'; Meteor.methods({ - 'authorization:saveRole'(roleData) { + async 'authorization:saveRole'(roleData) { if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'access-permissions')) { throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed', { method: 'authorization:saveRole', @@ -24,7 +24,7 @@ Meteor.methods({ roleData.scope = 'Users'; } - const update = Roles.createOrUpdate(roleData.name, roleData.scope, roleData.description, false, roleData.mandatory2fa); + const update = await Roles.createOrUpdate(roleData.name, roleData.scope, roleData.description, false, roleData.mandatory2fa); if (settings.get('UI_DisplayRoles')) { api.broadcast('user.roleUpdate', { type: 'changed', diff --git a/app/authorization/server/streamer/permissions/index.js b/app/authorization/server/streamer/permissions/index.js deleted file mode 100644 index edffbdfe3e734..0000000000000 --- a/app/authorization/server/streamer/permissions/index.js +++ /dev/null @@ -1,25 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import Permissions from '../../../../models/server/models/Permissions'; - -Meteor.methods({ - 'permissions/get'(updatedAt) { - // TODO: should we return this for non logged users? - // TODO: we could cache this collection - - const records = Permissions.find().fetch(); - - if (updatedAt instanceof Date) { - return { - update: records.filter((record) => record._updatedAt > updatedAt), - remove: Permissions.trashFindDeletedAfter( - updatedAt, - {}, - { fields: { _id: 1, _deletedAt: 1 } }, - ).fetch(), - }; - } - - return records; - }, -}); diff --git a/app/authorization/server/streamer/permissions/index.ts b/app/authorization/server/streamer/permissions/index.ts new file mode 100644 index 0000000000000..fcc3bad0e34cd --- /dev/null +++ b/app/authorization/server/streamer/permissions/index.ts @@ -0,0 +1,30 @@ +import { Meteor } from 'meteor/meteor'; +import { check, Match } from 'meteor/check'; + +import { Permissions } from '../../../../models/server/raw'; + +Meteor.methods({ + async 'permissions/get'(updatedAt: Date) { + check(updatedAt, Match.Maybe(Date)); + + // TODO: should we return this for non logged users? + // TODO: we could cache this collection + + const records = await Permissions.find( + updatedAt && { _updatedAt: { $gt: updatedAt } }, + ).toArray(); + + if (updatedAt instanceof Date) { + return { + update: records, + remove: await Permissions.trashFindDeletedAfter( + updatedAt, + {}, + { projection: { _id: 1, _deletedAt: 1 } }, + ).toArray(), + }; + } + + return records; + }, +}); diff --git a/app/autotranslate/server/permissions.js b/app/autotranslate/server/permissions.js deleted file mode 100644 index 64ce0028fa872..0000000000000 --- a/app/autotranslate/server/permissions.js +++ /dev/null @@ -1,11 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { Permissions } from '../../models'; - -Meteor.startup(() => { - if (Permissions) { - if (!Permissions.findOne({ _id: 'auto-translate' })) { - Permissions.insert({ _id: 'auto-translate', roles: ['admin'] }); - } - } -}); diff --git a/app/autotranslate/server/permissions.ts b/app/autotranslate/server/permissions.ts new file mode 100644 index 0000000000000..5ce05e8f1ef72 --- /dev/null +++ b/app/autotranslate/server/permissions.ts @@ -0,0 +1,9 @@ +import { Meteor } from 'meteor/meteor'; + +import { Permissions } from '../../models/server/raw'; + +Meteor.startup(async () => { + if (!await Permissions.findOne({ _id: 'auto-translate' })) { + Permissions.create('auto-translate', ['admin']); + } +}); diff --git a/app/cas/server/cas_server.js b/app/cas/server/cas_server.js index 646e87a8f0539..cc569eeab4419 100644 --- a/app/cas/server/cas_server.js +++ b/app/cas/server/cas_server.js @@ -10,7 +10,8 @@ import CAS from 'cas'; import { logger } from './cas_rocketchat'; import { settings } from '../../settings'; -import { Rooms, CredentialTokens } from '../../models/server'; +import { Rooms } from '../../models/server'; +import { CredentialTokens } from '../../models/server/raw'; import { _setRealName } from '../../lib'; import { createRoom } from '../../lib/server/functions/createRoom'; @@ -43,7 +44,7 @@ const casTicket = function(req, token, callback) { service: `${ appUrl }/_cas/${ token }`, }); - cas.validate(ticketId, Meteor.bindEnvironment(function(err, status, username, details) { + cas.validate(ticketId, Meteor.bindEnvironment(async function(err, status, username, details) { if (err) { logger.error(`error when trying to validate: ${ err.message }`); } else if (status) { @@ -54,11 +55,11 @@ const casTicket = function(req, token, callback) { if (details && details.attributes) { _.extend(user_info, { attributes: details.attributes }); } - CredentialTokens.create(token, user_info); + await CredentialTokens.create(token, user_info); } else { logger.error(`Unable to validate ticket: ${ ticketId }`); } - // logger.debug("Receveied response: " + JSON.stringify(details, null , 4)); + // logger.debug("Received response: " + JSON.stringify(details, null , 4)); callback(); })); @@ -114,7 +115,8 @@ Accounts.registerLoginHandler(function(options) { return undefined; } - const credentials = CredentialTokens.findOneById(options.cas.credentialToken); + // TODO: Sync wrapper due to the chain conversion to async models + const credentials = Promise.await(CredentialTokens.findOneNotExpiredById(options.cas.credentialToken)); if (credentials === undefined) { throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching login attempt found'); diff --git a/app/channel-settings/server/functions/saveRoomName.js b/app/channel-settings/server/functions/saveRoomName.js index 5d3197d133b35..0cc31cfa77b44 100644 --- a/app/channel-settings/server/functions/saveRoomName.js +++ b/app/channel-settings/server/functions/saveRoomName.js @@ -1,6 +1,7 @@ import { Meteor } from 'meteor/meteor'; -import { Rooms, Messages, Subscriptions, Integrations } from '../../../models/server'; +import { Rooms, Messages, Subscriptions } from '../../../models/server'; +import { Integrations } from '../../../models/server/raw'; import { roomTypes, getValidRoomName } from '../../../utils/server'; import { callbacks } from '../../../callbacks/server'; import { checkUsernameAvailability } from '../../../lib/server/functions'; @@ -19,7 +20,7 @@ const updateRoomName = (rid, displayName, isDiscussion) => { return Rooms.setNameById(rid, slugifiedRoomName, displayName) && Subscriptions.updateNameAndAlertByRoomId(rid, slugifiedRoomName, displayName); }; -export const saveRoomName = function(rid, displayName, user, sendMessage = true) { +export async function saveRoomName(rid, displayName, user, sendMessage = true) { const room = Rooms.findOneById(rid); if (roomTypes.getConfig(room.t).preventRenaming()) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { @@ -35,10 +36,10 @@ export const saveRoomName = function(rid, displayName, user, sendMessage = true) return; } - Integrations.updateRoomName(room.name, displayName); + await Integrations.updateRoomName(room.name, displayName); if (sendMessage) { Messages.createRoomRenamedWithRoomIdRoomNameAndUser(rid, displayName, user); } callbacks.run('afterRoomNameChange', { rid, name: displayName, oldName: room.name }); return displayName; -}; +} diff --git a/app/channel-settings/server/methods/saveRoomSettings.js b/app/channel-settings/server/methods/saveRoomSettings.js index 811c492fb70d4..59c0bb239f791 100644 --- a/app/channel-settings/server/methods/saveRoomSettings.js +++ b/app/channel-settings/server/methods/saveRoomSettings.js @@ -128,7 +128,7 @@ const validators = { const settingSavers = { roomName({ value, rid, user, room }) { - if (!saveRoomName(rid, value, user)) { + if (!Promise.await(saveRoomName(rid, value, user))) { return; } @@ -231,13 +231,13 @@ const settingSavers = { favorite({ value, rid }) { Rooms.saveFavoriteById(rid, value.favorite, value.defaultValue); }, - roomAvatar({ value, rid, user }) { - setRoomAvatar(rid, value, user); + async roomAvatar({ value, rid, user }) { + await setRoomAvatar(rid, value, user); }, }; Meteor.methods({ - saveRoomSettings(rid, settings, value) { + async saveRoomSettings(rid, settings, value) { const userId = Meteor.userId(); if (!userId) { @@ -313,10 +313,10 @@ Meteor.methods({ }); // saving data - Object.keys(settings).forEach((setting) => { + for await (const setting of Object.keys(settings)) { const value = settings[setting]; - const saver = settingSavers[setting]; + const saver = await settingSavers[setting]; if (saver) { saver({ value, @@ -325,7 +325,7 @@ Meteor.methods({ user, }); } - }); + } Meteor.defer(function() { const room = Rooms.findOneById(rid); diff --git a/app/cloud/server/functions/buildRegistrationData.js b/app/cloud/server/functions/buildRegistrationData.js index d8ecff67687f8..5346558e23ab6 100644 --- a/app/cloud/server/functions/buildRegistrationData.js +++ b/app/cloud/server/functions/buildRegistrationData.js @@ -1,10 +1,11 @@ import { settings } from '../../../settings/server'; -import { Users, Statistics } from '../../../models/server'; +import { Users } from '../../../models/server'; +import { Statistics } from '../../../models/server/raw'; import { statistics } from '../../../statistics'; import { LICENSE_VERSION } from '../license'; -export function buildWorkspaceRegistrationData() { - const stats = Statistics.findLast() || statistics.get(); +export async function buildWorkspaceRegistrationData() { + const stats = await Statistics.findLast() || statistics.get(); const address = settings.get('Site_Url'); const siteName = settings.get('Site_Name'); diff --git a/app/cloud/server/functions/startRegisterWorkspace.js b/app/cloud/server/functions/startRegisterWorkspace.js index bb533c79c5809..2f9e4f90b7894 100644 --- a/app/cloud/server/functions/startRegisterWorkspace.js +++ b/app/cloud/server/functions/startRegisterWorkspace.js @@ -7,18 +7,17 @@ import { Settings } from '../../../models'; import { buildWorkspaceRegistrationData } from './buildRegistrationData'; import { SystemLogger } from '../../../../server/lib/logger/system'; - -export function startRegisterWorkspace(resend = false) { +export async function startRegisterWorkspace(resend = false) { const { workspaceRegistered, connectToCloud } = retrieveRegistrationStatus(); if ((workspaceRegistered && connectToCloud) || process.env.TEST_MODE) { - syncWorkspace(true); + await syncWorkspace(true); return true; } Settings.updateValueById('Register_Server', true); - const regInfo = buildWorkspaceRegistrationData(); + const regInfo = await buildWorkspaceRegistrationData(); const cloudUrl = settings.get('Cloud_Url'); diff --git a/app/cloud/server/functions/syncWorkspace.js b/app/cloud/server/functions/syncWorkspace.js index 03f67acf4a4b9..1b1021402e25e 100644 --- a/app/cloud/server/functions/syncWorkspace.js +++ b/app/cloud/server/functions/syncWorkspace.js @@ -10,13 +10,13 @@ import { getAndCreateNpsSurvey } from '../../../../server/services/nps/getAndCre import { NPS, Banner } from '../../../../server/sdk'; import { SystemLogger } from '../../../../server/lib/logger/system'; -export function syncWorkspace(reconnectCheck = false) { +export async function syncWorkspace(reconnectCheck = false) { const { workspaceRegistered, connectToCloud } = retrieveRegistrationStatus(); if (!workspaceRegistered || (!connectToCloud && !reconnectCheck)) { return false; } - const info = buildWorkspaceRegistrationData(); + const info = await buildWorkspaceRegistrationData(); const workspaceUrl = settings.get('Cloud_Workspace_Registration_Client_Uri'); @@ -64,11 +64,11 @@ export function syncWorkspace(reconnectCheck = false) { const startAt = new Date(data.nps.startAt); - Promise.await(NPS.create({ + await NPS.create({ npsId, startAt, expireAt: new Date(expireAt), - })); + }); const now = new Date(); @@ -79,19 +79,19 @@ export function syncWorkspace(reconnectCheck = false) { // add banners if (data.banners) { - for (const banner of data.banners) { + for await (const banner of data.banners) { const { createdAt, expireAt, startAt, } = banner; - Promise.await(Banner.create({ + await Banner.create({ ...banner, createdAt: new Date(createdAt), expireAt: new Date(expireAt), startAt: new Date(startAt), - })); + }); } } diff --git a/app/cloud/server/index.js b/app/cloud/server/index.js index 98ae3b710b963..eb239b095c680 100644 --- a/app/cloud/server/index.js +++ b/app/cloud/server/index.js @@ -6,9 +6,12 @@ import { getWorkspaceAccessToken } from './functions/getWorkspaceAccessToken'; import { getWorkspaceAccessTokenWithScope } from './functions/getWorkspaceAccessTokenWithScope'; import { getWorkspaceLicense } from './functions/getWorkspaceLicense'; import { getUserCloudAccessToken } from './functions/getUserCloudAccessToken'; +import { retrieveRegistrationStatus } from './functions/retrieveRegistrationStatus'; import { getWorkspaceKey } from './functions/getWorkspaceKey'; import { syncWorkspace } from './functions/syncWorkspace'; +import { connectWorkspace } from './functions/connectWorkspace'; import { settings } from '../../settings/server'; +import { SystemLogger } from '../../../server/lib/logger/system'; const licenseCronName = 'Cloud Workspace Sync'; @@ -34,6 +37,22 @@ Meteor.startup(function() { job: syncWorkspace, }); }); + + const { workspaceRegistered } = retrieveRegistrationStatus(); + + if (process.env.REG_TOKEN && process.env.REG_TOKEN !== '' && !workspaceRegistered) { + try { + SystemLogger.info('REG_TOKEN Provided. Attempting to register'); + + if (!connectWorkspace(process.env.REG_TOKEN)) { + throw new Error('Couldn\'t register with token. Please make sure token is valid or hasn\'t already been used'); + } + + console.log('Successfully registered with token provided by REG_TOKEN!'); + } catch (e) { + SystemLogger.error('An error occured registering with token.', e.message); + } + } }); export { getWorkspaceAccessToken, getWorkspaceAccessTokenWithScope, getWorkspaceLicense, getWorkspaceKey, getUserCloudAccessToken }; diff --git a/app/cloud/server/methods.js b/app/cloud/server/methods.js index 7723566601f5a..83847711a603b 100644 --- a/app/cloud/server/methods.js +++ b/app/cloud/server/methods.js @@ -26,7 +26,7 @@ Meteor.methods({ return retrieveRegistrationStatus(); }, - 'cloud:getWorkspaceRegisterData'() { + async 'cloud:getWorkspaceRegisterData'() { if (!Meteor.userId()) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'cloud:getWorkspaceRegisterData' }); } @@ -35,9 +35,9 @@ Meteor.methods({ throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'cloud:getWorkspaceRegisterData' }); } - return Buffer.from(JSON.stringify(buildWorkspaceRegistrationData())).toString('base64'); + return Buffer.from(JSON.stringify(await buildWorkspaceRegistrationData())).toString('base64'); }, - 'cloud:registerWorkspace'() { + async 'cloud:registerWorkspace'() { if (!Meteor.userId()) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'cloud:startRegister' }); } @@ -48,7 +48,7 @@ Meteor.methods({ return startRegisterWorkspace(); }, - 'cloud:syncWorkspace'() { + async 'cloud:syncWorkspace'() { if (!Meteor.userId()) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'cloud:syncWorkspace' }); } diff --git a/app/cors/server/cors.js b/app/cors/server/cors.js index 80a1f7a385ab0..f2c63ee441a61 100644 --- a/app/cors/server/cors.js +++ b/app/cors/server/cors.js @@ -31,6 +31,11 @@ WebApp.rawConnectHandlers.use(function(req, res, next) { settings.get('CDN_PREFIX_ALL') ? null : settings.get('CDN_JSCSS_PREFIX'), ].filter(Boolean).join(' '); + const inlineHashes = [ + // Hash for `window.close()`, required by the CAS login popup. + "'sha256-jqxtvDkBbRAl9Hpqv68WdNOieepg8tJSYu1xIy7zT34='", + ].filter(Boolean).join(' '); + res.setHeader( 'Content-Security-Policy', [ @@ -40,7 +45,7 @@ WebApp.rawConnectHandlers.use(function(req, res, next) { 'frame-src *', 'img-src * data:', 'media-src * data:', - `script-src 'self' 'unsafe-eval' ${ cdn_prefixes }`, + `script-src 'self' 'unsafe-eval' ${ inlineHashes } ${ cdn_prefixes }`, `style-src 'self' 'unsafe-inline' ${ cdn_prefixes }`, ].join('; '), ); diff --git a/app/crowd/server/crowd.js b/app/crowd/server/crowd.js index d5b4b5b97ef36..dba130ef489e3 100644 --- a/app/crowd/server/crowd.js +++ b/app/crowd/server/crowd.js @@ -208,7 +208,7 @@ export class CROWD { if (settings.get('CROWD_Remove_Orphaned_Users') === true) { logger.info('Removing user:', crowd_username); Meteor.defer(function() { - deleteUser(user._id); + Promise.await(deleteUser(user._id)); logger.info('User removed:', crowd_username); }); } diff --git a/app/custom-oauth/server/custom_oauth_server.js b/app/custom-oauth/server/custom_oauth_server.js index 2be67a0925473..2e62be9647eb2 100644 --- a/app/custom-oauth/server/custom_oauth_server.js +++ b/app/custom-oauth/server/custom_oauth_server.js @@ -334,6 +334,8 @@ export class CustomOAuth { return; } + callbacks.run('afterProcessOAuthUser', { serviceName, serviceData, user }); + // User already created or merged and has identical name as before if (user.services && user.services[serviceName] && user.services[serviceName].id === serviceData.id && user.name === serviceData.name) { return; @@ -343,8 +345,6 @@ export class CustomOAuth { throw new Meteor.Error('CustomOAuth', `User with username ${ user.username } already exists`); } - callbacks.run('afterProcessOAuthUser', { serviceName, serviceData, user }); - const serviceIdKey = `services.${ serviceName }.id`; const update = { $set: { diff --git a/app/custom-oauth/server/transform_helpers.tests.js b/app/custom-oauth/server/transform_helpers.tests.js index ec1475780e682..5139edb2410e2 100644 --- a/app/custom-oauth/server/transform_helpers.tests.js +++ b/app/custom-oauth/server/transform_helpers.tests.js @@ -1,5 +1,3 @@ -/* eslint-env mocha */ - import { expect } from 'chai'; import { diff --git a/app/custom-sounds/server/methods/deleteCustomSound.js b/app/custom-sounds/server/methods/deleteCustomSound.js index b72c852bacfc6..d168fac12d1d4 100644 --- a/app/custom-sounds/server/methods/deleteCustomSound.js +++ b/app/custom-sounds/server/methods/deleteCustomSound.js @@ -1,16 +1,16 @@ import { Meteor } from 'meteor/meteor'; -import { CustomSounds } from '../../../models'; +import { CustomSounds } from '../../../models/server/raw'; import { hasPermission } from '../../../authorization'; import { Notifications } from '../../../notifications'; import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; Meteor.methods({ - deleteCustomSound(_id) { + async deleteCustomSound(_id) { let sound = null; if (hasPermission(this.userId, 'manage-sounds')) { - sound = CustomSounds.findOneById(_id); + sound = await CustomSounds.findOneById(_id); } else { throw new Meteor.Error('not_authorized'); } @@ -20,7 +20,7 @@ Meteor.methods({ } RocketChatFileCustomSoundsInstance.deleteFile(`${ sound._id }.${ sound.extension }`); - CustomSounds.removeById(_id); + await CustomSounds.removeById(_id); Notifications.notifyAll('deleteCustomSound', { soundData: sound }); return true; diff --git a/app/custom-sounds/server/methods/insertOrUpdateSound.js b/app/custom-sounds/server/methods/insertOrUpdateSound.js index d3fe25e0173b0..b1fa7c749747c 100644 --- a/app/custom-sounds/server/methods/insertOrUpdateSound.js +++ b/app/custom-sounds/server/methods/insertOrUpdateSound.js @@ -3,12 +3,12 @@ import s from 'underscore.string'; import { check } from 'meteor/check'; import { hasPermission } from '../../../authorization'; -import { CustomSounds } from '../../../models'; +import { CustomSounds } from '../../../models/server/raw'; import { Notifications } from '../../../notifications'; import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; Meteor.methods({ - insertOrUpdateSound(soundData) { + async insertOrUpdateSound(soundData) { if (!hasPermission(this.userId, 'manage-sounds')) { throw new Meteor.Error('not_authorized'); } @@ -34,9 +34,9 @@ Meteor.methods({ if (soundData._id) { check(soundData._id, String); - matchingResults = CustomSounds.findByNameExceptId(soundData.name, soundData._id).fetch(); + matchingResults = await CustomSounds.findByNameExceptId(soundData.name, soundData._id).toArray(); } else { - matchingResults = CustomSounds.findByName(soundData.name).fetch(); + matchingResults = await CustomSounds.findByName(soundData.name).toArray(); } if (matchingResults.length > 0) { @@ -50,7 +50,7 @@ Meteor.methods({ extension: soundData.extension, }; - const _id = CustomSounds.create(createSound); + const _id = await (await CustomSounds.create(createSound)).insertedId; createSound._id = _id; return _id; @@ -61,7 +61,7 @@ Meteor.methods({ } if (soundData.name !== soundData.previousName) { - CustomSounds.setName(soundData._id, soundData.name); + await CustomSounds.setName(soundData._id, soundData.name); Notifications.notifyAll('updateCustomSound', { soundData }); } diff --git a/app/custom-sounds/server/methods/listCustomSounds.js b/app/custom-sounds/server/methods/listCustomSounds.js index 90bf6db20435a..475da52286be1 100644 --- a/app/custom-sounds/server/methods/listCustomSounds.js +++ b/app/custom-sounds/server/methods/listCustomSounds.js @@ -1,9 +1,9 @@ import { Meteor } from 'meteor/meteor'; -import { CustomSounds } from '../../../models'; +import { CustomSounds } from '../../../models/server/raw'; Meteor.methods({ - listCustomSounds() { - return CustomSounds.find({}).fetch(); + async listCustomSounds() { + return CustomSounds.find({}).toArray(); }, }); diff --git a/app/discussion/client/createDiscussionMessageAction.js b/app/discussion/client/createDiscussionMessageAction.js index 7d107be268b22..120001c9e52d3 100644 --- a/app/discussion/client/createDiscussionMessageAction.js +++ b/app/discussion/client/createDiscussionMessageAction.js @@ -22,12 +22,12 @@ Meteor.startup(function() { label: 'Discussion_start', context: ['message', 'message-mobile'], async action() { - const { msg: message } = messageArgs(this); + const { msg: message, room } = messageArgs(this); imperativeModal.open({ component: CreateDiscussion, props: { - defaultParentRoom: message.rid, + defaultParentRoom: room.prid || room._id, onClose: imperativeModal.close, parentMessageId: message._id, nameSuggestion: message?.msg?.substr(0, 140), diff --git a/app/discussion/client/discussionFromMessageBox.js b/app/discussion/client/discussionFromMessageBox.js index 668cf6ac75b54..e2a8b29c845bb 100644 --- a/app/discussion/client/discussionFromMessageBox.js +++ b/app/discussion/client/discussionFromMessageBox.js @@ -20,7 +20,7 @@ Meteor.startup(function() { imperativeModal.open({ component: CreateDiscussion, props: { - defaultParentRoom: data.rid, + defaultParentRoom: data.prid || data.rid, onClose: imperativeModal.close, }, }); diff --git a/app/discussion/server/methods/createDiscussion.js b/app/discussion/server/methods/createDiscussion.js index b9d28a0d58df7..8c1ea7dbb3d35 100644 --- a/app/discussion/server/methods/createDiscussion.js +++ b/app/discussion/server/methods/createDiscussion.js @@ -38,7 +38,7 @@ const mentionMessage = (rid, { _id, username, name }, message_embedded) => { }; const create = ({ prid, pmid, t_name, reply, users, user, encrypted }) => { - // if you set both, prid and pmid, and the rooms doesnt match... should throw an error) + // if you set both, prid and pmid, and the rooms dont match... should throw an error) let message = false; if (pmid) { message = Messages.findOne({ _id: pmid }); diff --git a/app/discussion/server/permissions.js b/app/discussion/server/permissions.ts similarity index 87% rename from app/discussion/server/permissions.js rename to app/discussion/server/permissions.ts index 3d54e4c66b16b..da3ac2ee2290a 100644 --- a/app/discussion/server/permissions.js +++ b/app/discussion/server/permissions.ts @@ -1,6 +1,7 @@ import { Meteor } from 'meteor/meteor'; -import { Permissions } from '../../models'; +import { Permissions } from '../../models/server/raw'; + Meteor.startup(() => { // Add permissions for discussion diff --git a/app/e2e/server/beforeCreateRoom.js b/app/e2e/server/beforeCreateRoom.js index ce3b21ad69355..a8c6a89335196 100644 --- a/app/e2e/server/beforeCreateRoom.js +++ b/app/e2e/server/beforeCreateRoom.js @@ -3,9 +3,8 @@ import { settings } from '../../settings/server'; callbacks.add('beforeCreateRoom', ({ type, extraData }) => { if ( - settings.get('E2E_Enabled') && ((type === 'd' && settings.get('E2E_Enabled_Default_DirectRooms')) - || (type === 'p' && settings.get('E2E_Enabled_Default_PrivateRooms'))) - ) { + settings.get('E2E_Enable') && ((type === 'd' && settings.get('E2E_Enabled_Default_DirectRooms')) + || (type === 'p' && settings.get('E2E_Enabled_Default_PrivateRooms')))) { extraData.encrypted = extraData.encrypted ?? true; } }); diff --git a/app/e2e/server/methods/getUsersOfRoomWithoutKey.js b/app/e2e/server/methods/getUsersOfRoomWithoutKey.js index a686af5e88c4c..2139ac8fde7e1 100644 --- a/app/e2e/server/methods/getUsersOfRoomWithoutKey.js +++ b/app/e2e/server/methods/getUsersOfRoomWithoutKey.js @@ -1,16 +1,23 @@ import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; -import { Subscriptions, Users } from '../../../models'; +import { canAccessRoom } from '../../../authorization/server'; +import { Subscriptions, Users } from '../../../models/server'; Meteor.methods({ 'e2e.getUsersOfRoomWithoutKey'(rid) { + check(rid, String); + const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'e2e.getUsersOfRoomWithoutKey' }); } - const room = Meteor.call('canAccessRoom', rid, userId); - if (!room) { + if (!rid) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'e2e.getUsersOfRoomWithoutKey' }); + } + + if (!canAccessRoom({ _id: rid }, { _id: userId })) { throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'e2e.getUsersOfRoomWithoutKey' }); } diff --git a/app/e2e/server/methods/setRoomKeyID.js b/app/e2e/server/methods/setRoomKeyID.js index a273e803b9340..e2f8aafa059bf 100644 --- a/app/e2e/server/methods/setRoomKeyID.js +++ b/app/e2e/server/methods/setRoomKeyID.js @@ -1,19 +1,29 @@ import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; -import { Rooms } from '../../../models'; +import { canAccessRoom } from '../../../authorization/server'; +import { Rooms } from '../../../models/server'; Meteor.methods({ 'e2e.setRoomKeyID'(rid, keyID) { + check(rid, String); + check(keyID, String); + const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'e2e.setRoomKeyID' }); } - const room = Meteor.call('canAccessRoom', rid, userId); - if (!room) { + if (!rid) { throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'e2e.setRoomKeyID' }); } + if (!canAccessRoom({ _id: rid }, { _id: userId })) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'e2e.setRoomKeyID' }); + } + + const room = Rooms.findOneById(rid, { fields: { e2eKeyId: 1 } }); + if (room.e2eKeyId) { throw new Meteor.Error('error-room-e2e-key-already-exists', 'E2E Key ID already exists', { method: 'e2e.setRoomKeyID' }); } diff --git a/app/e2e/server/settings.ts b/app/e2e/server/settings.ts index 3b3aad9e6dc79..20c624fed9b54 100644 --- a/app/e2e/server/settings.ts +++ b/app/e2e/server/settings.ts @@ -11,11 +11,13 @@ settingsRegistry.addGroup('E2E Encryption', function() { this.add('E2E_Enabled_Default_DirectRooms', false, { type: 'boolean', + public: true, enableQuery: { _id: 'E2E_Enable', value: true }, }); this.add('E2E_Enabled_Default_PrivateRooms', false, { type: 'boolean', + public: true, enableQuery: { _id: 'E2E_Enable', value: true }, }); }); diff --git a/app/emoji-custom/server/methods/deleteEmojiCustom.js b/app/emoji-custom/server/methods/deleteEmojiCustom.js index 7393f245b459e..2964c5ff6cd66 100644 --- a/app/emoji-custom/server/methods/deleteEmojiCustom.js +++ b/app/emoji-custom/server/methods/deleteEmojiCustom.js @@ -2,22 +2,22 @@ import { Meteor } from 'meteor/meteor'; import { api } from '../../../../server/sdk/api'; import { hasPermission } from '../../../authorization'; -import { EmojiCustom } from '../../../models'; +import { EmojiCustom } from '../../../models/server/raw'; import { RocketChatFileEmojiCustomInstance } from '../startup/emoji-custom'; Meteor.methods({ - deleteEmojiCustom(emojiID) { + async deleteEmojiCustom(emojiID) { if (!hasPermission(this.userId, 'manage-emoji')) { throw new Meteor.Error('not_authorized'); } - const emoji = EmojiCustom.findOneById(emojiID); + const emoji = await EmojiCustom.findOneById(emojiID); if (emoji == null) { throw new Meteor.Error('Custom_Emoji_Error_Invalid_Emoji', 'Invalid emoji', { method: 'deleteEmojiCustom' }); } RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${ emoji.name }.${ emoji.extension }`)); - EmojiCustom.removeById(emojiID); + await EmojiCustom.removeById(emojiID); api.broadcast('emoji.deleteCustom', emoji); return true; diff --git a/app/emoji-custom/server/methods/insertOrUpdateEmoji.js b/app/emoji-custom/server/methods/insertOrUpdateEmoji.js index b96b40b2fbd06..23843c81cec95 100644 --- a/app/emoji-custom/server/methods/insertOrUpdateEmoji.js +++ b/app/emoji-custom/server/methods/insertOrUpdateEmoji.js @@ -4,12 +4,12 @@ import s from 'underscore.string'; import limax from 'limax'; import { hasPermission } from '../../../authorization'; -import { EmojiCustom } from '../../../models'; +import { EmojiCustom } from '../../../models/server/raw'; import { RocketChatFileEmojiCustomInstance } from '../startup/emoji-custom'; import { api } from '../../../../server/sdk/api'; Meteor.methods({ - insertOrUpdateEmoji(emojiData) { + async insertOrUpdateEmoji(emojiData) { if (!hasPermission(this.userId, 'manage-emoji')) { throw new Meteor.Error('not_authorized'); } @@ -50,14 +50,14 @@ Meteor.methods({ let matchingResults = []; if (emojiData._id) { - matchingResults = EmojiCustom.findByNameOrAliasExceptID(emojiData.name, emojiData._id).fetch(); - for (const alias of emojiData.aliases) { - matchingResults = matchingResults.concat(EmojiCustom.findByNameOrAliasExceptID(alias, emojiData._id).fetch()); + matchingResults = await EmojiCustom.findByNameOrAliasExceptID(emojiData.name, emojiData._id).toArray(); + for await (const alias of emojiData.aliases) { + matchingResults = matchingResults.concat(await EmojiCustom.findByNameOrAliasExceptID(alias, emojiData._id).toArray()); } } else { - matchingResults = EmojiCustom.findByNameOrAlias(emojiData.name).fetch(); - for (const alias of emojiData.aliases) { - matchingResults = matchingResults.concat(EmojiCustom.findByNameOrAlias(alias).fetch()); + matchingResults = await EmojiCustom.findByNameOrAlias(emojiData.name).toArray(); + for await (const alias of emojiData.aliases) { + matchingResults = matchingResults.concat(await EmojiCustom.findByNameOrAlias(alias).toArray()); } } @@ -77,7 +77,7 @@ Meteor.methods({ extension: emojiData.extension, }; - const _id = EmojiCustom.create(createEmoji); + const _id = (await EmojiCustom.create(createEmoji)).insertedId; api.broadcast('emoji.updateCustom', createEmoji); @@ -90,7 +90,7 @@ Meteor.methods({ RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${ emojiData.previousName }.${ emojiData.extension }`)); RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${ emojiData.previousName }.${ emojiData.previousExtension }`)); - EmojiCustom.setExtension(emojiData._id, emojiData.extension); + await EmojiCustom.setExtension(emojiData._id, emojiData.extension); } else if (emojiData.name !== emojiData.previousName) { const rs = RocketChatFileEmojiCustomInstance.getFileWithReadStream(encodeURIComponent(`${ emojiData.previousName }.${ emojiData.previousExtension }`)); if (rs !== null) { @@ -104,13 +104,13 @@ Meteor.methods({ } if (emojiData.name !== emojiData.previousName) { - EmojiCustom.setName(emojiData._id, emojiData.name); + await EmojiCustom.setName(emojiData._id, emojiData.name); } if (emojiData.aliases) { - EmojiCustom.setAliases(emojiData._id, emojiData.aliases); + await EmojiCustom.setAliases(emojiData._id, emojiData.aliases); } else { - EmojiCustom.setAliases(emojiData._id, []); + await EmojiCustom.setAliases(emojiData._id, []); } api.broadcast('emoji.updateCustom', emojiData); diff --git a/app/emoji-custom/server/methods/listEmojiCustom.js b/app/emoji-custom/server/methods/listEmojiCustom.js index d06b382af85e6..d66aeee1a6add 100644 --- a/app/emoji-custom/server/methods/listEmojiCustom.js +++ b/app/emoji-custom/server/methods/listEmojiCustom.js @@ -1,9 +1,9 @@ import { Meteor } from 'meteor/meteor'; -import { EmojiCustom } from '../../../models'; +import { EmojiCustom } from '../../../models/server/raw'; Meteor.methods({ - listEmojiCustom(options = {}) { - return EmojiCustom.find(options).fetch(); + async listEmojiCustom(options = {}) { + return EmojiCustom.find(options).toArray(); }, }); diff --git a/app/federation/server/endpoints/dispatch.js b/app/federation/server/endpoints/dispatch.js index ae392ac8aac86..333a30bbeebf3 100644 --- a/app/federation/server/endpoints/dispatch.js +++ b/app/federation/server/endpoints/dispatch.js @@ -4,12 +4,13 @@ import { API } from '../../../api/server'; import { serverLogger } from '../lib/logger'; import { contextDefinitions, eventTypes } from '../../../models/server/models/FederationEvents'; import { - FederationRoomEvents, FederationServers, + FederationRoomEvents, Messages, Rooms, Subscriptions, Users, } from '../../../models/server'; +import { FederationServers } from '../../../models/server/raw'; import { normalizers } from '../normalizers'; import { deleteRoom } from '../../../lib/server/functions'; import { Notifications } from '../../../notifications/server'; @@ -139,7 +140,7 @@ const eventHandlers = { // Refresh the servers list if (federationAltered) { - FederationServers.refreshServers(); + await FederationServers.refreshServers(); // Update the room's federation property Rooms.update({ _id: roomId }, { $set: { 'federation.domains': domainsAfterAdd } }); @@ -163,7 +164,7 @@ const eventHandlers = { Subscriptions.removeByRoomIdAndUserId(roomId, user._id); // Refresh the servers list - FederationServers.refreshServers(); + await FederationServers.refreshServers(); // Update the room's federation property Rooms.update({ _id: roomId }, { $set: { 'federation.domains': domainsAfterRemoval } }); @@ -186,7 +187,7 @@ const eventHandlers = { Subscriptions.removeByRoomIdAndUserId(roomId, user._id); // Refresh the servers list - FederationServers.refreshServers(); + await FederationServers.refreshServers(); // Update the room's federation property Rooms.update({ _id: roomId }, { $set: { 'federation.domains': domainsAfterRemoval } }); @@ -226,7 +227,7 @@ const eventHandlers = { const { federation: { origin } } = denormalizedMessage; - const { upload, buffer } = getUpload(origin, denormalizedMessage.file._id); + const { upload, buffer } = await getUpload(origin, denormalizedMessage.file._id); const oldUploadId = upload._id; @@ -444,7 +445,7 @@ const eventHandlers = { }; API.v1.addRoute('federation.events.dispatch', { authRequired: false, rateLimiterOptions: { numRequestsAllowed: 30, intervalTimeInMS: 1000 } }, { - async post() { + post() { if (!isFederationEnabled()) { return API.v1.failure('Federation not enabled'); } @@ -454,7 +455,7 @@ API.v1.addRoute('federation.events.dispatch', { authRequired: false, rateLimiter let payload; try { - payload = decryptIfNeeded(this.request, this.bodyParams); + payload = Promise.await(decryptIfNeeded(this.request, this.bodyParams)); } catch (err) { return API.v1.failure('Could not decrypt payload'); } @@ -472,7 +473,7 @@ API.v1.addRoute('federation.events.dispatch', { authRequired: false, rateLimiter let eventResult; if (eventHandlers[event.type]) { - eventResult = await eventHandlers[event.type](event); + eventResult = Promise.await(eventHandlers[event.type](event)); } // If there was an error handling the event, take action @@ -480,7 +481,7 @@ API.v1.addRoute('federation.events.dispatch', { authRequired: false, rateLimiter try { serverLogger.debug({ msg: 'federation.events.dispatch => Event has missing parents', event }); - requestEventsFromLatest(event.origin, getFederationDomain(), contextDefinitions.defineType(event), event.context, eventResult.latestEventIds); + Promise.await(requestEventsFromLatest(event.origin, getFederationDomain(), contextDefinitions.defineType(event), event.context, eventResult.latestEventIds)); // And stop handling the events break; diff --git a/app/federation/server/endpoints/requestFromLatest.js b/app/federation/server/endpoints/requestFromLatest.js index cac0168c8c12d..84fd69f88d3af 100644 --- a/app/federation/server/endpoints/requestFromLatest.js +++ b/app/federation/server/endpoints/requestFromLatest.js @@ -8,7 +8,7 @@ import { isFederationEnabled } from '../lib/isFederationEnabled'; import { dispatchEvents } from '../handler'; API.v1.addRoute('federation.events.requestFromLatest', { authRequired: false }, { - async post() { + post() { if (!isFederationEnabled()) { return API.v1.failure('Federation not enabled'); } @@ -18,7 +18,7 @@ API.v1.addRoute('federation.events.requestFromLatest', { authRequired: false }, let payload; try { - payload = decryptIfNeeded(this.request, this.bodyParams); + payload = Promise.await(decryptIfNeeded(this.request, this.bodyParams)); } catch (err) { return API.v1.failure('Could not decrypt payload'); } @@ -54,7 +54,7 @@ API.v1.addRoute('federation.events.requestFromLatest', { authRequired: false }, } // Dispatch all the events, on the same request - dispatchEvents([fromDomain], missingEvents); + Promise.await(dispatchEvents([fromDomain], missingEvents)); return API.v1.success(); }, diff --git a/app/federation/server/endpoints/uploads.js b/app/federation/server/endpoints/uploads.js index 7735a630f15e7..a997b2aff3073 100644 --- a/app/federation/server/endpoints/uploads.js +++ b/app/federation/server/endpoints/uploads.js @@ -1,5 +1,5 @@ import { API } from '../../../api/server'; -import { Uploads } from '../../../models/server'; +import { Uploads } from '../../../models/server/raw'; import { FileUpload } from '../../../file-upload/server'; import { isFederationEnabled } from '../lib/isFederationEnabled'; @@ -11,7 +11,7 @@ API.v1.addRoute('federation.uploads', { authRequired: false }, { const { upload_id } = this.requestParams(); - const upload = Uploads.findOneById(upload_id); + const upload = Promise.await(Uploads.findOneById(upload_id)); if (!upload) { return API.v1.failure('There is no such file in this server'); diff --git a/app/federation/server/functions/addUser.js b/app/federation/server/functions/addUser.js index eebd1656260b2..314b7893fbc10 100644 --- a/app/federation/server/functions/addUser.js +++ b/app/federation/server/functions/addUser.js @@ -1,15 +1,16 @@ import { Meteor } from 'meteor/meteor'; import * as federationErrors from './errors'; -import { FederationServers, Users } from '../../../models/server'; +import { Users } from '../../../models/server'; +import { FederationServers } from '../../../models/server/raw'; import { getUserByUsername } from '../handler'; -export function addUser(query) { +export async function addUser(query) { if (!Meteor.userId()) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addUser' }); } - const user = getUserByUsername(query); + const user = await getUserByUsername(query); if (!user) { throw federationErrors.userNotFound(query); @@ -22,7 +23,7 @@ export function addUser(query) { userId = Users.create(user); // Refresh the servers list - FederationServers.refreshServers(); + await FederationServers.refreshServers(); } catch (err) { // This might get called twice by the createDirectMessage method // so we need to handle the situation accordingly diff --git a/app/federation/server/functions/dashboard.js b/app/federation/server/functions/dashboard.js index 137ef802c5dc1..3f256bf3d88ab 100644 --- a/app/federation/server/functions/dashboard.js +++ b/app/federation/server/functions/dashboard.js @@ -1,21 +1,22 @@ import { Meteor } from 'meteor/meteor'; -import { FederationServers, FederationRoomEvents, Users } from '../../../models/server'; +import { FederationRoomEvents, Users } from '../../../models/server'; +import { FederationServers } from '../../../models/server/raw'; -export function getStatistics() { +export async function getStatistics() { const numberOfEvents = FederationRoomEvents.find().count(); const numberOfFederatedUsers = Users.findRemote().count(); - const numberOfServers = FederationServers.find().count(); + const numberOfServers = await FederationServers.find().count(); return { numberOfEvents, numberOfFederatedUsers, numberOfServers }; } -export function federationGetOverviewData() { +export async function federationGetOverviewData() { if (!Meteor.userId()) { throw new Meteor.Error('not-authorized'); } - const { numberOfEvents, numberOfFederatedUsers, numberOfServers } = getStatistics(); + const { numberOfEvents, numberOfFederatedUsers, numberOfServers } = await getStatistics(); return { data: [{ @@ -31,12 +32,12 @@ export function federationGetOverviewData() { }; } -export function federationGetServers() { +export async function federationGetServers() { if (!Meteor.userId()) { throw new Meteor.Error('not-authorized'); } - const servers = FederationServers.find().fetch(); + const servers = await FederationServers.find().toArray(); return { data: servers, diff --git a/app/federation/server/functions/helpers.js b/app/federation/server/functions/helpers.js deleted file mode 100644 index 4113b6014edd2..0000000000000 --- a/app/federation/server/functions/helpers.js +++ /dev/null @@ -1,69 +0,0 @@ -import { Settings, Subscriptions, Users } from '../../../models/server'; -import { STATUS_ENABLED, STATUS_REGISTERING } from '../constants'; - -export const getNameAndDomain = (fullyQualifiedName) => fullyQualifiedName.split('@'); -export const isFullyQualified = (name) => name.indexOf('@') !== -1; - -export function isRegisteringOrEnabled() { - const status = Settings.findOneById('FEDERATION_Status'); - return [STATUS_ENABLED, STATUS_REGISTERING].includes(status && status.value); -} - -export function updateStatus(status) { - Settings.updateValueById('FEDERATION_Status', status); -} - -export function updateEnabled(enabled) { - Settings.updateValueById('FEDERATION_Enabled', enabled); -} - -export const checkRoomType = (room) => room.t === 'p' || room.t === 'd'; -export const checkRoomDomainsLength = (domains) => domains.length <= (process.env.FEDERATED_DOMAINS_LENGTH || 10); - -export const hasExternalDomain = ({ federation }) => { - // same test as isFederated(room) - if (!federation) { - return false; - } - - return federation.domains - .some((domain) => domain !== federation.origin); -}; - -export const isLocalUser = ({ federation }, localDomain) => - !federation || federation.origin === localDomain; - -export const getFederatedRoomData = (room) => { - let hasFederatedUser = false; - - let users = null; - let subscriptions = null; - - if (room.t === 'd') { - // Check if there is a federated user on this room - hasFederatedUser = room.usernames.some(isFullyQualified); - } else { - // Find all subscriptions of this room - subscriptions = Subscriptions.findByRoomIdWhenUsernameExists(room._id).fetch(); - subscriptions = subscriptions.reduce((acc, s) => { - acc[s.u._id] = s; - - return acc; - }, {}); - - // Get all user ids - const userIds = Object.keys(subscriptions); - - // Load all the users - users = Users.findUsersWithUsernameByIds(userIds).fetch(); - - // Check if there is a federated user on this room - hasFederatedUser = users.some((u) => isFullyQualified(u.username)); - } - - return { - hasFederatedUser, - users, - subscriptions, - }; -}; diff --git a/app/federation/server/functions/helpers.ts b/app/federation/server/functions/helpers.ts new file mode 100644 index 0000000000000..e8cb5b3e5170c --- /dev/null +++ b/app/federation/server/functions/helpers.ts @@ -0,0 +1,77 @@ +import { IRoom, isDirectMessageRoom } from '../../../../definition/IRoom'; +import { ISubscription } from '../../../../definition/ISubscription'; +import { IRegisterUser, IUser } from '../../../../definition/IUser'; +import { Subscriptions, Users } from '../../../models/server'; +import { Settings } from '../../../models/server/raw'; +import { STATUS_ENABLED, STATUS_REGISTERING } from '../constants'; + +export const getNameAndDomain = (fullyQualifiedName: string): string [] => fullyQualifiedName.split('@'); + +export const isFullyQualified = (name: string): boolean => name.indexOf('@') !== -1; + +export async function isRegisteringOrEnabled(): Promise { + const value = await Settings.getValueById('FEDERATION_Status'); + return typeof value === 'string' && [STATUS_ENABLED, STATUS_REGISTERING].includes(value); +} + +export async function updateStatus(status: string): Promise { + await Settings.updateValueById('FEDERATION_Status', status); +} + +export async function updateEnabled(enabled: boolean): Promise { + await Settings.updateValueById('FEDERATION_Enabled', enabled); +} + +export const checkRoomType = (room: IRoom): boolean => room.t === 'p' || room.t === 'd'; +export const checkRoomDomainsLength = (domains: unknown[]): boolean => domains.length <= (process.env.FEDERATED_DOMAINS_LENGTH || 10); + +export const hasExternalDomain = ({ federation }: { federation: { origin: string; domains: string[] } }): boolean => { + // same test as isFederated(room) + if (!federation) { + return false; + } + + return federation.domains + .some((domain) => domain !== federation.origin); +}; + +export const isLocalUser = ({ federation }: { federation: { origin: string } }, localDomain: string): boolean => + !federation || federation.origin === localDomain; + +export const getFederatedRoomData = (room: IRoom): { + hasFederatedUser: boolean; + users: IUser[]; + subscriptions: { [k: string]: ISubscription } | undefined; +} => { + if (isDirectMessageRoom(room)) { + // Check if there is a federated user on this room + + return { + users: [], + hasFederatedUser: room.usernames.some(isFullyQualified), + subscriptions: undefined, + }; + } + + // Find all subscriptions of this room + const s = Subscriptions.findByRoomIdWhenUsernameExists(room._id).fetch() as ISubscription[]; + const subscriptions = s.reduce((acc, s) => { + acc[s.u._id] = s; + return acc; + }, {} as { [k: string]: ISubscription }); + + // Get all user ids + const userIds = Object.keys(subscriptions); + + // Load all the users + const users: IRegisterUser[] = Users.findUsersWithUsernameByIds(userIds).fetch(); + + // Check if there is a federated user on this room + const hasFederatedUser = users.some((u) => isFullyQualified(u.username)); + + return { + hasFederatedUser, + users, + subscriptions, + }; +}; diff --git a/app/federation/server/handler/index.js b/app/federation/server/handler/index.js index 7827ddf063a78..46aec0b7dcea1 100644 --- a/app/federation/server/handler/index.js +++ b/app/federation/server/handler/index.js @@ -5,7 +5,7 @@ import { clientLogger } from '../lib/logger'; import { isFederationEnabled } from '../lib/isFederationEnabled'; import { federationRequestToPeer } from '../lib/http'; -export function federationSearchUsers(query) { +export async function federationSearchUsers(query) { if (!isFederationEnabled()) { throw disabled('client.searchUsers'); } @@ -16,12 +16,12 @@ export function federationSearchUsers(query) { const uri = `/api/v1/federation.users.search?${ qs.stringify({ username, domain: peerDomain }) }`; - const { data: { users } } = federationRequestToPeer('GET', peerDomain, uri); + const { data: { users } } = await federationRequestToPeer('GET', peerDomain, uri); return users; } -export function getUserByUsername(query) { +export async function getUserByUsername(query) { if (!isFederationEnabled()) { throw disabled('client.searchUsers'); } @@ -32,12 +32,12 @@ export function getUserByUsername(query) { const uri = `/api/v1/federation.users.getByUsername?${ qs.stringify({ username }) }`; - const { data: { user } } = federationRequestToPeer('GET', peerDomain, uri); + const { data: { user } } = await federationRequestToPeer('GET', peerDomain, uri); return user; } -export function requestEventsFromLatest(domain, fromDomain, contextType, contextQuery, latestEventIds) { +export async function requestEventsFromLatest(domain, fromDomain, contextType, contextQuery, latestEventIds) { if (!isFederationEnabled()) { throw disabled('client.requestEventsFromLatest'); } @@ -46,11 +46,11 @@ export function requestEventsFromLatest(domain, fromDomain, contextType, context const uri = '/api/v1/federation.events.requestFromLatest'; - federationRequestToPeer('POST', domain, uri, { fromDomain, contextType, contextQuery, latestEventIds }); + await federationRequestToPeer('POST', domain, uri, { fromDomain, contextType, contextQuery, latestEventIds }); } -export function dispatchEvents(domains, events) { +export async function dispatchEvents(domains, events) { if (!isFederationEnabled()) { throw disabled('client.dispatchEvents'); } @@ -61,17 +61,17 @@ export function dispatchEvents(domains, events) { const uri = '/api/v1/federation.events.dispatch'; - for (const domain of domains) { - federationRequestToPeer('POST', domain, uri, { events }, { ignoreErrors: true }); + for await (const domain of domains) { + await federationRequestToPeer('POST', domain, uri, { events }, { ignoreErrors: true }); } } -export function dispatchEvent(domains, event) { - dispatchEvents([...new Set(domains)], [event]); +export async function dispatchEvent(domains, event) { + await dispatchEvents([...new Set(domains)], [event]); } -export function getUpload(domain, fileId) { - const { data: { upload, buffer } } = federationRequestToPeer('GET', domain, `/api/v1/federation.uploads?${ qs.stringify({ upload_id: fileId }) }`); +export async function getUpload(domain, fileId) { + const { data: { upload, buffer } } = await federationRequestToPeer('GET', domain, `/api/v1/federation.uploads?${ qs.stringify({ upload_id: fileId }) }`); return { upload, buffer: Buffer.from(buffer) }; } diff --git a/app/federation/server/hooks/afterCreateDirectRoom.js b/app/federation/server/hooks/afterCreateDirectRoom.js index ac05794e1c2ee..79e6fc992836e 100644 --- a/app/federation/server/hooks/afterCreateDirectRoom.js +++ b/app/federation/server/hooks/afterCreateDirectRoom.js @@ -41,7 +41,7 @@ async function afterCreateDirectRoom(room, extras) { })); // Dispatch the events - dispatchEvents(normalizedRoom.federation.domains, [genesisEvent, ...events]); + await dispatchEvents(normalizedRoom.federation.domains, [genesisEvent, ...events]); } catch (err) { await deleteRoom(room._id); diff --git a/app/federation/server/hooks/afterCreateRoom.js b/app/federation/server/hooks/afterCreateRoom.js index 75dfeeac6575a..905e108740cf8 100644 --- a/app/federation/server/hooks/afterCreateRoom.js +++ b/app/federation/server/hooks/afterCreateRoom.js @@ -47,7 +47,7 @@ export async function doAfterCreateRoom(room, users, subscriptions) { const genesisEvent = await FederationRoomEvents.createGenesisEvent(getFederationDomain(), normalizedRoom); // Dispatch the events - dispatchEvents(normalizedRoom.federation.domains, [genesisEvent, ...addUserEvents]); + await dispatchEvents(normalizedRoom.federation.domains, [genesisEvent, ...addUserEvents]); } async function afterCreateRoom(roomOwner, room) { diff --git a/app/federation/server/lib/crypt.js b/app/federation/server/lib/crypt.js index 7a231a13fb911..5a7685a2e9e09 100644 --- a/app/federation/server/lib/crypt.js +++ b/app/federation/server/lib/crypt.js @@ -1,19 +1,19 @@ -import { FederationKeys } from '../../../models/server'; +import { FederationKeys } from '../../../models/server/raw'; import { getFederationDomain } from './getFederationDomain'; import { search } from './dns'; import { cryptLogger } from './logger'; -export function decrypt(data, peerKey) { +export async function decrypt(data, peerKey) { // // Decrypt the payload const payloadBuffer = Buffer.from(data); // Decrypt with the peer's public key try { - data = FederationKeys.loadKey(peerKey, 'public').decryptPublic(payloadBuffer); + data = (await FederationKeys.loadKey(peerKey, 'public')).decryptPublic(payloadBuffer); // Decrypt with the local private key - data = FederationKeys.getPrivateKey().decrypt(data); + data = (await FederationKeys.getPrivateKey()).decrypt(data); } catch (err) { cryptLogger.error(err); @@ -23,7 +23,7 @@ export function decrypt(data, peerKey) { return JSON.parse(data.toString()); } -export function decryptIfNeeded(request, bodyParams) { +export async function decryptIfNeeded(request, bodyParams) { // // Look for the domain that sent this event const remotePeerDomain = request.headers['x-federation-domain']; @@ -48,17 +48,17 @@ export function decryptIfNeeded(request, bodyParams) { return decrypt(bodyParams, peerKey); } -export function encrypt(data, peerKey) { +export async function encrypt(data, peerKey) { if (!data) { return data; } try { // Encrypt with the peer's public key - data = FederationKeys.loadKey(peerKey, 'public').encrypt(data); + data = (await FederationKeys.loadKey(peerKey, 'public')).encrypt(data); // Encrypt with the local private key - return FederationKeys.getPrivateKey().encryptPrivate(data); + return (await FederationKeys.getPrivateKey()).encryptPrivate(data); } catch (err) { cryptLogger.error(err); diff --git a/app/federation/server/lib/dns.js b/app/federation/server/lib/dns.js index 0c4e2f348e1b9..0080ddae625be 100644 --- a/app/federation/server/lib/dns.js +++ b/app/federation/server/lib/dns.js @@ -17,12 +17,12 @@ const memoizedDnsResolveTXT = mem(dnsResolveTXT, { maxAge: cacheMaxAge }); const hubUrl = process.env.NODE_ENV === 'development' ? 'http://localhost:8080' : 'https://hub.rocket.chat'; -export function registerWithHub(peerDomain, url, publicKey) { +export async function registerWithHub(peerDomain, url, publicKey) { const body = { domain: peerDomain, url, public_key: publicKey }; try { // If there is no DNS entry for that, get from the Hub - federationRequest('POST', `${ hubUrl }/api/v1/peers`, body); + await federationRequest('POST', `${ hubUrl }/api/v1/peers`, body); return true; } catch (err) { @@ -32,12 +32,12 @@ export function registerWithHub(peerDomain, url, publicKey) { } } -export function searchHub(peerDomain) { +export async function searchHub(peerDomain) { try { dnsLogger.debug(`searchHub: peerDomain=${ peerDomain }`); // If there is no DNS entry for that, get from the Hub - const { data: { peer } } = federationRequest('GET', `${ hubUrl }/api/v1/peers?search=${ peerDomain }`); + const { data: { peer } } = await federationRequest('GET', `${ hubUrl }/api/v1/peers?search=${ peerDomain }`); if (!peer) { dnsLogger.debug(`searchHub: could not find peerDomain=${ peerDomain }`); diff --git a/app/federation/server/lib/http.js b/app/federation/server/lib/http.js index 542a2d32ef9ea..e18d09b8e86d8 100644 --- a/app/federation/server/lib/http.js +++ b/app/federation/server/lib/http.js @@ -6,14 +6,14 @@ import { getFederationDomain } from './getFederationDomain'; import { search } from './dns'; import { encrypt } from './crypt'; -export function federationRequest(method, url, body, headers, peerKey = null) { +export async function federationRequest(method, url, body, headers, peerKey = null) { let data = null; if ((method === 'POST' || method === 'PUT') && body) { data = EJSON.toJSONValue(body); if (peerKey) { - data = encrypt(data, peerKey); + data = await encrypt(data, peerKey); } } @@ -22,7 +22,7 @@ export function federationRequest(method, url, body, headers, peerKey = null) { return MeteorHTTP.call(method, url, { data, timeout: 2000, headers: { ...headers, 'x-federation-domain': getFederationDomain() } }); } -export function federationRequestToPeer(method, peerDomain, uri, body, options = {}) { +export async function federationRequestToPeer(method, peerDomain, uri, body, options = {}) { const ignoreErrors = peerDomain === getFederationDomain() ? false : options.ignoreErrors; const { url: baseUrl, publicKey } = search(peerDomain); @@ -39,7 +39,7 @@ export function federationRequestToPeer(method, peerDomain, uri, body, options = try { httpLogger.debug({ msg: 'federationRequestToPeer', url: `${ baseUrl }${ uri }` }); - result = federationRequest(method, `${ baseUrl }${ uri }`, body, options.headers || {}, peerKey); + result = await federationRequest(method, `${ baseUrl }${ uri }`, body, options.headers || {}, peerKey); } catch (err) { httpLogger.error({ msg: `${ ignoreErrors ? '[IGNORED] ' : '' }Error`, err }); diff --git a/app/federation/server/startup/generateKeys.js b/app/federation/server/startup/generateKeys.js index 012cdd0b48f47..32eaacc304184 100644 --- a/app/federation/server/startup/generateKeys.js +++ b/app/federation/server/startup/generateKeys.js @@ -1,6 +1,8 @@ -import { FederationKeys } from '../../../models/server'; +import { FederationKeys } from '../../../models/server/raw'; // Create key pair if needed -if (!FederationKeys.getPublicKey()) { - FederationKeys.generateKeys(); -} +(async () => { + if (!await FederationKeys.getPublicKey()) { + await FederationKeys.generateKeys(); + } +})(); diff --git a/app/federation/server/startup/settings.ts b/app/federation/server/startup/settings.ts index cfa7fda19e6af..36ade9e70eeb1 100644 --- a/app/federation/server/startup/settings.ts +++ b/app/federation/server/startup/settings.ts @@ -7,11 +7,11 @@ import { getFederationDiscoveryMethod } from '../lib/getFederationDiscoveryMetho import { registerWithHub } from '../lib/dns'; import { enableCallbacks, disableCallbacks } from '../lib/callbacks'; import { setupLogger } from '../lib/logger'; -import { FederationKeys } from '../../../models/server'; +import { FederationKeys } from '../../../models/server/raw'; import { STATUS_ENABLED, STATUS_REGISTERING, STATUS_ERROR_REGISTERING, STATUS_DISABLED } from '../constants'; -Meteor.startup(function() { - const federationPublicKey = FederationKeys.getPublicKeyString(); +Meteor.startup(async function() { + const federationPublicKey = await FederationKeys.getPublicKeyString(); settingsRegistry.addGroup('Federation', function() { this.add('FEDERATION_Enabled', false, { @@ -36,7 +36,7 @@ Meteor.startup(function() { // disableReset: true, }); - this.add('FEDERATION_Public_Key', federationPublicKey, { + this.add('FEDERATION_Public_Key', federationPublicKey || '', { readonly: true, type: 'string', multiline: true, @@ -65,26 +65,26 @@ Meteor.startup(function() { }); }); -const updateSettings = function(): void { +const updateSettings = async function(): Promise { // Get the key pair - if (getFederationDiscoveryMethod() === 'hub' && !isRegisteringOrEnabled()) { + if (getFederationDiscoveryMethod() === 'hub' && !Promise.await(isRegisteringOrEnabled())) { // Register with hub try { - updateStatus(STATUS_REGISTERING); + await updateStatus(STATUS_REGISTERING); - registerWithHub(getFederationDomain(), settings.get('Site_Url'), FederationKeys.getPublicKeyString()); + await registerWithHub(getFederationDomain(), settings.get('Site_Url'), await FederationKeys.getPublicKeyString()); - updateStatus(STATUS_ENABLED); + await updateStatus(STATUS_ENABLED); } catch (err) { // Disable federation - updateEnabled(false); + await updateEnabled(false); - updateStatus(STATUS_ERROR_REGISTERING); + await updateStatus(STATUS_ERROR_REGISTERING); } - } else { - updateStatus(STATUS_ENABLED); + return; } + await updateStatus(STATUS_ENABLED); }; // Add settings listeners @@ -92,11 +92,11 @@ settings.watch('FEDERATION_Enabled', function enableOrDisable(value) { setupLogger.info(`Federation is ${ value ? 'enabled' : 'disabled' }`); if (value) { - updateSettings(); + Promise.await(updateSettings()); enableCallbacks(); } else { - updateStatus(STATUS_DISABLED); + Promise.await(updateStatus(STATUS_DISABLED)); disableCallbacks(); } diff --git a/app/file-upload/server/lib/FileUpload.js b/app/file-upload/server/lib/FileUpload.js index aa8d49ab408eb..70ecac27f2037 100644 --- a/app/file-upload/server/lib/FileUpload.js +++ b/app/file-upload/server/lib/FileUpload.js @@ -2,6 +2,7 @@ import fs from 'fs'; import stream from 'stream'; import { Meteor } from 'meteor/meteor'; +import { Mongo } from 'meteor/mongo'; import streamBuffers from 'stream-buffers'; import Future from 'fibers/future'; import sharp from 'sharp'; @@ -13,9 +14,7 @@ import filesize from 'filesize'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; import { settings } from '../../../settings/server'; -import Uploads from '../../../models/server/models/Uploads'; -import UserDataFiles from '../../../models/server/models/UserDataFiles'; -import Avatars from '../../../models/server/models/Avatars'; +import { Avatars, UserDataFiles, Uploads } from '../../../models/server/raw'; import Users from '../../../models/server/models/Users'; import Rooms from '../../../models/server/models/Rooms'; import Settings from '../../../models/server/models/Settings'; @@ -41,6 +40,9 @@ settings.watch('FileUpload_MaxFileSize', function(value) { } }); +const AvatarModel = new Mongo.Collection(Avatars.col.collectionName); +const UserDataFilesModel = new Mongo.Collection(UserDataFiles.col.collectionName); +const UploadsModel = new Mongo.Collection(Uploads.col.collectionName); export const FileUpload = { handlers: {}, @@ -139,7 +141,7 @@ export const FileUpload = { defaultUploads() { return { - collection: Uploads.model, + collection: UploadsModel, filter: new UploadFS.Filter({ onCheck: FileUpload.validateFileUpload, }), @@ -161,7 +163,7 @@ export const FileUpload = { defaultAvatars() { return { - collection: Avatars.model, + collection: AvatarModel, filter: new UploadFS.Filter({ onCheck: FileUpload.validateAvatarUpload, }), @@ -176,7 +178,7 @@ export const FileUpload = { defaultUserDataFiles() { return { - collection: UserDataFiles.model, + collection: UserDataFilesModel, getPath(file) { return `${ settings.get('uniqueID') }/uploads/userData/${ file.userId }`; }, @@ -254,7 +256,7 @@ export const FileUpload = { }, resizeImagePreview(file) { - file = Uploads.findOneById(file._id); + file = Promise.await(Uploads.findOneById(file._id)); file = FileUpload.addExtensionTo(file); const image = FileUpload.getStore('Uploads')._store.getReadStream(file._id, file); @@ -279,7 +281,7 @@ export const FileUpload = { return; } - file = Uploads.findOneById(file._id); + file = Promise.await(Uploads.findOneById(file._id)); file = FileUpload.addExtensionTo(file); const store = FileUpload.getStore('Uploads'); const image = store._store.getReadStream(file._id, file); @@ -378,11 +380,11 @@ export const FileUpload = { } // update file record to match user's username const user = Users.findOneById(file.userId); - const oldAvatar = Avatars.findOneByName(user.username); + const oldAvatar = Promise.await(Avatars.findOneByName(user.username)); if (oldAvatar) { - Avatars.deleteFile(oldAvatar._id); + Promise.await(Avatars.deleteFile(oldAvatar._id)); } - Avatars.updateFileNameById(file._id, user.username); + Promise.await(Avatars.updateFileNameById(file._id, user.username)); // console.log('upload finished ->', file); }, @@ -567,15 +569,16 @@ export class FileUploadClass { } delete(fileId) { + // TODO: Remove this method if (this.store && this.store.delete) { this.store.delete(fileId); } - return this.model.deleteFile(fileId); + return Promise.await(this.model.deleteFile(fileId)); } deleteById(fileId) { - const file = this.model.findOneById(fileId); + const file = Promise.await(this.model.findOneById(fileId)); if (!file) { return; @@ -587,7 +590,7 @@ export class FileUploadClass { } deleteByName(fileName) { - const file = this.model.findOneByName(fileName); + const file = Promise.await(this.model.findOneByName(fileName)); if (!file) { return; @@ -600,7 +603,7 @@ export class FileUploadClass { deleteByRoomId(rid) { - const file = this.model.findOneByRoomId(rid); + const file = Promise.await(this.model.findOneByRoomId(rid)); if (!file) { return; diff --git a/app/file-upload/server/lib/requests.js b/app/file-upload/server/lib/requests.js index 80a3b4213b38d..3b2e8dad19d77 100644 --- a/app/file-upload/server/lib/requests.js +++ b/app/file-upload/server/lib/requests.js @@ -1,13 +1,13 @@ import { WebApp } from 'meteor/webapp'; import { FileUpload } from './FileUpload'; -import { Uploads } from '../../../models'; +import { Uploads } from '../../../models/server/raw'; -WebApp.connectHandlers.use(FileUpload.getPath(), function(req, res, next) { +WebApp.connectHandlers.use(FileUpload.getPath(), async function(req, res, next) { const match = /^\/([^\/]+)\/(.*)/.exec(req.url); if (match && match[1]) { - const file = Uploads.findOneById(match[1]); + const file = await Uploads.findOneById(match[1]); if (file) { if (!FileUpload.requestCanAccessFiles(req)) { diff --git a/app/file-upload/server/methods/getS3FileUrl.js b/app/file-upload/server/methods/getS3FileUrl.js index f68f720d171be..cfffdfcc032af 100644 --- a/app/file-upload/server/methods/getS3FileUrl.js +++ b/app/file-upload/server/methods/getS3FileUrl.js @@ -2,7 +2,7 @@ import { Meteor } from 'meteor/meteor'; import { UploadFS } from 'meteor/jalik:ufs'; import { settings } from '../../../settings/server'; -import { Uploads } from '../../../models'; +import { Uploads } from '../../../models/server/raw'; let protectedFiles; @@ -11,11 +11,11 @@ settings.watch('FileUpload_ProtectFiles', function(value) { }); Meteor.methods({ - getS3FileUrl(fileId) { + async getS3FileUrl(fileId) { if (protectedFiles && !Meteor.userId()) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'sendFileMessage' }); } - const file = Uploads.findOneById(fileId); + const file = await Uploads.findOneById(fileId); return UploadFS.getStore('AmazonS3:Uploads').getRedirectURL(file); }, diff --git a/app/file-upload/server/methods/sendFileMessage.ts b/app/file-upload/server/methods/sendFileMessage.ts index 80dec7bca683e..886f9167e07ee 100644 --- a/app/file-upload/server/methods/sendFileMessage.ts +++ b/app/file-upload/server/methods/sendFileMessage.ts @@ -3,8 +3,7 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; import _ from 'underscore'; -import { Uploads } from '../../../models/server'; -import { Rooms } from '../../../models/server/raw'; +import { Rooms, Uploads } from '../../../models/server/raw'; import { callbacks } from '../../../callbacks/server'; import { FileUpload } from '../lib/FileUpload'; import { canAccessRoom } from '../../../authorization/server/functions/canAccessRoom'; @@ -35,7 +34,7 @@ Meteor.methods({ tmid: Match.Optional(String), }); - Uploads.updateFileComplete(file._id, user._id, _.omit(file, '_id')); + await Uploads.updateFileComplete(file._id, user._id, _.omit(file, '_id')); const fileUrl = FileUpload.getPath(`${ file._id }/${ encodeURI(file.name) }`); diff --git a/app/google-oauth/server/index.js b/app/google-oauth/server/index.js index c785c3da47a5b..6d49f091fb203 100644 --- a/app/google-oauth/server/index.js +++ b/app/google-oauth/server/index.js @@ -22,6 +22,7 @@ Meteor.startup(() => { credentialSecret: escape(options.credentialSecret), storagePrefix: escape(OAuth._storageTokenPrefix), redirectUrl: escape(options.redirectUrl), + isCordova: Boolean(options.isCordova), }; let template; @@ -64,12 +65,14 @@ Meteor.startup(() => { } } + const isCordova = OAuth._isCordovaFromQuery(details.query); if (details.error) { res.end(renderEndOfLoginResponse({ loginStyle: details.loginStyle, setCredentialToken: false, redirectUrl, + isCordova, }), 'utf-8'); return; } @@ -83,6 +86,7 @@ Meteor.startup(() => { credentialToken: details.credentials.token, credentialSecret: details.credentials.secret, redirectUrl, + isCordova, }), 'utf-8'); }; }); diff --git a/app/highlight-words/tests/helper.tests.js b/app/highlight-words/tests/helper.tests.js index 28c5fd075164e..2b4e895d0e652 100644 --- a/app/highlight-words/tests/helper.tests.js +++ b/app/highlight-words/tests/helper.tests.js @@ -1,7 +1,4 @@ -/* eslint-env mocha */ - -import 'babel-polyfill'; -import assert from 'assert'; +import { expect } from 'chai'; import { highlightWords, getRegexHighlight, getRegexHighlightUrl } from '../client/helper'; @@ -14,7 +11,7 @@ describe('helper', () => { urlRegex: getRegexHighlightUrl(highlight), }))); - assert.equal(res, 'here is some word'); + expect(res).to.be.equal('here is some word'); }); describe('handles links', () => { @@ -25,7 +22,7 @@ describe('helper', () => { urlRegex: getRegexHighlightUrl(highlight), }))); - assert.equal(res, 'here we go https://somedomain.com/here-some.word/pulls more words after'); + expect(res).to.be.equal('here we go https://somedomain.com/here-some.word/pulls more words after'); }); it('not highlighting two links', () => { @@ -36,7 +33,7 @@ describe('helper', () => { urlRegex: getRegexHighlightUrl(highlight), }))); - assert.equal(res, msg); + expect(res).to.be.equal(msg); }); it('not highlighting link but keep words on message highlighted', () => { @@ -46,7 +43,7 @@ describe('helper', () => { urlRegex: getRegexHighlightUrl(highlight), }))); - assert.equal(res, 'here we go https://somedomain.com/here-some.foo/pulls more foo after'); + expect(res).to.be.equal('here we go https://somedomain.com/here-some.foo/pulls more foo after'); }); }); }); diff --git a/app/importer/server/classes/ImportDataConverter.ts b/app/importer/server/classes/ImportDataConverter.ts index 9f09a44d49bd6..906ead79effcc 100644 --- a/app/importer/server/classes/ImportDataConverter.ts +++ b/app/importer/server/classes/ImportDataConverter.ts @@ -219,11 +219,18 @@ export class ImportDataConverter { userData._id = _id; + if (!userData.roles && !existingUser.roles) { + userData.roles = ['user']; + } + if (!userData.type && !existingUser.type) { + userData.type = 'user'; + } + // #ToDo: #TODO: Move this to the model class const updateData: Record = { $set: { - roles: userData.roles || ['user'], - type: userData.type || 'user', + ...userData.roles && { roles: userData.roles }, + ...userData.type && { type: userData.type }, ...userData.statusText && { statusText: userData.statusText }, ...userData.bio && { bio: userData.bio }, ...userData.services?.ldap && { ldap: true }, @@ -235,7 +242,13 @@ export class ImportDataConverter { this.addUserServices(updateData, userData); this.addUserImportId(updateData, userData); this.addUserEmails(updateData, userData, existingUser.emails || []); - Users.update({ _id }, updateData); + + if (Object.keys(updateData.$set).length === 0) { + delete updateData.$set; + } + if (Object.keys(updateData).length > 0) { + Users.update({ _id }, updateData); + } if (userData.utcOffset) { Users.setUtcOffset(_id, userData.utcOffset); diff --git a/app/integrations/server/api/api.js b/app/integrations/server/api/api.js index 17a04b6c35822..eb223c67c9ad6 100644 --- a/app/integrations/server/api/api.js +++ b/app/integrations/server/api/api.js @@ -14,6 +14,7 @@ import { incomingLogger } from '../logger'; import { processWebhookMessage } from '../../../lib/server'; import { API, APIClass, defaultRateLimiterOptions } from '../../../api/server'; import * as Models from '../../../models/server'; +import { Integrations } from '../../../models/server/raw'; import { settings } from '../../../settings/server'; const compiledScripts = {}; @@ -129,9 +130,10 @@ function removeIntegration(options, user) { incomingLogger.info('Remove integration'); incomingLogger.debug(options); - const integrationToRemove = Models.Integrations.findOne({ - urls: options.target_url, - }); + const integrationToRemove = Promise.await(Integrations.findOneByUrl(options.target_url)); + if (!integrationToRemove) { + return API.v1.failure('integration-not-found'); + } Meteor.runAsUser(user._id, () => Meteor.call('deleteOutgoingIntegration', integrationToRemove._id)); @@ -373,10 +375,10 @@ const Api = new WebHookAPI({ } } - this.integration = Models.Integrations.findOne({ + this.integration = Promise.await(Integrations.findOne({ _id: this.request.params.integrationId, token: decodeURIComponent(this.request.params.token), - }); + })); if (!this.integration) { incomingLogger.info(`Invalid integration id ${ this.request.params.integrationId } or token ${ this.request.params.token }`); diff --git a/app/integrations/server/lib/triggerHandler.js b/app/integrations/server/lib/triggerHandler.js index 33cc33ddb24d1..27f71a4e5729d 100644 --- a/app/integrations/server/lib/triggerHandler.js +++ b/app/integrations/server/lib/triggerHandler.js @@ -10,6 +10,7 @@ import Fiber from 'fibers'; import Future from 'fibers/future'; import * as Models from '../../../models/server'; +import { Integrations, IntegrationHistory } from '../../../models/server/raw'; import { settings } from '../../../settings/server'; import { getRoomByNameOrIdWithOptionToJoin, processWebhookMessage } from '../../../lib/server'; import { outgoingLogger } from '../logger'; @@ -22,7 +23,7 @@ export class RocketChatIntegrationHandler { this.compiledScripts = {}; this.triggers = {}; - Models.Integrations.find({ type: 'webhook-outgoing' }).fetch().forEach((data) => this.addIntegration(data)); + Promise.await(Integrations.find({ type: 'webhook-outgoing' }).forEach((data) => this.addIntegration(data))); } addIntegration(record) { @@ -142,11 +143,11 @@ export class RocketChatIntegrationHandler { } if (historyId) { - Models.IntegrationHistory.update({ _id: historyId }, { $set: history }); + Promise.await(IntegrationHistory.updateOne({ _id: historyId }, { $set: history })); return historyId; } history._createdAt = new Date(); - return Models.IntegrationHistory.insert(Object.assign({ _id: Random.id() }, history)); + return Promise.await(IntegrationHistory.insertOne({ _id: Random.id(), ...history })); } // Trigger is the trigger, nameOrId is a string which is used to try and find a room, room is a room, message is a message, and data contains "user_name" if trigger.impersonateUser is truthful. @@ -715,7 +716,7 @@ export class RocketChatIntegrationHandler { if (result.statusCode === 410) { this.updateHistory({ historyId, step: 'after-process-http-status-410', error: true }); outgoingLogger.error(`Disabling the Integration "${ trigger.name }" because the status code was 401 (Gone).`); - Models.Integrations.update({ _id: trigger._id }, { $set: { enabled: false } }); + Promise.await(Integrations.updateOne({ _id: trigger._id }, { $set: { enabled: false } })); return; } diff --git a/app/integrations/server/methods/clearIntegrationHistory.js b/app/integrations/server/methods/clearIntegrationHistory.ts similarity index 70% rename from app/integrations/server/methods/clearIntegrationHistory.js rename to app/integrations/server/methods/clearIntegrationHistory.ts index 87eec581e37aa..f4ef3e974b961 100644 --- a/app/integrations/server/methods/clearIntegrationHistory.js +++ b/app/integrations/server/methods/clearIntegrationHistory.ts @@ -1,17 +1,20 @@ import { Meteor } from 'meteor/meteor'; -import { hasPermission } from '../../../authorization'; -import { IntegrationHistory, Integrations } from '../../../models'; +import { hasPermission } from '../../../authorization/server'; +import { IntegrationHistory, Integrations } from '../../../models/server/raw'; import notifications from '../../../notifications/server/lib/Notifications'; Meteor.methods({ - clearIntegrationHistory(integrationId) { + async clearIntegrationHistory(integrationId) { let integration; if (hasPermission(this.userId, 'manage-outgoing-integrations') || hasPermission(this.userId, 'manage-outgoing-integrations', 'bot')) { - integration = Integrations.findOne(integrationId); + integration = await Integrations.findOneById(integrationId); } else if (hasPermission(this.userId, 'manage-own-outgoing-integrations') || hasPermission(this.userId, 'manage-own-outgoing-integrations', 'bot')) { - integration = Integrations.findOne(integrationId, { fields: { '_createdBy._id': this.userId } }); + integration = await Integrations.findOne({ + _id: integrationId, + '_createdBy._id': this.userId, + }); } else { throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'clearIntegrationHistory' }); } @@ -20,7 +23,7 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { method: 'clearIntegrationHistory' }); } - IntegrationHistory.removeByIntegrationId(integrationId); + await IntegrationHistory.removeByIntegrationId(integrationId); notifications.streamIntegrationHistory.emit(integrationId, { type: 'removed' }); diff --git a/app/integrations/server/methods/incoming/addIncomingIntegration.js b/app/integrations/server/methods/incoming/addIncomingIntegration.js index 6e86dacd5700e..23b339ed48fb4 100644 --- a/app/integrations/server/methods/incoming/addIncomingIntegration.js +++ b/app/integrations/server/methods/incoming/addIncomingIntegration.js @@ -4,13 +4,14 @@ import { Babel } from 'meteor/babel-compiler'; import _ from 'underscore'; import s from 'underscore.string'; -import { hasPermission, hasAllPermission } from '../../../../authorization'; -import { Users, Rooms, Integrations, Roles, Subscriptions } from '../../../../models'; +import { hasPermission, hasAllPermission } from '../../../../authorization/server'; +import { Users, Rooms, Subscriptions } from '../../../../models/server'; +import { Integrations, Roles } from '../../../../models/server/raw'; const validChannelChars = ['@', '#']; Meteor.methods({ - addIncomingIntegration(integration) { + async addIncomingIntegration(integration) { if (!hasPermission(this.userId, 'manage-incoming-integrations') && !hasPermission(this.userId, 'manage-own-incoming-integrations')) { throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'addIncomingIntegration' }); } @@ -95,9 +96,11 @@ Meteor.methods({ integration._createdAt = new Date(); integration._createdBy = Users.findOne(this.userId, { fields: { username: 1 } }); - Roles.addUserRoles(user._id, 'bot'); + await Roles.addUserRoles(user._id, 'bot'); - integration._id = Integrations.insert(integration); + const result = await Integrations.insertOne(integration); + + integration._id = result.insertedId; return integration; }, diff --git a/app/integrations/server/methods/incoming/deleteIncomingIntegration.js b/app/integrations/server/methods/incoming/deleteIncomingIntegration.ts similarity index 56% rename from app/integrations/server/methods/incoming/deleteIncomingIntegration.js rename to app/integrations/server/methods/incoming/deleteIncomingIntegration.ts index 96c25116a10d2..bbd158f20ae0f 100644 --- a/app/integrations/server/methods/incoming/deleteIncomingIntegration.js +++ b/app/integrations/server/methods/incoming/deleteIncomingIntegration.ts @@ -1,16 +1,19 @@ import { Meteor } from 'meteor/meteor'; -import { hasPermission } from '../../../../authorization'; -import { Integrations } from '../../../../models'; +import { hasPermission } from '../../../../authorization/server'; +import { Integrations } from '../../../../models/server/raw'; Meteor.methods({ - deleteIncomingIntegration(integrationId) { + async deleteIncomingIntegration(integrationId) { let integration; if (hasPermission(this.userId, 'manage-incoming-integrations')) { - integration = Integrations.findOne(integrationId); + integration = Integrations.findOneById(integrationId); } else if (hasPermission(this.userId, 'manage-own-incoming-integrations')) { - integration = Integrations.findOne(integrationId, { fields: { '_createdBy._id': this.userId } }); + integration = Integrations.findOne({ + _id: integrationId, + '_createdBy._id': this.userId, + }); } else { throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'deleteIncomingIntegration' }); } @@ -19,7 +22,7 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { method: 'deleteIncomingIntegration' }); } - Integrations.remove({ _id: integrationId }); + await Integrations.removeById(integrationId); return true; }, diff --git a/app/integrations/server/methods/incoming/updateIncomingIntegration.js b/app/integrations/server/methods/incoming/updateIncomingIntegration.js index 5e7b3517ba0ed..fc5a6d384b951 100644 --- a/app/integrations/server/methods/incoming/updateIncomingIntegration.js +++ b/app/integrations/server/methods/incoming/updateIncomingIntegration.js @@ -3,13 +3,14 @@ import { Babel } from 'meteor/babel-compiler'; import _ from 'underscore'; import s from 'underscore.string'; -import { Integrations, Rooms, Users, Roles, Subscriptions } from '../../../../models'; -import { hasAllPermission, hasPermission } from '../../../../authorization'; +import { Rooms, Users, Subscriptions } from '../../../../models/server'; +import { Integrations, Roles } from '../../../../models/server/raw'; +import { hasAllPermission, hasPermission } from '../../../../authorization/server'; const validChannelChars = ['@', '#']; Meteor.methods({ - updateIncomingIntegration(integrationId, integration) { + async updateIncomingIntegration(integrationId, integration) { if (!_.isString(integration.channel) || integration.channel.trim() === '') { throw new Meteor.Error('error-invalid-channel', 'Invalid channel', { method: 'updateIncomingIntegration' }); } @@ -25,9 +26,9 @@ Meteor.methods({ let currentIntegration; if (hasPermission(this.userId, 'manage-incoming-integrations')) { - currentIntegration = Integrations.findOne(integrationId); + currentIntegration = await Integrations.findOneById(integrationId); } else if (hasPermission(this.userId, 'manage-own-incoming-integrations')) { - currentIntegration = Integrations.findOne({ _id: integrationId, '_createdBy._id': this.userId }); + currentIntegration = await Integrations.findOne({ _id: integrationId, '_createdBy._id': this.userId }); } else { throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'updateIncomingIntegration' }); } @@ -43,14 +44,14 @@ Meteor.methods({ integration.scriptCompiled = Babel.compile(integration.script, babelOptions).code; integration.scriptError = undefined; - Integrations.update(integrationId, { + await Integrations.updateOne({ _id: integrationId }, { $set: { scriptCompiled: integration.scriptCompiled }, $unset: { scriptError: 1 }, }); } catch (e) { integration.scriptCompiled = undefined; integration.scriptError = _.pick(e, 'name', 'message', 'stack'); - Integrations.update(integrationId, { + await Integrations.updateOne({ _id: integrationId }, { $set: { scriptError: integration.scriptError, }, @@ -100,9 +101,9 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-post-as-user', 'Invalid Post As User', { method: 'updateIncomingIntegration' }); } - Roles.addUserRoles(user._id, 'bot'); + await Roles.addUserRoles(user._id, 'bot'); - Integrations.update(integrationId, { + await Integrations.updateOne({ _id: integrationId }, { $set: { enabled: integration.enabled, name: integration.name, @@ -117,6 +118,6 @@ Meteor.methods({ }, }); - return Integrations.findOne(integrationId); + return Integrations.findOneById(integrationId); }, }); diff --git a/app/integrations/server/methods/outgoing/addOutgoingIntegration.js b/app/integrations/server/methods/outgoing/addOutgoingIntegration.js index 5baf6e88cda45..ae6f1aa6933db 100644 --- a/app/integrations/server/methods/outgoing/addOutgoingIntegration.js +++ b/app/integrations/server/methods/outgoing/addOutgoingIntegration.js @@ -1,11 +1,12 @@ import { Meteor } from 'meteor/meteor'; -import { hasPermission } from '../../../../authorization'; -import { Users, Integrations } from '../../../../models'; +import { hasPermission } from '../../../../authorization/server'; +import { Users } from '../../../../models/server'; +import { Integrations } from '../../../../models/server/raw'; import { integrations } from '../../../lib/rocketchat'; Meteor.methods({ - addOutgoingIntegration(integration) { + async addOutgoingIntegration(integration) { if (!hasPermission(this.userId, 'manage-outgoing-integrations') && !hasPermission(this.userId, 'manage-own-outgoing-integrations') && !hasPermission(this.userId, 'manage-outgoing-integrations', 'bot') @@ -17,7 +18,9 @@ Meteor.methods({ integration._createdAt = new Date(); integration._createdBy = Users.findOne(this.userId, { fields: { username: 1 } }); - integration._id = Integrations.insert(integration); + + const result = await Integrations.insertOne(integration); + integration._id = result.insertedId; return integration; }, diff --git a/app/integrations/server/methods/outgoing/deleteOutgoingIntegration.js b/app/integrations/server/methods/outgoing/deleteOutgoingIntegration.ts similarity index 63% rename from app/integrations/server/methods/outgoing/deleteOutgoingIntegration.js rename to app/integrations/server/methods/outgoing/deleteOutgoingIntegration.ts index 07823b22bb2cb..a63e845eaa77a 100644 --- a/app/integrations/server/methods/outgoing/deleteOutgoingIntegration.js +++ b/app/integrations/server/methods/outgoing/deleteOutgoingIntegration.ts @@ -1,16 +1,19 @@ import { Meteor } from 'meteor/meteor'; -import { hasPermission } from '../../../../authorization'; -import { IntegrationHistory, Integrations } from '../../../../models'; +import { hasPermission } from '../../../../authorization/server'; +import { IntegrationHistory, Integrations } from '../../../../models/server/raw'; Meteor.methods({ - deleteOutgoingIntegration(integrationId) { + async deleteOutgoingIntegration(integrationId) { let integration; if (hasPermission(this.userId, 'manage-outgoing-integrations') || hasPermission(this.userId, 'manage-outgoing-integrations', 'bot')) { - integration = Integrations.findOne(integrationId); + integration = Integrations.findOneById(integrationId); } else if (hasPermission(this.userId, 'manage-own-outgoing-integrations') || hasPermission(this.userId, 'manage-own-outgoing-integrations', 'bot')) { - integration = Integrations.findOne(integrationId, { fields: { '_createdBy._id': this.userId } }); + integration = Integrations.findOne({ + _id: integrationId, + '_createdBy._id': this.userId, + }); } else { throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'deleteOutgoingIntegration' }); } @@ -19,8 +22,8 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { method: 'deleteOutgoingIntegration' }); } - Integrations.remove({ _id: integrationId }); - IntegrationHistory.removeByIntegrationId(integrationId); + await Integrations.removeById(integrationId); + await IntegrationHistory.removeByIntegrationId(integrationId); return true; }, diff --git a/app/integrations/server/methods/outgoing/replayOutgoingIntegration.js b/app/integrations/server/methods/outgoing/replayOutgoingIntegration.ts similarity index 69% rename from app/integrations/server/methods/outgoing/replayOutgoingIntegration.js rename to app/integrations/server/methods/outgoing/replayOutgoingIntegration.ts index 8d88cde3ea28d..bf3136525bc99 100644 --- a/app/integrations/server/methods/outgoing/replayOutgoingIntegration.js +++ b/app/integrations/server/methods/outgoing/replayOutgoingIntegration.ts @@ -1,17 +1,20 @@ import { Meteor } from 'meteor/meteor'; -import { hasPermission } from '../../../../authorization'; -import { Integrations, IntegrationHistory } from '../../../../models'; +import { hasPermission } from '../../../../authorization/server'; +import { Integrations, IntegrationHistory } from '../../../../models/server/raw'; import { triggerHandler } from '../../lib/triggerHandler'; Meteor.methods({ - replayOutgoingIntegration({ integrationId, historyId }) { + async replayOutgoingIntegration({ integrationId, historyId }) { let integration; if (hasPermission(this.userId, 'manage-outgoing-integrations') || hasPermission(this.userId, 'manage-outgoing-integrations', 'bot')) { - integration = Integrations.findOne(integrationId); + integration = await Integrations.findOneById(integrationId); } else if (hasPermission(this.userId, 'manage-own-outgoing-integrations') || hasPermission(this.userId, 'manage-own-outgoing-integrations', 'bot')) { - integration = Integrations.findOne(integrationId, { fields: { '_createdBy._id': this.userId } }); + integration = await Integrations.findOne({ + _id: integrationId, + '_createdBy._id': this.userId, + }); } else { throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'replayOutgoingIntegration' }); } @@ -20,7 +23,7 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { method: 'replayOutgoingIntegration' }); } - const history = IntegrationHistory.findOneByIntegrationIdAndHistoryId(integration._id, historyId); + const history = await IntegrationHistory.findOneByIntegrationIdAndHistoryId(integration._id, historyId); if (!history) { throw new Meteor.Error('error-invalid-integration-history', 'Invalid Integration History', { method: 'replayOutgoingIntegration' }); diff --git a/app/integrations/server/methods/outgoing/updateOutgoingIntegration.js b/app/integrations/server/methods/outgoing/updateOutgoingIntegration.js index 981a7890bc290..e9e4bc1ba9682 100644 --- a/app/integrations/server/methods/outgoing/updateOutgoingIntegration.js +++ b/app/integrations/server/methods/outgoing/updateOutgoingIntegration.js @@ -1,11 +1,12 @@ import { Meteor } from 'meteor/meteor'; -import { hasPermission } from '../../../../authorization'; -import { Integrations, Users } from '../../../../models'; +import { hasPermission } from '../../../../authorization/server'; +import { Users } from '../../../../models/server'; +import { Integrations } from '../../../../models/server/raw'; import { integrations } from '../../../lib/rocketchat'; Meteor.methods({ - updateOutgoingIntegration(integrationId, integration) { + async updateOutgoingIntegration(integrationId, integration) { integration = integrations.validateOutgoing(integration, this.userId); if (!integration.token || integration.token.trim() === '') { @@ -15,9 +16,9 @@ Meteor.methods({ let currentIntegration; if (hasPermission(this.userId, 'manage-outgoing-integrations')) { - currentIntegration = Integrations.findOne(integrationId); + currentIntegration = await Integrations.findOneById(integrationId); } else if (hasPermission(this.userId, 'manage-own-outgoing-integrations')) { - currentIntegration = Integrations.findOne({ _id: integrationId, '_createdBy._id': this.userId }); + currentIntegration = await Integrations.findOne({ _id: integrationId, '_createdBy._id': this.userId }); } else { throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'updateOutgoingIntegration' }); } @@ -26,18 +27,18 @@ Meteor.methods({ throw new Meteor.Error('invalid_integration', '[methods] updateOutgoingIntegration -> integration not found'); } if (integration.scriptCompiled) { - Integrations.update(integrationId, { + await Integrations.updateOne({ _id: integrationId }, { $set: { scriptCompiled: integration.scriptCompiled }, $unset: { scriptError: 1 }, }); } else { - Integrations.update(integrationId, { + await Integrations.updateOne({ _id: integrationId }, { $set: { scriptError: integration.scriptError }, $unset: { scriptCompiled: 1 }, }); } - Integrations.update(integrationId, { + await Integrations.updateOne({ _id: integrationId }, { $set: { event: integration.event, enabled: integration.enabled, @@ -65,6 +66,6 @@ Meteor.methods({ }, }); - return Integrations.findOne(integrationId); + return Integrations.findOneById(integrationId); }, }); diff --git a/app/invites/server/functions/findOrCreateInvite.js b/app/invites/server/functions/findOrCreateInvite.js index c875a53906d0a..3b608e7121e05 100644 --- a/app/invites/server/functions/findOrCreateInvite.js +++ b/app/invites/server/functions/findOrCreateInvite.js @@ -3,7 +3,8 @@ import { Random } from 'meteor/random'; import { hasPermission } from '../../../authorization'; import { Notifications } from '../../../notifications'; -import { Invites, Subscriptions, Rooms } from '../../../models/server'; +import { Subscriptions, Rooms } from '../../../models/server'; +import { Invites } from '../../../models/server/raw'; import { settings } from '../../../settings'; import { getURL } from '../../../utils/lib/getURL'; import { roomTypes, RoomMemberActions } from '../../../utils/server'; @@ -23,7 +24,7 @@ function getInviteUrl(invite) { const possibleDays = [0, 1, 7, 15, 30]; const possibleUses = [0, 1, 5, 10, 25, 50, 100]; -export const findOrCreateInvite = (userId, invite) => { +export const findOrCreateInvite = async (userId, invite) => { if (!userId || !invite) { return false; } @@ -57,7 +58,7 @@ export const findOrCreateInvite = (userId, invite) => { } // Before anything, let's check if there's an existing invite with the same settings for the same channel and user and that has not yet expired. - const existing = Invites.findOneByUserRoomMaxUsesAndExpiration(userId, invite.rid, maxUses, days); + const existing = await Invites.findOneByUserRoomMaxUsesAndExpiration(userId, invite.rid, maxUses, days); // If an existing invite was found, return it's _id instead of creating a new one. if (existing) { @@ -86,7 +87,7 @@ export const findOrCreateInvite = (userId, invite) => { uses: 0, }; - Invites.create(createInvite); + await Invites.insertOne(createInvite); Notifications.notifyUser(userId, 'updateInvites', { invite: createInvite }); createInvite.url = getInviteUrl(createInvite); diff --git a/app/invites/server/functions/listInvites.js b/app/invites/server/functions/listInvites.js index 476a5f729e092..10d67435237dc 100644 --- a/app/invites/server/functions/listInvites.js +++ b/app/invites/server/functions/listInvites.js @@ -1,9 +1,9 @@ import { Meteor } from 'meteor/meteor'; -import { hasPermission } from '../../../authorization'; -import { Invites } from '../../../models'; +import { hasPermission } from '../../../authorization/server'; +import { Invites } from '../../../models/server/raw'; -export const listInvites = (userId) => { +export const listInvites = async (userId) => { if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'listInvites' }); } @@ -12,5 +12,5 @@ export const listInvites = (userId) => { throw new Meteor.Error('not_authorized'); } - return Invites.find({}).fetch(); + return Invites.find({}).toArray(); }; diff --git a/app/invites/server/functions/removeInvite.js b/app/invites/server/functions/removeInvite.js index eadbe67966b3b..0ea066a8e2879 100644 --- a/app/invites/server/functions/removeInvite.js +++ b/app/invites/server/functions/removeInvite.js @@ -1,9 +1,9 @@ import { Meteor } from 'meteor/meteor'; import { hasPermission } from '../../../authorization'; -import Invites from '../../../models/server/models/Invites'; +import { Invites } from '../../../models/server/raw'; -export const removeInvite = (userId, invite) => { +export const removeInvite = async (userId, invite) => { if (!userId || !invite) { return false; } @@ -17,13 +17,13 @@ export const removeInvite = (userId, invite) => { } // Before anything, let's check if there's an existing invite - const existing = Invites.findOneById(invite._id); + const existing = await Invites.findOneById(invite._id); if (!existing) { throw new Meteor.Error('invalid-invitation-id', 'Invalid Invitation _id', { method: 'removeInvite' }); } - Invites.removeById(invite._id); + await Invites.removeById(invite._id); return true; }; diff --git a/app/invites/server/functions/useInviteToken.js b/app/invites/server/functions/useInviteToken.js index 3cf638fd3e942..6fcef0a407883 100644 --- a/app/invites/server/functions/useInviteToken.js +++ b/app/invites/server/functions/useInviteToken.js @@ -1,11 +1,12 @@ import { Meteor } from 'meteor/meteor'; -import { Invites, Users, Subscriptions } from '../../../models/server'; +import { Users, Subscriptions } from '../../../models/server'; +import { Invites } from '../../../models/server/raw'; import { validateInviteToken } from './validateInviteToken'; import { addUserToRoom } from '../../../lib/server/functions/addUserToRoom'; import { roomTypes, RoomMemberActions } from '../../../utils/server'; -export const useInviteToken = (userId, token) => { +export const useInviteToken = async (userId, token) => { if (!userId) { throw new Meteor.Error('error-invalid-user', 'The user is invalid', { method: 'useInviteToken', field: 'userId' }); } @@ -14,7 +15,7 @@ export const useInviteToken = (userId, token) => { throw new Meteor.Error('error-invalid-token', 'The invite token is invalid.', { method: 'useInviteToken', field: 'token' }); } - const { inviteData, room } = validateInviteToken(token); + const { inviteData, room } = await validateInviteToken(token); if (!roomTypes.getConfig(room.t).allowMemberAction(room, RoomMemberActions.INVITE)) { throw new Meteor.Error('error-room-type-not-allowed', 'Can\'t join room of this type via invite', { method: 'useInviteToken', field: 'token' }); @@ -25,7 +26,7 @@ export const useInviteToken = (userId, token) => { const subscription = Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { fields: { _id: 1 } }); if (!subscription) { - Invites.increaseUsageById(inviteData._id); + await Invites.increaseUsageById(inviteData._id); } // If the user already has an username, then join the invite room, diff --git a/app/invites/server/functions/validateInviteToken.js b/app/invites/server/functions/validateInviteToken.js index dda8add8b6123..81febb4394423 100644 --- a/app/invites/server/functions/validateInviteToken.js +++ b/app/invites/server/functions/validateInviteToken.js @@ -1,13 +1,14 @@ import { Meteor } from 'meteor/meteor'; -import { Invites, Rooms } from '../../../models'; +import { Rooms } from '../../../models'; +import { Invites } from '../../../models/server/raw'; -export const validateInviteToken = (token) => { +export const validateInviteToken = async (token) => { if (!token || typeof token !== 'string') { throw new Meteor.Error('error-invalid-token', 'The invite token is invalid.', { method: 'validateInviteToken', field: 'token' }); } - const inviteData = Invites.findOneById(token); + const inviteData = await Invites.findOneById(token); if (!inviteData) { throw new Meteor.Error('error-invalid-token', 'The invite token is invalid.', { method: 'validateInviteToken', field: 'token' }); } diff --git a/app/lib/server/functions/addOAuthService.ts b/app/lib/server/functions/addOAuthService.ts index a41bb2ee174e7..1a2a220fcddc7 100644 --- a/app/lib/server/functions/addOAuthService.ts +++ b/app/lib/server/functions/addOAuthService.ts @@ -57,6 +57,18 @@ export function addOAuthService(name: string, values: { [k: string]: string | bo enterprise: true, invalidValue: false, modules: ['oauth-enterprise'] }); + settingsRegistry.add(`Accounts_OAuth_Custom-${ name }-roles_to_sync` , values.rolesToSync || '', { type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${ name }`, + i18nLabel: 'Accounts_OAuth_Custom_Roles_To_Sync', + i18nDescription: 'Accounts_OAuth_Custom_Roles_To_Sync_Description', + enterprise: true, + enableQuery: { + _id: `Accounts_OAuth_Custom-${ name }-merge_roles`, + value: true, + }, + invalidValue: '', + modules: ['oauth-enterprise'] }); settingsRegistry.add(`Accounts_OAuth_Custom-${ name }-merge_users`, values.mergeUsers || false, { type: 'boolean', group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Merge_Users', persistent: true }); settingsRegistry.add(`Accounts_OAuth_Custom-${ name }-show_button` , values.showButton || true , { type: 'boolean', group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Show_Button_On_Login_Page', persistent: true }); settingsRegistry.add(`Accounts_OAuth_Custom-${ name }-groups_channel_map` , values.channelsMap || '{\n\t"rocket-admin": "admin",\n\t"tech-support": "support"\n}' , { type: 'code' , multiline: true, code: 'application/json', group: 'OAuth', section: `Custom OAuth: ${ name }`, i18nLabel: 'Accounts_OAuth_Custom_Channel_Map', persistent: true }); diff --git a/app/lib/server/functions/closeOmnichannelConversations.ts b/app/lib/server/functions/closeOmnichannelConversations.ts index 46be29d8a9848..c5cff5081f35f 100644 --- a/app/lib/server/functions/closeOmnichannelConversations.ts +++ b/app/lib/server/functions/closeOmnichannelConversations.ts @@ -12,8 +12,9 @@ type SubscribedRooms = { export const closeOmnichannelConversations = (user: IUser, subscribedRooms: SubscribedRooms[]): void => { const roomsInfo = LivechatRooms.findByIds(subscribedRooms.map(({ rid }) => rid)); - const language = settings.get('Language') || 'en'; - roomsInfo.map((room: any) => - Livechat.closeRoom({ user, visitor: {}, room, comment: TAPi18n.__('Agent_deactivated', { lng: language }) }), - ); + const language = settings.get('Language') || 'en'; + const comment = TAPi18n.__('Agent_deactivated', { lng: language }); + roomsInfo.forEach((room: any) => { + Livechat.closeRoom({ user, visitor: {}, room, comment }); + }); }; diff --git a/app/lib/server/functions/deleteMessage.ts b/app/lib/server/functions/deleteMessage.ts index 8f698842e205e..96e563b1a4648 100644 --- a/app/lib/server/functions/deleteMessage.ts +++ b/app/lib/server/functions/deleteMessage.ts @@ -2,14 +2,15 @@ import { Meteor } from 'meteor/meteor'; import { FileUpload } from '../../../file-upload/server'; import { settings } from '../../../settings/server'; -import { Messages, Uploads, Rooms } from '../../../models/server'; +import { Messages, Rooms } from '../../../models/server'; +import { Uploads } from '../../../models/server/raw'; import { Notifications } from '../../../notifications/server'; import { callbacks } from '../../../callbacks/server'; import { Apps } from '../../../apps/server'; import { IMessage } from '../../../../definition/IMessage'; import { IUser } from '../../../../definition/IUser'; -export const deleteMessage = function(message: IMessage, user: IUser): void { +export const deleteMessage = async function(message: IMessage, user: IUser): Promise { const deletedMsg = Messages.findOneById(message._id); const isThread = deletedMsg.tcount > 0; const keepHistory = settings.get('Message_KeepHistory') || isThread; @@ -36,9 +37,9 @@ export const deleteMessage = function(message: IMessage, user: IUser): void { Messages.setHiddenById(message._id, true); } - files.forEach((file) => { - file?._id && Uploads.update(file._id, { $set: { _hidden: true } }); - }); + for await (const file of files) { + file?._id && await Uploads.update({ _id: file._id }, { $set: { _hidden: true } }); + } } else { if (!showDeletedStatus) { Messages.removeById(message._id); diff --git a/app/lib/server/functions/deleteUser.js b/app/lib/server/functions/deleteUser.js index 680517db54055..4193774e11364 100644 --- a/app/lib/server/functions/deleteUser.js +++ b/app/lib/server/functions/deleteUser.js @@ -2,7 +2,8 @@ import { Meteor } from 'meteor/meteor'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import { FileUpload } from '../../../file-upload/server'; -import { Users, Subscriptions, Messages, Rooms, Integrations, FederationServers } from '../../../models/server'; +import { Users, Subscriptions, Messages, Rooms } from '../../../models/server'; +import { FederationServers, Integrations } from '../../../models/server/raw'; import { settings } from '../../../settings/server'; import { updateGroupDMsName } from './updateGroupDMsName'; import { relinquishRoomOwnerships } from './relinquishRoomOwnerships'; @@ -10,7 +11,7 @@ import { getSubscribedRoomsForUserWithDetails, shouldRemoveOrChangeOwner } from import { getUserSingleOwnedRooms } from './getUserSingleOwnedRooms'; import { api } from '../../../../server/sdk/api'; -export const deleteUser = function(userId, confirmRelinquish = false) { +export async function deleteUser(userId, confirmRelinquish = false) { const user = Users.findOneById(userId, { fields: { username: 1, avatarOrigin: 1, federation: 1 }, }); @@ -36,7 +37,7 @@ export const deleteUser = function(userId, confirmRelinquish = false) { // Users without username can't do anything, so there is nothing to remove if (user.username != null) { - relinquishRoomOwnerships(userId, subscribedRooms); + await relinquishRoomOwnerships(userId, subscribedRooms); const messageErasureType = settings.get('Message_ErasureType'); switch (messageErasureType) { @@ -64,7 +65,7 @@ export const deleteUser = function(userId, confirmRelinquish = false) { FileUpload.getStore('Avatars').deleteByName(user.username); } - Integrations.disableByUserId(userId); // Disables all the integrations which rely on the user being deleted. + await Integrations.disableByUserId(userId); // Disables all the integrations which rely on the user being deleted. api.broadcast('user.deleted', user); } @@ -75,5 +76,5 @@ export const deleteUser = function(userId, confirmRelinquish = false) { updateGroupDMsName(user); // Refresh the servers list - FederationServers.refreshServers(); -}; + await FederationServers.refreshServers(); +} diff --git a/app/lib/server/functions/relinquishRoomOwnerships.js b/app/lib/server/functions/relinquishRoomOwnerships.js index 7c56e3bc05a52..f5c403f1b2b14 100644 --- a/app/lib/server/functions/relinquishRoomOwnerships.js +++ b/app/lib/server/functions/relinquishRoomOwnerships.js @@ -1,5 +1,6 @@ import { FileUpload } from '../../../file-upload/server'; -import { Subscriptions, Messages, Rooms, Roles } from '../../../models/server'; +import { Subscriptions, Messages, Rooms } from '../../../models/server'; +import { Roles } from '../../../models/server/raw'; const bulkRoomCleanUp = (rids) => { // no bulk deletion for files @@ -12,11 +13,14 @@ const bulkRoomCleanUp = (rids) => { ])); }; -export const relinquishRoomOwnerships = function(userId, subscribedRooms, removeDirectMessages = true) { +export const relinquishRoomOwnerships = async function(userId, subscribedRooms, removeDirectMessages = true) { // change owners - subscribedRooms - .filter(({ shouldChangeOwner }) => shouldChangeOwner) - .forEach(({ newOwner, rid }) => Roles.addUserRoles(newOwner, ['owner'], rid)); + const changeOwner = subscribedRooms + .filter(({ shouldChangeOwner }) => shouldChangeOwner); + + for await (const { newOwner, rid } of changeOwner) { + await Roles.addUserRoles(newOwner, ['owner'], rid); + } const roomIdsToRemove = subscribedRooms.filter(({ shouldBeRemoved }) => shouldBeRemoved).map(({ rid }) => rid); diff --git a/app/lib/server/functions/setRoomAvatar.js b/app/lib/server/functions/setRoomAvatar.js index 9b0ea487c7583..540de27992019 100644 --- a/app/lib/server/functions/setRoomAvatar.js +++ b/app/lib/server/functions/setRoomAvatar.js @@ -2,13 +2,14 @@ import { Meteor } from 'meteor/meteor'; import { RocketChatFile } from '../../../file'; import { FileUpload } from '../../../file-upload'; -import { Rooms, Avatars, Messages } from '../../../models/server'; +import { Rooms, Messages } from '../../../models/server'; +import { Avatars } from '../../../models/server/raw'; import { api } from '../../../../server/sdk/api'; -export const setRoomAvatar = function(rid, dataURI, user) { +export const setRoomAvatar = async function(rid, dataURI, user) { const fileStore = FileUpload.getStore('Avatars'); - const current = Avatars.findOneByRoomId(rid); + const current = await Avatars.findOneByRoomId(rid); if (!dataURI) { fileStore.deleteByRoomId(rid); diff --git a/app/lib/server/functions/setUserActiveStatus.js b/app/lib/server/functions/setUserActiveStatus.js index 8f1207ad898c1..77a137695fdda 100644 --- a/app/lib/server/functions/setUserActiveStatus.js +++ b/app/lib/server/functions/setUserActiveStatus.js @@ -43,7 +43,7 @@ export function setUserActiveStatus(userId, active, confirmRelinquish = false) { // Users without username can't do anything, so there is no need to check for owned rooms if (user.username != null && !active) { - const userAdmin = Users.findOneAdmin(userId.count); + const userAdmin = Users.findOneAdmin(userId); const adminsCount = Users.findActiveUsersInRoles(['admin']).count(); if (userAdmin && adminsCount === 1) { throw new Meteor.Error('error-action-not-allowed', 'Leaving the app without an active admin is not allowed', { @@ -63,7 +63,7 @@ export function setUserActiveStatus(userId, active, confirmRelinquish = false) { } closeOmnichannelConversations(user, livechatSubscribedRooms); - relinquishRoomOwnerships(user, chatSubscribedRooms, false); + Promise.await(relinquishRoomOwnerships(user, chatSubscribedRooms, false)); } if (active && !user.active) { diff --git a/app/lib/server/functions/setUsername.js b/app/lib/server/functions/setUsername.js index 8795fb6b01fcf..97a05291f5d1b 100644 --- a/app/lib/server/functions/setUsername.js +++ b/app/lib/server/functions/setUsername.js @@ -3,7 +3,8 @@ import s from 'underscore.string'; import { Accounts } from 'meteor/accounts-base'; import { settings } from '../../../settings'; -import { Users, Invites } from '../../../models/server'; +import { Users } from '../../../models/server'; +import { Invites } from '../../../models/server/raw'; import { hasPermission } from '../../../authorization'; import { RateLimiter } from '../lib'; import { addUserToRoom } from './addUserToRoom'; @@ -70,7 +71,7 @@ export const _setUsername = function(userId, u, fullUser) { // If it's the first username and the user has an invite Token, then join the invite room if (!previousUsername && user.inviteToken) { - const inviteData = Invites.findOneById(user.inviteToken); + const inviteData = Promise.await(Invites.findOneById(user.inviteToken)); if (inviteData && inviteData.rid) { addUserToRoom(inviteData.rid, user); } diff --git a/app/lib/server/index.js b/app/lib/server/index.js index ed1b32afd5a2a..0a0e59bc49b6c 100644 --- a/app/lib/server/index.js +++ b/app/lib/server/index.js @@ -7,7 +7,6 @@ import './startup/settingsOnLoadCdnPrefix'; import './startup/settingsOnLoadDirectReply'; import './startup/settingsOnLoadSMTP'; import '../lib/MessageTypes'; -import '../startup'; import '../startup/defaultRoomTypes'; import './lib/bugsnag'; import './lib/debug'; diff --git a/app/lib/server/lib/getRoomRoles.js b/app/lib/server/lib/getRoomRoles.js index 9c3718628782a..6ed6527c53686 100644 --- a/app/lib/server/lib/getRoomRoles.js +++ b/app/lib/server/lib/getRoomRoles.js @@ -1,7 +1,8 @@ import _ from 'underscore'; import { settings } from '../../../settings'; -import { Subscriptions, Users, Roles } from '../../../models'; +import { Subscriptions, Users } from '../../../models'; +import { Roles } from '../../../models/server/raw'; export function getRoomRoles(rid) { const options = { @@ -17,7 +18,7 @@ export function getRoomRoles(rid) { const UI_Use_Real_Name = settings.get('UI_Use_Real_Name') === true; - const roles = Roles.find({ scope: 'Subscriptions', description: { $exists: 1, $ne: '' } }).fetch(); + const roles = Promise.await(Roles.find({ scope: 'Subscriptions', description: { $exists: 1, $ne: '' } }).toArray()); const subscriptions = Subscriptions.findByRoomIdAndRoles(rid, _.pluck(roles, '_id'), options).fetch(); if (!UI_Use_Real_Name) { diff --git a/app/lib/server/lib/processDirectEmail.js b/app/lib/server/lib/processDirectEmail.js index 3b97938d46943..98313697028f8 100644 --- a/app/lib/server/lib/processDirectEmail.js +++ b/app/lib/server/lib/processDirectEmail.js @@ -5,7 +5,7 @@ import moment from 'moment'; import { settings } from '../../../settings/server'; import { Rooms, Messages, Users, Subscriptions } from '../../../models/server'; import { metrics } from '../../../metrics/server'; -import { hasPermission } from '../../../authorization/server'; +import { canAccessRoom, hasPermission } from '../../../authorization/server'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { sendMessage as _sendMessage } from '../functions'; @@ -55,29 +55,25 @@ export const processDirectEmail = function(email) { } message.rid = prevMessage.rid; - const room = Meteor.call('canAccessRoom', message.rid, user._id); - if (!room) { + const room = Rooms.findOneById(message.rid); + + if (!canAccessRoom(room, user)) { return false; } - const roomInfo = Rooms.findOneById(message.rid, { - t: 1, - name: 1, - }); - // check mention - if (message.msg.indexOf(`@${ prevMessage.u.username }`) === -1 && roomInfo.t !== 'd') { + if (message.msg.indexOf(`@${ prevMessage.u.username }`) === -1 && room.t !== 'd') { message.msg = `@${ prevMessage.u.username } ${ message.msg }`; } // reply message link let prevMessageLink = `[ ](${ Meteor.absoluteUrl().replace(/\/$/, '') }`; - if (roomInfo.t === 'c') { - prevMessageLink += `/channel/${ roomInfo.name }?msg=${ email.headers.mid }) `; - } else if (roomInfo.t === 'd') { + if (room.t === 'c') { + prevMessageLink += `/channel/${ room.name }?msg=${ email.headers.mid }) `; + } else if (room.t === 'd') { prevMessageLink += `/direct/${ prevMessage.u.username }?msg=${ email.headers.mid }) `; - } else if (roomInfo.t === 'p') { - prevMessageLink += `/group/${ roomInfo.name }?msg=${ email.headers.mid }) `; + } else if (room.t === 'p') { + prevMessageLink += `/group/${ room.name }?msg=${ email.headers.mid }) `; } // add reply message link message.msg = prevMessageLink + message.msg; diff --git a/app/lib/server/methods/deleteMessage.js b/app/lib/server/methods/deleteMessage.js index 086b9caee7f91..8be7da4e5e456 100644 --- a/app/lib/server/methods/deleteMessage.js +++ b/app/lib/server/methods/deleteMessage.js @@ -6,7 +6,7 @@ import { Messages } from '../../../models'; import { deleteMessage } from '../functions'; Meteor.methods({ - deleteMessage(message) { + async deleteMessage(message) { check(message, Match.ObjectIncluding({ _id: String, })); diff --git a/app/lib/server/methods/deleteUserOwnAccount.js b/app/lib/server/methods/deleteUserOwnAccount.js index 1ff7494a87516..2d7f269b08f75 100644 --- a/app/lib/server/methods/deleteUserOwnAccount.js +++ b/app/lib/server/methods/deleteUserOwnAccount.js @@ -9,7 +9,7 @@ import { Users } from '../../../models'; import { deleteUser } from '../functions'; Meteor.methods({ - deleteUserOwnAccount(password, confirmRelinquish) { + async deleteUserOwnAccount(password, confirmRelinquish) { check(password, String); if (!Meteor.userId()) { @@ -39,7 +39,7 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-username', 'Invalid username', { method: 'deleteUserOwnAccount' }); } - deleteUser(userId, confirmRelinquish); + await deleteUser(userId, confirmRelinquish); return true; }, diff --git a/app/lib/server/methods/getChannelHistory.js b/app/lib/server/methods/getChannelHistory.js index 25c645231038b..80237842b118d 100644 --- a/app/lib/server/methods/getChannelHistory.js +++ b/app/lib/server/methods/getChannelHistory.js @@ -2,8 +2,8 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; import _ from 'underscore'; -import { hasPermission } from '../../../authorization/server'; -import { Subscriptions, Messages } from '../../../models/server'; +import { canAccessRoom, hasPermission } from '../../../authorization/server'; +import { Subscriptions, Messages, Rooms } from '../../../models/server'; import { settings } from '../../../settings/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; import { getHiddenSystemMessages } from '../lib/getHiddenSystemMessages'; @@ -17,11 +17,15 @@ Meteor.methods({ } const fromUserId = Meteor.userId(); - const room = Meteor.call('canAccessRoom', rid, fromUserId); + const room = Rooms.findOneById(rid); if (!room) { return false; } + if (!canAccessRoom(room, { _id: fromUserId })) { + return false; + } + // Make sure they can access the room if (room.t === 'c' && !hasPermission(fromUserId, 'preview-c-room') && !Subscriptions.findOneByRoomIdAndUserId(rid, fromUserId, { fields: { _id: 1 } })) { return false; diff --git a/app/lib/server/methods/getMessages.js b/app/lib/server/methods/getMessages.js index d4b3ce4bd20a4..f8ccbbbb6f740 100644 --- a/app/lib/server/methods/getMessages.js +++ b/app/lib/server/methods/getMessages.js @@ -1,28 +1,22 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; -import { Messages } from '../../../models'; +import { canAccessRoom } from '../../../authorization/server'; +import { Messages } from '../../../models/server'; Meteor.methods({ getMessages(messages) { check(messages, [String]); - const cache = {}; + const msgs = Messages.findVisibleByIds(messages).fetch(); - return messages.map((msgId) => { - const msg = Messages.findOneById(msgId); + const user = { _id: Meteor.userId() }; - if (!msg || !msg.rid) { - return undefined; - } + const rids = [...new Set(msgs.map((m) => m.rid))]; + if (!rids.every((_id) => canAccessRoom({ _id }, user))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getSingleMessage' }); + } - cache[msg.rid] = cache[msg.rid] || Meteor.call('canAccessRoom', msg.rid, Meteor.userId()); - - if (!cache[msg.rid]) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getSingleMessage' }); - } - - return msg; - }); + return msgs; }, }); diff --git a/app/lib/server/methods/getSingleMessage.js b/app/lib/server/methods/getSingleMessage.js index 399aa335cab33..604ac2f1b4f70 100644 --- a/app/lib/server/methods/getSingleMessage.js +++ b/app/lib/server/methods/getSingleMessage.js @@ -1,7 +1,8 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; -import { Messages } from '../../../models'; +import { canAccessRoom } from '../../../authorization/server'; +import { Messages } from '../../../models/server'; Meteor.methods({ getSingleMessage(msgId) { @@ -13,7 +14,7 @@ Meteor.methods({ return undefined; } - if (!Meteor.call('canAccessRoom', msg.rid, Meteor.userId())) { + if (!canAccessRoom({ _id: msg.rid }, { _id: Meteor.userId() })) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getSingleMessage' }); } diff --git a/app/lib/server/methods/leaveRoom.js b/app/lib/server/methods/leaveRoom.ts similarity index 76% rename from app/lib/server/methods/leaveRoom.js rename to app/lib/server/methods/leaveRoom.ts index 561d7bdb548ac..cce12a25b8f84 100644 --- a/app/lib/server/methods/leaveRoom.js +++ b/app/lib/server/methods/leaveRoom.ts @@ -1,13 +1,14 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; -import { hasPermission, hasRole, getUsersInRole } from '../../../authorization'; -import { Subscriptions, Rooms } from '../../../models'; +import { hasPermission, hasRole } from '../../../authorization/server'; +import { Subscriptions, Rooms } from '../../../models/server'; import { removeUserFromRoom } from '../functions'; import { roomTypes, RoomMemberActions } from '../../../utils/server'; +import { Roles } from '../../../models/server/raw'; Meteor.methods({ - leaveRoom(rid) { + async leaveRoom(rid) { check(rid, String); if (!Meteor.userId()) { @@ -17,7 +18,7 @@ Meteor.methods({ const room = Rooms.findOneById(rid); const user = Meteor.user(); - if (!roomTypes.getConfig(room.t).allowMemberAction(room, RoomMemberActions.LEAVE)) { + if (!user || !roomTypes.getConfig(room.t).allowMemberAction(room, RoomMemberActions.LEAVE)) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'leaveRoom' }); } @@ -32,7 +33,8 @@ Meteor.methods({ // If user is room owner, check if there are other owners. If there isn't anyone else, warn user to set a new owner. if (hasRole(user._id, 'owner', room._id)) { - const numOwners = getUsersInRole('owner', room._id).count(); + const cursor = await Roles.findUsersInRole('owner', room._id); + const numOwners = Promise.await(cursor.count()); if (numOwners === 1) { throw new Meteor.Error('error-you-are-last-owner', 'You are the last owner. Please set new owner before leaving the room.', { method: 'leaveRoom' }); } diff --git a/app/lib/server/methods/refreshOAuthService.js b/app/lib/server/methods/refreshOAuthService.ts similarity index 66% rename from app/lib/server/methods/refreshOAuthService.js rename to app/lib/server/methods/refreshOAuthService.ts index e0ef565cb45e1..14dbeaa1fcd6f 100644 --- a/app/lib/server/methods/refreshOAuthService.js +++ b/app/lib/server/methods/refreshOAuthService.ts @@ -1,11 +1,11 @@ import { Meteor } from 'meteor/meteor'; import { ServiceConfiguration } from 'meteor/service-configuration'; -import { hasPermission } from '../../../authorization'; -import { Settings } from '../../../models'; +import { hasPermission } from '../../../authorization/server'; +import { Settings } from '../../../models/server/raw'; Meteor.methods({ - refreshOAuthService() { + async refreshOAuthService() { if (!Meteor.userId()) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'refreshOAuthService' }); } @@ -16,6 +16,6 @@ Meteor.methods({ ServiceConfiguration.configurations.remove({}); - Settings.update({ _id: /^(Accounts_OAuth_|SAML_|CAS_|Blockstack_).+/ }, { $set: { _updatedAt: new Date() } }, { multi: true }); + await Settings.update({ _id: /^(Accounts_OAuth_|SAML_|CAS_|Blockstack_).+/ }, { $set: { _updatedAt: new Date() } }, { multi: true }); }, }); diff --git a/app/lib/server/methods/removeOAuthService.js b/app/lib/server/methods/removeOAuthService.js deleted file mode 100644 index 5c271f3e75f0e..0000000000000 --- a/app/lib/server/methods/removeOAuthService.js +++ /dev/null @@ -1,51 +0,0 @@ -import { capitalize } from '@rocket.chat/string-helpers'; -import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; - -import { hasPermission } from '../../../authorization'; -import { Settings } from '../../../models/server'; - -Meteor.methods({ - removeOAuthService(name) { - check(name, String); - - if (!Meteor.userId()) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'removeOAuthService' }); - } - - if (hasPermission(Meteor.userId(), 'add-oauth-service') !== true) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'removeOAuthService' }); - } - - name = name.toLowerCase().replace(/[^a-z0-9_]/g, ''); - name = capitalize(name); - Settings.removeById(`Accounts_OAuth_Custom-${ name }`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-url`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-token_path`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-identity_path`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-authorize_path`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-scope`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-access_token_param`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-token_sent_via`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-identity_token_sent_via`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-id`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-secret`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-button_label_text`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-button_label_color`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-button_color`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-login_style`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-key_field`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-username_field`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-email_field`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-name_field`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-avatar_field`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-roles_claim`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-merge_roles`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-merge_users`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-show_button`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-groups_claim`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-channels_admin`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-map_channels`); - Settings.removeById(`Accounts_OAuth_Custom-${ name }-groups_channel_map`); - }, -}); diff --git a/app/lib/server/methods/removeOAuthService.ts b/app/lib/server/methods/removeOAuthService.ts new file mode 100644 index 0000000000000..4aecbcdc53df1 --- /dev/null +++ b/app/lib/server/methods/removeOAuthService.ts @@ -0,0 +1,55 @@ +import { capitalize } from '@rocket.chat/string-helpers'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { hasPermission } from '../../../authorization/server'; +import { Settings } from '../../../models/server/raw'; + + +Meteor.methods({ + async removeOAuthService(name) { + check(name, String); + + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'removeOAuthService' }); + } + + if (hasPermission(Meteor.userId(), 'add-oauth-service') !== true) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'removeOAuthService' }); + } + + name = name.toLowerCase().replace(/[^a-z0-9_]/g, ''); + name = capitalize(name); + await Promise.all([ + Settings.removeById(`Accounts_OAuth_Custom-${ name }`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-url`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-token_path`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-identity_path`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-authorize_path`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-scope`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-access_token_param`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-token_sent_via`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-identity_token_sent_via`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-id`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-secret`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-button_label_text`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-button_label_color`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-button_color`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-login_style`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-key_field`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-username_field`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-email_field`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-name_field`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-avatar_field`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-roles_claim`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-merge_roles`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-roles_to_sync`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-merge_users`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-show_button`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-groups_claim`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-channels_admin`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-map_channels`), + Settings.removeById(`Accounts_OAuth_Custom-${ name }-groups_channel_map`), + ]); + }, +}); diff --git a/app/lib/server/methods/saveSetting.js b/app/lib/server/methods/saveSetting.js index b375b1ad5952c..993915db53f61 100644 --- a/app/lib/server/methods/saveSetting.js +++ b/app/lib/server/methods/saveSetting.js @@ -3,11 +3,11 @@ import { Match, check } from 'meteor/check'; import { hasPermission, hasAllPermission } from '../../../authorization/server'; import { getSettingPermissionId } from '../../../authorization/lib'; -import { Settings } from '../../../models'; import { twoFactorRequired } from '../../../2fa/server/twoFactorRequired'; +import { Settings } from '../../../models/server/raw'; Meteor.methods({ - saveSetting: twoFactorRequired(function(_id, value, editor) { + saveSetting: twoFactorRequired(async function(_id, value, editor) { const uid = Meteor.userId(); if (!uid) { throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { @@ -26,7 +26,7 @@ Meteor.methods({ // Verify the _id passed in is a string. check(_id, String); - const setting = Settings.db.findOneById(_id); + const setting = await Settings.findOneById(_id); // Verify the value is what it should be switch (setting.type) { @@ -44,7 +44,7 @@ Meteor.methods({ break; } - Settings.updateValueAndEditorById(_id, value, editor); + await Settings.updateValueAndEditorById(_id, value, editor); return true; }), }); diff --git a/app/lib/server/methods/saveSettings.js b/app/lib/server/methods/saveSettings.js index 6b99b3c7665cb..6da862bd9aa55 100644 --- a/app/lib/server/methods/saveSettings.js +++ b/app/lib/server/methods/saveSettings.js @@ -1,13 +1,13 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; -import { hasPermission } from '../../../authorization'; -import { Settings } from '../../../models'; +import { hasPermission } from '../../../authorization/server'; import { getSettingPermissionId } from '../../../authorization/lib'; import { twoFactorRequired } from '../../../2fa/server/twoFactorRequired'; +import { Settings } from '../../../models/server/raw'; Meteor.methods({ - saveSettings: twoFactorRequired(function(params = []) { + saveSettings: twoFactorRequired(async function(params = []) { const uid = Meteor.userId(); const settingsNotAllowed = []; if (uid === null) { @@ -18,16 +18,16 @@ Meteor.methods({ const editPrivilegedSetting = hasPermission(uid, 'edit-privileged-setting'); const manageSelectedSettings = hasPermission(uid, 'manage-selected-settings'); - params.forEach(({ _id, value }) => { + await Promise.all(params.map(async ({ _id, value }) => { // Verify the _id passed in is a string. check(_id, String); if (!editPrivilegedSetting && !(manageSelectedSettings && hasPermission(uid, getSettingPermissionId(_id)))) { return settingsNotAllowed.push(_id); } - const setting = Settings.db.findOneById(_id); + const setting = await Settings.findOneById(_id); // Verify the value is what it should be - switch (setting.type) { + switch (setting?.type) { case 'roomPick': check(value, Match.OneOf([Object], '')); break; @@ -44,7 +44,7 @@ Meteor.methods({ check(value, String); break; } - }); + })); if (settingsNotAllowed.length) { throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { @@ -53,8 +53,8 @@ Meteor.methods({ }); } - params.forEach(({ _id, value, editor }) => Settings.updateValueById(_id, value, editor)); + await Promise.all(params.map(({ _id, value, editor }) => Settings.updateValueById(_id, value, editor))); return true; - }), + }, {}), }); diff --git a/app/lib/server/startup/oAuthServicesUpdate.js b/app/lib/server/startup/oAuthServicesUpdate.js index f3206824e2771..c0a4d6f46b588 100644 --- a/app/lib/server/startup/oAuthServicesUpdate.js +++ b/app/lib/server/startup/oAuthServicesUpdate.js @@ -55,6 +55,7 @@ function _OAuthServicesUpdate() { data.mergeUsers = settings.get(`${ key }-merge_users`); data.mapChannels = settings.get(`${ key }-map_channels`); data.mergeRoles = settings.get(`${ key }-merge_roles`); + data.rolesToSync = settings.get(`${ key }-roles_to_sync`); data.showButton = settings.get(`${ key }-show_button`); new CustomOAuth(serviceName.toLowerCase(), { @@ -78,6 +79,7 @@ function _OAuthServicesUpdate() { channelsAdmin: data.channelsAdmin, mergeUsers: data.mergeUsers, mergeRoles: data.mergeRoles, + rolesToSync: data.rolesToSync, accessTokenParam: data.accessTokenParam, showButton: data.showButton, }); @@ -184,6 +186,7 @@ function customOAuthServicesInit() { mergeUsers: process.env[`${ serviceKey }_merge_users`] === 'true', mapChannels: process.env[`${ serviceKey }_map_channels`], mergeRoles: process.env[`${ serviceKey }_merge_roles`] === 'true', + rolesToSync: process.env[`${ serviceKey }_roles_to_sync`], showButton: process.env[`${ serviceKey }_show_button`] === 'true', avatarField: process.env[`${ serviceKey }_avatar_field`], }; diff --git a/app/lib/server/startup/rateLimiter.js b/app/lib/server/startup/rateLimiter.js index 464404b88048e..e10921f3e8dea 100644 --- a/app/lib/server/startup/rateLimiter.js +++ b/app/lib/server/startup/rateLimiter.js @@ -108,10 +108,9 @@ const checkNameForStream = (name) => name && !names.has(name) && name.startsWith const ruleIds = {}; -const callback = (message, name) => (reply, input) => { +const callback = (msg, name) => (reply, input) => { if (reply.allowed === false) { - logger.info('DDP RATE LIMIT:', message); - logger.info({ ...reply, ...input }); + logger.info({ msg, reply, input }); metrics.ddpRateLimitExceeded.inc({ limit_name: name, user_id: input.userId, diff --git a/app/lib/server/startup/settings.ts b/app/lib/server/startup/settings.ts index 9ad2a03b47c0e..8b427f03ddc84 100644 --- a/app/lib/server/startup/settings.ts +++ b/app/lib/server/startup/settings.ts @@ -1675,10 +1675,6 @@ settingsRegistry.addGroup('Setup_Wizard', function() { key: 'aerospaceDefense', i18nLabel: 'Aerospace_and_Defense', }, - { - key: 'blockchain', - i18nLabel: 'Blockchain', - }, { key: 'consulting', i18nLabel: 'Consulting', @@ -2977,7 +2973,7 @@ settingsRegistry.addGroup('Setup_Wizard', function() { }); settingsRegistry.addGroup('Rate Limiter', function() { - this.section('DDP Rate Limiter', function() { + this.section('DDP_Rate_Limiter', function() { this.add('DDP_Rate_Limit_IP_Enabled', true, { type: 'boolean' }); this.add('DDP_Rate_Limit_IP_Requests_Allowed', 120000, { type: 'int', enableQuery: { _id: 'DDP_Rate_Limit_IP_Enabled', value: true } }); this.add('DDP_Rate_Limit_IP_Interval_Time', 60000, { type: 'int', enableQuery: { _id: 'DDP_Rate_Limit_IP_Enabled', value: true } }); @@ -2999,12 +2995,16 @@ settingsRegistry.addGroup('Rate Limiter', function() { this.add('DDP_Rate_Limit_Connection_By_Method_Interval_Time', 10000, { type: 'int', enableQuery: { _id: 'DDP_Rate_Limit_Connection_By_Method_Enabled', value: true } }); }); - this.section('API Rate Limiter', function() { + this.section('API_Rate_Limiter', function() { this.add('API_Enable_Rate_Limiter', true, { type: 'boolean' }); this.add('API_Enable_Rate_Limiter_Dev', true, { type: 'boolean', enableQuery: { _id: 'API_Enable_Rate_Limiter', value: true } }); this.add('API_Enable_Rate_Limiter_Limit_Calls_Default', 10, { type: 'int', enableQuery: { _id: 'API_Enable_Rate_Limiter', value: true } }); this.add('API_Enable_Rate_Limiter_Limit_Time_Default', 60000, { type: 'int', enableQuery: { _id: 'API_Enable_Rate_Limiter', value: true } }); }); + + this.section('Feature_Limiting', function() { + this.add('Rate_Limiter_Limit_RegisterUser', 1, { type: 'int', enableQuery: { _id: 'API_Enable_Rate_Limiter', value: true } }); + }); }); settingsRegistry.addGroup('Troubleshoot', function() { diff --git a/app/lib/startup/index.js b/app/lib/startup/index.js deleted file mode 100644 index 06021c7418326..0000000000000 --- a/app/lib/startup/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import * as Mailer from '../../mailer'; -import { settings } from '../../settings'; - -Mailer.setSettings(settings); diff --git a/app/lib/tests/server.tests.js b/app/lib/tests/server.tests.js index cc6a4de04b1af..a606cff94901a 100644 --- a/app/lib/tests/server.tests.js +++ b/app/lib/tests/server.tests.js @@ -1,7 +1,3 @@ -/* eslint-env mocha */ -import 'babel-polyfill'; -import assert from 'assert'; - import { expect } from 'chai'; import './server.mocks.js'; @@ -41,11 +37,11 @@ describe('PasswordPolicyClass', () => { describe('Password tests with default options', () => { it('should allow all passwords', () => { const passwordPolice = new PasswordPolicyClass({ throwError: false }); - assert.equal(passwordPolice.validate(), false); - assert.equal(passwordPolice.validate(''), false); - assert.equal(passwordPolice.validate(' '), false); - assert.equal(passwordPolice.validate('a'), true); - assert.equal(passwordPolice.validate('aaaaaaaaa'), true); + expect(passwordPolice.validate()).to.be.equal(false); + expect(passwordPolice.validate('')).to.be.equal(false); + expect(passwordPolice.validate(' ')).to.be.equal(false); + expect(passwordPolice.validate('a')).to.be.equal(true); + expect(passwordPolice.validate('aaaaaaaaa')).to.be.equal(true); }); }); }); diff --git a/app/livechat/client/lib/chartHandler.js b/app/livechat/client/lib/chartHandler.js index 7af6388409b1d..f99571f0e66d0 100644 --- a/app/livechat/client/lib/chartHandler.js +++ b/app/livechat/client/lib/chartHandler.js @@ -194,9 +194,9 @@ export const drawDoughnutChart = async (chart, title, chartContext, dataLabels, data: dataPoints, // data points corresponding to data labels, x-axis points backgroundColor: [ '#2de0a5', - '#ffd21f', - '#f5455c', '#cbced1', + '#f5455c', + '#ffd21f', ], borderWidth: 0, }], diff --git a/app/livechat/client/views/app/livechatReadOnly.js b/app/livechat/client/views/app/livechatReadOnly.js index c751c701e9ead..74dce229f25cb 100644 --- a/app/livechat/client/views/app/livechatReadOnly.js +++ b/app/livechat/client/views/app/livechatReadOnly.js @@ -64,7 +64,7 @@ Template.livechatReadOnly.onCreated(function() { this.preparing = new ReactiveVar(true); this.updateInquiry = async ({ clientAction, ...inquiry }) => { - if (clientAction === 'removed' || !await callWithErrorHandling('canAccessRoom', inquiry.rid, Meteor.userId())) { + if (clientAction === 'removed') { // this will force to refresh the room // since the client wont get notified of room changes when chats are on queue (no one assigned) // a better approach should be performed when refactoring these templates to use react diff --git a/app/livechat/client/views/app/tabbar/contactChatHistoryMessages.html b/app/livechat/client/views/app/tabbar/contactChatHistoryMessages.html index 1b5e7ec2b104e..7b83a9db8de7f 100644 --- a/app/livechat/client/views/app/tabbar/contactChatHistoryMessages.html +++ b/app/livechat/client/views/app/tabbar/contactChatHistoryMessages.html @@ -33,17 +33,25 @@

{{_ "No_results_found"}}

{{else}} -
-
    - {{# with messageContext}} - {{#each msg in messages}}{{> message msg=msg room=room subscription=subscription settings=settings u=u}}{{/each}} - {{/with}} + {{#if hasError}} +
    +
    +

    {{_ "Not_found_or_not_allowed"}}

    +
    +
    + {{else}} +
    +
      + {{# with messageContext}} + {{#each msg in messages}}{{> message msg=msg room=room subscription=subscription settings=settings u=u}}{{/each}} + {{/with}} - {{#if isLoading}} - {{> loading}} - {{/if}} -
    -
    + {{#if isLoading}} + {{> loading}} + {{/if}} +
+
+ {{/if}} {{/if}} diff --git a/app/livechat/client/views/app/tabbar/contactChatHistoryMessages.js b/app/livechat/client/views/app/tabbar/contactChatHistoryMessages.js index c73136c07a8f6..cb3171f67c65b 100644 --- a/app/livechat/client/views/app/tabbar/contactChatHistoryMessages.js +++ b/app/livechat/client/views/app/tabbar/contactChatHistoryMessages.js @@ -36,6 +36,12 @@ Template.contactChatHistoryMessages.helpers({ empty() { return Template.instance().messages.get().length === 0; }, + hasError() { + return Template.instance().hasError.get(); + }, + error() { + return Template.instance().error.get(); + }, }); Template.contactChatHistoryMessages.events({ @@ -72,15 +78,23 @@ Template.contactChatHistoryMessages.onCreated(function() { this.searchTerm = new ReactiveVar(''); this.isLoading = new ReactiveVar(true); this.limit = new ReactiveVar(MESSAGES_LIMIT); + this.hasError = new ReactiveVar(false); + this.error = new ReactiveVar(null); this.loadMessages = async (url) => { this.isLoading.set(true); const offset = this.offset.get(); - const { messages, total } = await APIClient.v1.get(url); - this.messages.set(offset === 0 ? messages : this.messages.get().concat(messages)); - this.hasMore.set(total > this.messages.get().length); - this.isLoading.set(false); + try { + const { messages, total } = await APIClient.v1.get(url); + this.messages.set(offset === 0 ? messages : this.messages.get().concat(messages)); + this.hasMore.set(total > this.messages.get().length); + } catch (e) { + this.hasError.set(true); + this.error.set(e); + } finally { + this.isLoading.set(false); + } }; this.autorun(() => { @@ -92,7 +106,7 @@ Template.contactChatHistoryMessages.onCreated(function() { return this.loadMessages(`chat.search/?roomId=${ this.rid }&searchText=${ searchTerm }&count=${ limit }&offset=${ offset }&sort={"ts": 1}`); } - this.loadMessages(`channels.messages/?roomId=${ this.rid }&count=${ limit }&offset=${ offset }&sort={"ts": 1}&query={"$or": [ {"t": {"$exists": false} }, {"t": "livechat-close"} ] }`); + this.loadMessages(`livechat/${ this.rid }/messages?count=${ limit }&offset=${ offset }&sort={"ts": 1}`); }); this.autorun(() => { diff --git a/app/livechat/imports/server/rest/rooms.js b/app/livechat/imports/server/rest/rooms.js index d2f4c6e20d63f..9680b8baffce1 100644 --- a/app/livechat/imports/server/rest/rooms.js +++ b/app/livechat/imports/server/rest/rooms.js @@ -21,12 +21,13 @@ API.v1.addRoute('livechat/rooms', { authRequired: true }, { get() { const { offset, count } = this.getPaginationItems(); const { sort, fields } = this.parseJsonQuery(); - const { agents, departmentId, open, tags, roomName } = this.requestParams(); + const { agents, departmentId, open, tags, roomName, onhold } = this.requestParams(); let { createdAt, customFields, closedAt } = this.requestParams(); check(agents, Match.Maybe([String])); check(roomName, Match.Maybe(String)); check(departmentId, Match.Maybe(String)); check(open, Match.Maybe(String)); + check(onhold, Match.Maybe(String)); check(tags, Match.Maybe([String])); const hasAdminAccess = hasPermission(this.userId, 'view-livechat-rooms'); @@ -51,6 +52,7 @@ API.v1.addRoute('livechat/rooms', { authRequired: true }, { closedAt, tags, customFields, + onhold, options: { offset, count, sort, fields }, }))); }, diff --git a/app/livechat/imports/server/rest/sms.js b/app/livechat/imports/server/rest/sms.js index 4f29e7e997e0d..ac180f4c652ea 100644 --- a/app/livechat/imports/server/rest/sms.js +++ b/app/livechat/imports/server/rest/sms.js @@ -30,7 +30,7 @@ const defineDepartment = (idOrName) => { return department && department._id; }; -const defineVisitor = (smsNumber) => { +const defineVisitor = (smsNumber, targetDepartment) => { const visitor = LivechatVisitors.findOneVisitorByPhone(smsNumber); let data = { token: (visitor && visitor.token) || Random.id(), @@ -45,9 +45,8 @@ const defineVisitor = (smsNumber) => { }); } - const department = defineDepartment(SMS.department); - if (department) { - data.department = department; + if (targetDepartment) { + data.department = targetDepartment; } const id = Livechat.registerGuest(data); @@ -70,10 +69,15 @@ API.v1.addRoute('livechat/sms-incoming/:service', { post() { const SMSService = SMS.getService(this.urlParams.service); const sms = SMSService.parse(this.bodyParams); + const { department } = this.queryParams; + let targetDepartment = defineDepartment(department || SMS.department); + if (!targetDepartment) { + targetDepartment = defineDepartment(SMS.department); + } - const visitor = defineVisitor(sms.from); + const visitor = defineVisitor(sms.from, targetDepartment); const { token } = visitor; - const room = LivechatRooms.findOneOpenByVisitorToken(token); + const room = LivechatRooms.findOneOpenByVisitorTokenAndDepartmentId(token, targetDepartment); const roomExists = !!room; const location = normalizeLocationSharing(sms); const rid = (room && room._id) || Random.id(); diff --git a/app/livechat/imports/server/rest/visitors.ts b/app/livechat/imports/server/rest/visitors.ts new file mode 100644 index 0000000000000..e75d4a955a2ad --- /dev/null +++ b/app/livechat/imports/server/rest/visitors.ts @@ -0,0 +1,47 @@ + +import { check } from 'meteor/check'; + +import { API } from '../../../../api/server'; +import { LivechatRooms } from '../../../../models/server'; +import { Messages } from '../../../../models/server/raw'; +import { normalizeMessagesForUser } from '../../../../utils/server/lib/normalizeMessagesForUser'; +import { canAccessRoom } from '../../../../authorization/server'; +import { IMessage } from '../../../../../definition/IMessage'; + +API.v1.addRoute('livechat/:rid/messages', { authRequired: true, permissionsRequired: ['view-l-room'] }, { + async get() { + check(this.urlParams, { + rid: String, + }); + + const { offset, count } = this.getPaginationItems(); + const { sort } = this.parseJsonQuery(); + + const room = LivechatRooms.findOneById(this.urlParams.rid); + + if (!room) { + throw new Error('invalid-room'); + } + + if (!canAccessRoom(room, this.user)) { + throw new Error('not-allowed'); + } + + const cursor = Messages.findLivechatClosedMessages(this.urlParams.rid, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + }); + + const total = await cursor.count(); + + const messages = await cursor.toArray() as IMessage[]; + + return API.v1.success({ + messages: normalizeMessagesForUser(messages, this.userId), + offset, + count, + total, + }); + }, +}); diff --git a/app/livechat/lib/messageTypes.js b/app/livechat/lib/messageTypes.js index bde52192cbe9d..fb6fa4c10160f 100644 --- a/app/livechat/lib/messageTypes.js +++ b/app/livechat/lib/messageTypes.js @@ -1,4 +1,6 @@ +import formatDistance from 'date-fns/formatDistance'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; +import moment from 'moment'; import { MessageTypes } from '../../ui-utils'; @@ -81,6 +83,22 @@ MessageTypes.registerType({ message: 'New_videocall_request', }); +MessageTypes.registerType({ + id: 'livechat_webrtc_video_call', + render(message) { + if (message.msg === 'ended' && message.webRtcCallEndTs && message.ts) { + return TAPi18n.__('WebRTC_call_ended_message', { + callDuration: formatDistance(new Date(message.webRtcCallEndTs), new Date(message.ts)), + endTime: moment(message.webRtcCallEndTs).format('h:mm A'), + }); + } + if (message.msg === 'declined' && message.webRtcCallEndTs) { + return TAPi18n.__('WebRTC_call_declined_message'); + } + return message.msg; + }, +}); + MessageTypes.registerType({ id: 'omnichannel_placed_chat_on_hold', system: true, diff --git a/app/livechat/server/api.js b/app/livechat/server/api.js index 6a13dddc86bf8..7aa0ee39c4a37 100644 --- a/app/livechat/server/api.js +++ b/app/livechat/server/api.js @@ -11,6 +11,7 @@ import '../imports/server/rest/triggers.js'; import '../imports/server/rest/integrations.js'; import '../imports/server/rest/messages.js'; import '../imports/server/rest/visitors.js'; +import '../imports/server/rest/visitors.ts'; import '../imports/server/rest/dashboards.js'; import '../imports/server/rest/queue.js'; import '../imports/server/rest/officeHour.js'; diff --git a/app/livechat/server/api/lib/departments.js b/app/livechat/server/api/lib/departments.js index 0a70d1b6fca44..1e70a709444f2 100644 --- a/app/livechat/server/api/lib/departments.js +++ b/app/livechat/server/api/lib/departments.js @@ -71,7 +71,7 @@ export async function findDepartmentsToAutocomplete({ uid, selector, onlyMyDepar let { conditions = {} } = selector; const options = { - fields: { + projection: { _id: 1, name: 1, }, diff --git a/app/livechat/server/api/lib/livechat.js b/app/livechat/server/api/lib/livechat.js index a7a29598250f3..a8abb9115851c 100644 --- a/app/livechat/server/api/lib/livechat.js +++ b/app/livechat/server/api/lib/livechat.js @@ -1,7 +1,9 @@ import { Meteor } from 'meteor/meteor'; import { Random } from 'meteor/random'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; -import { LivechatRooms, LivechatVisitors, LivechatDepartment, LivechatTrigger, EmojiCustom } from '../../../../models/server'; +import { LivechatRooms, LivechatVisitors, LivechatDepartment, LivechatTrigger } from '../../../../models/server'; +import { EmojiCustom } from '../../../../models/server/raw'; import { Livechat } from '../../lib/Livechat'; import { callbacks } from '../../../../callbacks/server'; import { normalizeAgent } from '../../lib/Helper'; @@ -55,6 +57,7 @@ export function findOpenRoom(token, departmentId) { departmentId: 1, servedBy: 1, open: 1, + callStatus: 1, }, }; @@ -86,12 +89,12 @@ export function normalizeHttpHeaderData(headers = {}) { const httpHeaders = Object.assign({}, headers); return { httpHeaders }; } -export function settings() { +export async function settings() { const initSettings = Livechat.getInitSettings(); const triggers = findTriggers(); const departments = findDepartments(); const sound = `${ Meteor.absoluteUrl() }sounds/chime.mp3`; - const emojis = EmojiCustom.find().fetch(); + const emojis = await EmojiCustom.find().toArray(); return { enabled: initSettings.Livechat_enabled, settings: { @@ -100,7 +103,7 @@ export function settings() { nameFieldRegistrationForm: initSettings.Livechat_name_field_registration_form, emailFieldRegistrationForm: initSettings.Livechat_email_field_registration_form, displayOfflineForm: initSettings.Livechat_display_offline_form, - videoCall: initSettings.Livechat_videocall_enabled === true && initSettings.Jitsi_Enabled === true, + videoCall: initSettings.Omnichannel_call_provider === 'Jitsi' && initSettings.Jitsi_Enabled === true, fileUpload: initSettings.Livechat_fileupload_enabled && initSettings.FileUpload_Enabled, language: initSettings.Language, transcript: initSettings.Livechat_enable_transcript, @@ -108,18 +111,25 @@ export function settings() { forceAcceptDataProcessingConsent: initSettings.Livechat_force_accept_data_processing_consent, showConnecting: initSettings.Livechat_Show_Connecting, agentHiddenInfo: initSettings.Livechat_show_agent_info === false, + clearLocalStorageWhenChatEnded: initSettings.Livechat_clear_local_storage_when_chat_ended, limitTextLength: initSettings.Livechat_enable_message_character_limit - && (initSettings.Livechat_message_character_limit || initSettings.Message_MaxAllowedSize), + && (initSettings.Livechat_message_character_limit || initSettings.Message_MaxAllowedSize), }, theme: { title: initSettings.Livechat_title, color: initSettings.Livechat_title_color, offlineTitle: initSettings.Livechat_offline_title, offlineColor: initSettings.Livechat_offline_title_color, - actionLinks: [ - { icon: 'icon-videocam', i18nLabel: 'Accept', method_id: 'createLivechatCall', params: '' }, - { icon: 'icon-cancel', i18nLabel: 'Decline', method_id: 'denyLivechatCall', params: '' }, - ], + actionLinks: { + webrtc: [ + { actionLinksAlignment: 'flex-start', i18nLabel: 'Join_call', label: TAPi18n.__('Join_call'), method_id: 'joinLivechatWebRTCCall' }, + { i18nLabel: 'End_call', label: TAPi18n.__('End_call'), method_id: 'endLivechatWebRTCCall', danger: true }, + ], + jitsi: [ + { icon: 'icon-videocam', i18nLabel: 'Accept', method_id: 'createLivechatCall' }, + { icon: 'icon-cancel', i18nLabel: 'Decline', method_id: 'denyLivechatCall' }, + ], + }, }, messages: { offlineMessage: initSettings.Livechat_offline_message, diff --git a/app/livechat/server/api/lib/rooms.js b/app/livechat/server/api/lib/rooms.js index 72d84803de153..957911737f0aa 100644 --- a/app/livechat/server/api/lib/rooms.js +++ b/app/livechat/server/api/lib/rooms.js @@ -9,6 +9,7 @@ export async function findRooms({ closedAt, tags, customFields, + onhold, options: { offset, count, @@ -25,6 +26,7 @@ export async function findRooms({ closedAt, tags, customFields, + onhold: ['t', 'true', '1'].includes(onhold), options: { sort: sort || { ts: -1 }, offset, diff --git a/app/livechat/server/api/lib/visitors.js b/app/livechat/server/api/lib/visitors.js index c0366bf1ac694..d03566d6da998 100644 --- a/app/livechat/server/api/lib/visitors.js +++ b/app/livechat/server/api/lib/visitors.js @@ -72,6 +72,7 @@ export async function findChatHistory({ userId, roomId, visitorId, pagination: { total, }; } + export async function searchChats({ userId, roomId, visitorId, searchText, closedChatsOnly, servedChatsOnly: served, pagination: { offset, count, sort } }) { if (!await hasPermissionAsync(userId, 'view-l-room')) { throw new Error('error-not-authorized'); @@ -111,7 +112,7 @@ export async function findVisitorsToAutocomplete({ userId, selector }) { const { exceptions = [], conditions = {} } = selector; const options = { - fields: { + projection: { _id: 1, name: 1, username: 1, diff --git a/app/livechat/server/api/v1/config.js b/app/livechat/server/api/v1/config.js index e43509d4ba3ca..f1a49c1ed3955 100644 --- a/app/livechat/server/api/v1/config.js +++ b/app/livechat/server/api/v1/config.js @@ -17,7 +17,7 @@ API.v1.addRoute('livechat/config', { return API.v1.success({ config: { enabled: false } }); } - const config = settings(); + const config = Promise.await(settings()); const { token, department } = this.queryParams; const status = Livechat.online(department); diff --git a/app/livechat/server/api/v1/message.js b/app/livechat/server/api/v1/message.js index 178f571da4907..0fd39bc5d0867 100644 --- a/app/livechat/server/api/v1/message.js +++ b/app/livechat/server/api/v1/message.js @@ -108,7 +108,7 @@ API.v1.addRoute('livechat/message/:_id', { } if (message.file) { - message = normalizeMessageFileUpload(message); + message = Promise.await(normalizeMessageFileUpload(message)); } return API.v1.success({ message }); @@ -151,7 +151,7 @@ API.v1.addRoute('livechat/message/:_id', { if (result) { let message = Messages.findOneById(_id); if (message.file) { - message = normalizeMessageFileUpload(message); + message = Promise.await(normalizeMessageFileUpload(message)); } return API.v1.success({ message }); @@ -191,7 +191,7 @@ API.v1.addRoute('livechat/message/:_id', { throw new Meteor.Error('invalid-message'); } - const result = Livechat.deleteMessage({ guest, message }); + const result = Promise.await(Livechat.deleteMessage({ guest, message })); if (result) { return API.v1.success({ message: { @@ -251,7 +251,7 @@ API.v1.addRoute('livechat/messages.history/:rid', { const messages = loadMessageHistory({ userId: guest._id, rid, end, limit, ls, sort, offset, text }) .messages - .map(normalizeMessageFileUpload); + .map((...args) => Promise.await(normalizeMessageFileUpload(...args))); return API.v1.success({ messages }); } catch (e) { return API.v1.failure(e); diff --git a/app/livechat/server/api/v1/room.js b/app/livechat/server/api/v1/room.js index 5dcbf8cf1dd0f..88c9e47171105 100644 --- a/app/livechat/server/api/v1/room.js +++ b/app/livechat/server/api/v1/room.js @@ -166,7 +166,7 @@ API.v1.addRoute('livechat/room.survey', { throw new Meteor.Error('invalid-room'); } - const config = settings(); + const config = Promise.await(settings()); if (!config.survey || !config.survey.items || !config.survey.values) { throw new Meteor.Error('invalid-livechat-config'); } diff --git a/app/livechat/server/api/v1/videoCall.js b/app/livechat/server/api/v1/videoCall.js index 38b9c2d664919..6aef0c49537e2 100644 --- a/app/livechat/server/api/v1/videoCall.js +++ b/app/livechat/server/api/v1/videoCall.js @@ -1,12 +1,15 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; import { Random } from 'meteor/random'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; -import { Messages } from '../../../../models'; -import { settings as rcSettings } from '../../../../settings'; +import { Messages, Rooms } from '../../../../models'; +import { settings as rcSettings } from '../../../../settings/server'; import { API } from '../../../../api/server'; import { findGuest, getRoom, settings } from '../lib/livechat'; import { OmnichannelSourceType } from '../../../../../definition/IRoom'; +import { hasPermission, canSendMessage } from '../../../../authorization'; +import { Livechat } from '../../lib/Livechat'; API.v1.addRoute('livechat/video.call/:token', { get() { @@ -35,13 +38,13 @@ API.v1.addRoute('livechat/video.call/:token', { }, }; const { room } = getRoom({ guest, rid, roomInfo }); - const config = settings(); - if (!config.theme || !config.theme.actionLinks) { + const config = Promise.await(settings()); + if (!config.theme || !config.theme.actionLinks || !config.theme.actionLinks.jitsi) { throw new Meteor.Error('invalid-livechat-config'); } Messages.createWithTypeRoomIdMessageAndUser('livechat_video_call', room._id, '', guest, { - actionLinks: config.theme.actionLinks, + actionLinks: config.theme.actionLinks.jitsi, }); let rname; if (rcSettings.get('Jitsi_URL_Room_Hash')) { @@ -63,3 +66,102 @@ API.v1.addRoute('livechat/video.call/:token', { } }, }); + +API.v1.addRoute('livechat/webrtc.call', { authRequired: true }, { + get() { + try { + check(this.queryParams, { + rid: Match.Maybe(String), + }); + + if (!hasPermission(this.userId, 'view-l-room')) { + return API.v1.unauthorized(); + } + + const room = canSendMessage(this.queryParams.rid, { + uid: this.userId, + username: this.user.username, + type: this.user.type, + }); + if (!room) { + throw new Meteor.Error('invalid-room'); + } + + const webrtcCallingAllowed = (rcSettings.get('WebRTC_Enabled') === true) && (rcSettings.get('Omnichannel_call_provider') === 'WebRTC'); + if (!webrtcCallingAllowed) { + throw new Meteor.Error('webRTC calling not enabled'); + } + + const config = Promise.await(settings()); + if (!config.theme || !config.theme.actionLinks || !config.theme.actionLinks.webrtc) { + throw new Meteor.Error('invalid-livechat-config'); + } + + let { callStatus } = room; + + if (!callStatus || callStatus === 'ended' || callStatus === 'declined') { + callStatus = 'ringing'; + Promise.await(Rooms.setCallStatusAndCallStartTime(room._id, callStatus)); + Promise.await(Messages.createWithTypeRoomIdMessageAndUser( + 'livechat_webrtc_video_call', + room._id, + TAPi18n.__('Join_my_room_to_start_the_video_call'), + this.user, + { + actionLinks: config.theme.actionLinks.webrtc, + }, + )); + } + const videoCall = { + rid: room._id, + provider: 'webrtc', + callStatus, + }; + return API.v1.success({ videoCall }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); + +API.v1.addRoute('livechat/webrtc.call/:callId', { authRequired: true }, { + put() { + try { + check(this.urlParams, { + callId: String, + }); + + check(this.bodyParams, { + rid: Match.Maybe(String), + status: Match.Maybe(String), + }); + + const { callId } = this.urlParams; + const { rid, status } = this.bodyParams; + + if (!hasPermission(this.userId, 'view-l-room')) { + return API.v1.unauthorized(); + } + + const room = canSendMessage(rid, { + uid: this.userId, + username: this.user.username, + type: this.user.type, + }); + if (!room) { + throw new Meteor.Error('invalid-room'); + } + + const call = Promise.await(Messages.findOneById(callId)); + if (!call || call.t !== 'livechat_webrtc_video_call') { + throw new Meteor.Error('invalid-callId'); + } + + Livechat.updateCallStatus(callId, rid, status, this.user); + + return API.v1.success({ status }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); diff --git a/app/livechat/server/api/v1/visitor.js b/app/livechat/server/api/v1/visitor.js index 98007540876c5..dc4e012839baa 100644 --- a/app/livechat/server/api/v1/visitor.js +++ b/app/livechat/server/api/v1/visitor.js @@ -128,6 +128,30 @@ API.v1.addRoute('livechat/visitor/:token/room', { authRequired: true }, { }, }); +API.v1.addRoute('livechat/visitor.callStatus', { + post() { + try { + check(this.bodyParams, { + token: String, + callStatus: String, + rid: String, + callId: String, + }); + + const { token, callStatus, rid, callId } = this.bodyParams; + const guest = findGuest(token); + if (!guest) { + throw new Meteor.Error('invalid-token'); + } + const status = callStatus; + Livechat.updateCallStatus(callId, rid, status, guest); + return API.v1.success({ token, callStatus }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); + API.v1.addRoute('livechat/visitor.status', { post() { try { diff --git a/app/livechat/server/business-hour/AbstractBusinessHour.ts b/app/livechat/server/business-hour/AbstractBusinessHour.ts index 3b564b2c4499c..01768b5240d09 100644 --- a/app/livechat/server/business-hour/AbstractBusinessHour.ts +++ b/app/livechat/server/business-hour/AbstractBusinessHour.ts @@ -47,7 +47,7 @@ export abstract class AbstractBusinessHourBehavior { } async changeAgentActiveStatus(agentId: string, status: string): Promise { - return this.UsersRepository.setLivechatStatus(agentId, status); + return this.UsersRepository.setLivechatStatusIf(agentId, status, { livechatStatusSystemModified: true }, { livechatStatusSystemModified: true }); } } diff --git a/app/livechat/server/business-hour/BusinessHourManager.ts b/app/livechat/server/business-hour/BusinessHourManager.ts index e1b5558d0acd6..04505776f32bd 100644 --- a/app/livechat/server/business-hour/BusinessHourManager.ts +++ b/app/livechat/server/business-hour/BusinessHourManager.ts @@ -1,11 +1,11 @@ import moment from 'moment'; import { ILivechatBusinessHour, LivechatBusinessHourTypes } from '../../../../definition/ILivechatBusinessHour'; -import { ICronJobs } from '../../../utils/server/lib/cron/Cronjobs'; import { IBusinessHourBehavior, IBusinessHourType } from './AbstractBusinessHour'; import { settings } from '../../../settings/server'; import { callbacks } from '../../../callbacks/server'; import { Users } from '../../../models/server/raw'; +import { ICronJobs } from '../../../../definition/ICronJobs'; const cronJobDayDict: Record = { Sunday: 0, diff --git a/app/livechat/server/config.ts b/app/livechat/server/config.ts index f0a19e3246e65..be6ac87f51b98 100644 --- a/app/livechat/server/config.ts +++ b/app/livechat/server/config.ts @@ -56,6 +56,15 @@ Meteor.startup(function() { enableQuery: omnichannelEnabledQuery, }); + this.add('Livechat_clear_local_storage_when_chat_ended', false, { + type: 'boolean', + group: 'Omnichannel', + public: true, + section: 'Livechat', + i18nLabel: 'Clear_livechat_session_when_chat_ended', + enableQuery: omnichannelEnabledQuery, + }); + this.add('Livechat_validate_offline_email', true, { type: 'boolean', group: 'Omnichannel', @@ -375,16 +384,6 @@ Meteor.startup(function() { enableQuery: omnichannelEnabledQuery, }); - this.add('Livechat_videocall_enabled', false, { - type: 'boolean', - group: 'Omnichannel', - section: 'Livechat', - public: true, - i18nLabel: 'Videocall_enabled', - i18nDescription: 'Beta_feature_Depends_on_Video_Conference_to_be_enabled', - enableQuery: [{ _id: 'Jitsi_Enabled', value: true }, omnichannelEnabledQuery], - }); - this.add('Livechat_fileupload_enabled', true, { type: 'boolean', group: 'Omnichannel', @@ -616,5 +615,21 @@ Meteor.startup(function() { i18nDescription: 'Time_in_seconds', enableQuery: omnichannelEnabledQuery, }); + + this.add('Omnichannel_call_provider', 'none', { + type: 'select', + public: true, + group: 'Omnichannel', + section: 'Video_and_Audio_Call', + values: [ + { key: 'none', i18nLabel: 'None' }, + { key: 'Jitsi', i18nLabel: 'Jitsi' }, + { key: 'WebRTC', i18nLabel: 'WebRTC' }, + ], + i18nDescription: 'Feature_depends_on_selected_call_provider_to_be_enabled_from_administration_settings', + i18nLabel: 'Call_provider', + alert: 'The WebRTC provider is currently in alpha!
We recommend using Firefox Browser for this feature since there are some known bugs within other browsers that still need to be fixed.
Please report bugs to github.com/RocketChat/Rocket.Chat/issues', + enableQuery: omnichannelEnabledQuery, + }); }); }); diff --git a/app/livechat/server/hooks/saveAnalyticsData.js b/app/livechat/server/hooks/saveAnalyticsData.js index 4ca8832c153dc..96d336c228ca7 100644 --- a/app/livechat/server/hooks/saveAnalyticsData.js +++ b/app/livechat/server/hooks/saveAnalyticsData.js @@ -14,7 +14,7 @@ callbacks.add('afterSaveMessage', function(message, room) { } if (message.file) { - message = normalizeMessageFileUpload(message); + message = Promise.await(normalizeMessageFileUpload(message)); } const now = new Date(); diff --git a/app/livechat/server/hooks/sendToCRM.js b/app/livechat/server/hooks/sendToCRM.js index ac1ec663904bc..94e4cfcd22ebe 100644 --- a/app/livechat/server/hooks/sendToCRM.js +++ b/app/livechat/server/hooks/sendToCRM.js @@ -84,7 +84,7 @@ function sendToCRM(type, room, includeMessages = true) { } const { u } = message; - postData.messages.push(normalizeMessageFileUpload({ u, ...msg })); + postData.messages.push(Promise.await(normalizeMessageFileUpload({ u, ...msg }))); }); } diff --git a/app/livechat/server/hooks/sendToFacebook.js b/app/livechat/server/hooks/sendToFacebook.js index 1af4767e36568..7c1b00f312115 100644 --- a/app/livechat/server/hooks/sendToFacebook.js +++ b/app/livechat/server/hooks/sendToFacebook.js @@ -29,7 +29,7 @@ callbacks.add('afterSaveMessage', function(message, room) { } if (message.file) { - message = normalizeMessageFileUpload(message); + message = Promise.await(normalizeMessageFileUpload(message)); } OmniChannel.reply({ diff --git a/app/livechat/server/lib/Analytics.js b/app/livechat/server/lib/Analytics.js index c4251fbd1bce1..b4aa71af346f6 100644 --- a/app/livechat/server/lib/Analytics.js +++ b/app/livechat/server/lib/Analytics.js @@ -2,6 +2,7 @@ import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import moment from 'moment'; import { LivechatRooms } from '../../../models'; +import { LivechatRooms as LivechatRoomsRaw } from '../../../models/server/raw'; import { secondsToHHMMSS } from '../../../utils/server'; import { getTimezone } from '../../../utils/server/lib/getTimezone'; import { Logger } from '../../../logger'; @@ -288,8 +289,8 @@ export const Analytics = { const totalMessagesInHour = new Map(); // total messages in hour 0, 1, ... 23 of weekday const days = to.diff(from, 'days') + 1; // total days - const summarize = (m) => ({ metrics, msgs }) => { - if (metrics && !metrics.chatDuration) { + const summarize = (m) => ({ metrics, msgs, onHold = false }) => { + if (metrics && !metrics.chatDuration && !onHold) { openConversations++; } totalMessages += msgs; @@ -337,13 +338,17 @@ export const Analytics = { to: utcBusiestHour >= 0 ? moment.utc().set({ hour: utcBusiestHour }).tz(timezone).format('hA') : '-', from: utcBusiestHour >= 0 ? moment.utc().set({ hour: utcBusiestHour }).subtract(1, 'hour').tz(timezone).format('hA') : '', }; + const onHoldConversations = Promise.await(LivechatRoomsRaw.getOnHoldConversationsBetweenDate(from, to, departmentId)); - const data = [{ + return [{ title: 'Total_conversations', value: totalConversations, }, { title: 'Open_conversations', value: openConversations, + }, { + title: 'On_Hold_conversations', + value: onHoldConversations, }, { title: 'Total_messages', value: totalMessages, @@ -357,8 +362,6 @@ export const Analytics = { title: 'Busiest_time', value: `${ busiestHour.from }${ busiestHour.to ? `- ${ busiestHour.to }` : '' }`, }]; - - return data; }, /** diff --git a/app/livechat/server/lib/Helper.js b/app/livechat/server/lib/Helper.js index fb42f39497243..4ed3a225136ee 100644 --- a/app/livechat/server/lib/Helper.js +++ b/app/livechat/server/lib/Helper.js @@ -40,6 +40,7 @@ export const createLivechatRoom = (rid, name, guest, roomInfo = {}, extraData = const extraRoomInfo = callbacks.run('livechat.beforeRoom', roomInfo, extraData); const { _id, username, token, department: departmentId, status = 'online' } = guest; + const newRoomAt = new Date(); logger.debug(`Creating livechat room for visitor ${ _id }`); @@ -47,10 +48,10 @@ export const createLivechatRoom = (rid, name, guest, roomInfo = {}, extraData = _id: rid, msgs: 0, usersCount: 1, - lm: new Date(), + lm: newRoomAt, fname: name, t: 'l', - ts: new Date(), + ts: newRoomAt, departmentId, v: { _id, @@ -67,6 +68,7 @@ export const createLivechatRoom = (rid, name, guest, roomInfo = {}, extraData = type: OmnichannelSourceType.OTHER, alias: 'unknown', }, + queuedAt: newRoomAt, }, extraRoomInfo); const roomId = Rooms.insert(room); diff --git a/app/livechat/server/lib/Livechat.js b/app/livechat/server/lib/Livechat.js index e7145d7def0dc..7110fadf8310a 100644 --- a/app/livechat/server/lib/Livechat.js +++ b/app/livechat/server/lib/Livechat.js @@ -40,6 +40,7 @@ import { normalizeTransferredByData, parseAgentCustomFields, updateDepartmentAge import { Apps, AppEvents } from '../../../apps/server'; import { businessHourManager } from '../business-hour'; import notifications from '../../../notifications/server/lib/Notifications'; +import { Users as UsersRaw } from '../../../models/server/raw'; const logger = new Logger('Livechat'); @@ -221,7 +222,7 @@ export const Livechat = { return true; }, - deleteMessage({ guest, message }) { + async deleteMessage({ guest, message }) { Livechat.logger.debug(`Attempting to delete a message by visitor ${ guest._id }`); check(message, Match.ObjectIncluding({ _id: String })); @@ -238,7 +239,7 @@ export const Livechat = { throw new Meteor.Error('error-action-not-allowed', 'Message deleting not allowed', { method: 'livechatDeleteMessage' }); } - deleteMessage(message, guest); + await deleteMessage(message, guest); return true; }, @@ -513,7 +514,7 @@ export const Livechat = { 'Livechat_offline_success_message', 'Livechat_offline_form_unavailable', 'Livechat_display_offline_form', - 'Livechat_videocall_enabled', + 'Omnichannel_call_provider', 'Jitsi_Enabled', 'Language', 'Livechat_enable_transcript', @@ -528,6 +529,7 @@ export const Livechat = { 'Livechat_force_accept_data_processing_consent', 'Livechat_data_processing_consent_text', 'Livechat_show_agent_info', + 'Livechat_clear_local_storage_when_chat_ended', ]).forEach((setting) => { rcSettings[setting._id] = setting.value; }); @@ -597,7 +599,7 @@ export const Livechat = { const user = Users.findOneById(userId); const { _id, username, name } = user; const transferredBy = normalizeTransferredByData({ _id, username, name }, room); - this.transfer(room, guest, { roomId: room._id, transferredBy, departmentId: guest.department }); + Promise.await(this.transfer(room, guest, { roomId: room._id, transferredBy, departmentId: guest.department })); }); }, @@ -886,6 +888,12 @@ export const Livechat = { return user; }, + setUserStatusLivechatIf(userId, status, condition, fields) { + const user = Promise.await(UsersRaw.setLivechatStatusIf(userId, status, condition, fields)); + callbacks.runAsync('livechat.setUserStatusLivechat', { userId, status }); + return user; + }, + cleanGuestHistory(_id) { const guest = LivechatVisitors.findOneById(_id); if (!guest) { @@ -1271,6 +1279,12 @@ export const Livechat = { }; LivechatVisitors.updateById(contactId, updateUser); }, + updateCallStatus(callId, rid, status, user) { + Rooms.setCallStatus(rid, status); + if (status === 'ended' || status === 'declined') { + return updateMessage({ _id: callId, msg: status, actionLinks: [], webRtcCallEndTs: new Date() }, user); + } + }, }; settings.watch('Livechat_history_monitor_type', (value) => { diff --git a/app/livechat/server/lib/QueueManager.js b/app/livechat/server/lib/QueueManager.js index c3a10ebb5bc48..711aa84351f9e 100644 --- a/app/livechat/server/lib/QueueManager.js +++ b/app/livechat/server/lib/QueueManager.js @@ -7,7 +7,7 @@ import { callbacks } from '../../../callbacks/server'; import { Logger } from '../../../logger'; import { RoutingManager } from './RoutingManager'; -const logger = new Logger('QueueMananger'); +const logger = new Logger('QueueManager'); export const saveQueueInquiry = (inquiry) => { LivechatInquiry.queueInquiry(inquiry._id); diff --git a/app/livechat/server/lib/analytics/dashboards.js b/app/livechat/server/lib/analytics/dashboards.js index 18bdca6300332..70dc1c7925ff8 100644 --- a/app/livechat/server/lib/analytics/dashboards.js +++ b/app/livechat/server/lib/analytics/dashboards.js @@ -25,6 +25,7 @@ const findAllChatsStatusAsync = async ({ open: await LivechatRooms.countAllOpenChatsBetweenDate({ start, end, departmentId }), closed: await LivechatRooms.countAllClosedChatsBetweenDate({ start, end, departmentId }), queued: await LivechatRooms.countAllQueuedChatsBetweenDate({ start, end, departmentId }), + onhold: await LivechatRooms.getOnHoldConversationsBetweenDate(start, end, departmentId), }; }; @@ -193,7 +194,7 @@ const getConversationsMetricsAsync = async ({ utcOffset: user.utcOffset, language: user.language || settings.get('Language') || 'en', }); - const metrics = ['Total_conversations', 'Open_conversations', 'Total_messages']; + const metrics = ['Total_conversations', 'Open_conversations', 'On_Hold_conversations', 'Total_messages']; const visitorsCount = await LivechatVisitors.getVisitorsBetweenDate({ start, end, department: departmentId }).count(); return { totalizers: [ @@ -213,13 +214,20 @@ const findAllChatMetricsByAgentAsync = async ({ } const open = await LivechatRooms.countAllOpenChatsByAgentBetweenDate({ start, end, departmentId }); const closed = await LivechatRooms.countAllClosedChatsByAgentBetweenDate({ start, end, departmentId }); + const onhold = await LivechatRooms.countAllOnHoldChatsByAgentBetweenDate({ start, end, departmentId }); const result = {}; (open || []).forEach((agent) => { - result[agent._id] = { open: agent.chats, closed: 0 }; + result[agent._id] = { open: agent.chats, closed: 0, onhold: 0 }; }); (closed || []).forEach((agent) => { result[agent._id] = { open: result[agent._id] ? result[agent._id].open : 0, closed: agent.chats }; }); + (onhold || []).forEach((agent) => { + result[agent._id] = { + ...result[agent._id], + onhold: agent.chats, + }; + }); return result; }; diff --git a/app/livechat/server/lib/stream/agentStatus.ts b/app/livechat/server/lib/stream/agentStatus.ts index ed46bacbd0f89..12985a42f58d9 100644 --- a/app/livechat/server/lib/stream/agentStatus.ts +++ b/app/livechat/server/lib/stream/agentStatus.ts @@ -2,6 +2,9 @@ import { Meteor } from 'meteor/meteor'; import { Livechat } from '../Livechat'; import { settings } from '../../../../settings/server'; +import { Logger } from '../../../../logger/server'; + +const logger = new Logger('AgentStatusWatcher'); export let monitorAgents = false; let actionTimeout = 60000; @@ -64,12 +67,19 @@ export const onlineAgents = { onlineAgents.users.delete(userId); onlineAgents.queue.delete(userId); - if (action === 'close') { - return Livechat.closeOpenChats(userId, comment); - } + try { + if (action === 'close') { + return Livechat.closeOpenChats(userId, comment); + } - if (action === 'forward') { - return Livechat.forwardOpenChats(userId); + if (action === 'forward') { + return Livechat.forwardOpenChats(userId); + } + } catch (e) { + logger.error({ + msg: `Cannot perform action ${ action }`, + err: e, + }); } }), }; diff --git a/app/livechat/server/methods/getInitialData.js b/app/livechat/server/methods/getInitialData.js index 9b7a2f22a7055..1243a3360ec24 100644 --- a/app/livechat/server/methods/getInitialData.js +++ b/app/livechat/server/methods/getInitialData.js @@ -3,6 +3,7 @@ import _ from 'underscore'; import { LivechatRooms, Users, LivechatDepartment, LivechatTrigger, LivechatVisitors } from '../../../models'; import { Livechat } from '../lib/Livechat'; +import { deprecationWarning } from '../../../api/server/helpers/deprecationWarning'; Meteor.methods({ 'livechat:getInitialData'(visitorToken, departmentId) { @@ -75,7 +76,7 @@ Meteor.methods({ info.offlineUnavailableMessage = initSettings.Livechat_offline_form_unavailable; info.displayOfflineForm = initSettings.Livechat_display_offline_form; info.language = initSettings.Language; - info.videoCall = initSettings.Livechat_videocall_enabled === true && initSettings.Jitsi_Enabled === true; + info.videoCall = initSettings.Omnichannel_call_provider === 'Jitsi' && initSettings.Jitsi_Enabled === true; info.fileUpload = initSettings.Livechat_fileupload_enabled && initSettings.FileUpload_Enabled; info.transcript = initSettings.Livechat_enable_transcript; info.transcriptMessage = initSettings.Livechat_transcript_message; @@ -98,6 +99,7 @@ Meteor.methods({ info.allowSwitchingDepartments = initSettings.Livechat_allow_switching_departments; info.online = Users.findOnlineAgents().count() > 0; - return info; + + return deprecationWarning({ endpoint: 'livechat:getInitialData', versionWillBeRemoved: '5.0', response: info }); }, }); diff --git a/app/livechat/server/methods/saveIntegration.js b/app/livechat/server/methods/saveIntegration.js index 38288eab9e0d1..d9585643783eb 100644 --- a/app/livechat/server/methods/saveIntegration.js +++ b/app/livechat/server/methods/saveIntegration.js @@ -3,7 +3,6 @@ import s from 'underscore.string'; import { hasPermission } from '../../../authorization'; import { Settings } from '../../../models/server'; -import { settings } from '../../../settings'; Meteor.methods({ 'livechat:saveIntegration'(values) { @@ -16,39 +15,39 @@ Meteor.methods({ } if (typeof values.Livechat_secret_token !== 'undefined') { - settings.updateValueById('Livechat_secret_token', s.trim(values.Livechat_secret_token)); + Settings.updateValueById('Livechat_secret_token', s.trim(values.Livechat_secret_token)); } if (typeof values.Livechat_webhook_on_start !== 'undefined') { - settings.updateValueById('Livechat_webhook_on_start', !!values.Livechat_webhook_on_start); + Settings.updateValueById('Livechat_webhook_on_start', !!values.Livechat_webhook_on_start); } if (typeof values.Livechat_webhook_on_close !== 'undefined') { - settings.updateValueById('Livechat_webhook_on_close', !!values.Livechat_webhook_on_close); + Settings.updateValueById('Livechat_webhook_on_close', !!values.Livechat_webhook_on_close); } if (typeof values.Livechat_webhook_on_chat_taken !== 'undefined') { - settings.updateValueById('Livechat_webhook_on_chat_taken', !!values.Livechat_webhook_on_chat_taken); + Settings.updateValueById('Livechat_webhook_on_chat_taken', !!values.Livechat_webhook_on_chat_taken); } if (typeof values.Livechat_webhook_on_chat_queued !== 'undefined') { - settings.updateValueById('Livechat_webhook_on_chat_queued', !!values.Livechat_webhook_on_chat_queued); + Settings.updateValueById('Livechat_webhook_on_chat_queued', !!values.Livechat_webhook_on_chat_queued); } if (typeof values.Livechat_webhook_on_forward !== 'undefined') { - settings.updateValueById('Livechat_webhook_on_forward', !!values.Livechat_webhook_on_forward); + Settings.updateValueById('Livechat_webhook_on_forward', !!values.Livechat_webhook_on_forward); } if (typeof values.Livechat_webhook_on_offline_msg !== 'undefined') { - settings.updateValueById('Livechat_webhook_on_offline_msg', !!values.Livechat_webhook_on_offline_msg); + Settings.updateValueById('Livechat_webhook_on_offline_msg', !!values.Livechat_webhook_on_offline_msg); } if (typeof values.Livechat_webhook_on_visitor_message !== 'undefined') { - settings.updateValueById('Livechat_webhook_on_visitor_message', !!values.Livechat_webhook_on_visitor_message); + Settings.updateValueById('Livechat_webhook_on_visitor_message', !!values.Livechat_webhook_on_visitor_message); } if (typeof values.Livechat_webhook_on_agent_message !== 'undefined') { - settings.updateValueById('Livechat_webhook_on_agent_message', !!values.Livechat_webhook_on_agent_message); + Settings.updateValueById('Livechat_webhook_on_agent_message', !!values.Livechat_webhook_on_agent_message); } }, }); diff --git a/app/livechat/server/sendMessageBySMS.js b/app/livechat/server/sendMessageBySMS.js index 6094d1dc62a33..5e3bd1f133424 100644 --- a/app/livechat/server/sendMessageBySMS.js +++ b/app/livechat/server/sendMessageBySMS.js @@ -31,7 +31,7 @@ callbacks.add('afterSaveMessage', function(message, room) { let extraData; if (message.file) { - message = normalizeMessageFileUpload(message); + message = Promise.await(normalizeMessageFileUpload(message)); const { fileUpload, rid, u: { _id: userId } = {} } = message; extraData = Object.assign({}, { rid, userId, fileUpload }); } diff --git a/app/livechat/server/startup.js b/app/livechat/server/startup.js index 50b9bd3dc0ec6..729eb4f92979f 100644 --- a/app/livechat/server/startup.js +++ b/app/livechat/server/startup.js @@ -62,5 +62,5 @@ Meteor.startup(async () => { RoutingManager.setMethodNameAndStartQueue(value); }); - Accounts.onLogout(({ user }) => user?.roles?.includes('livechat-agent') && !user?.roles?.includes('bot') && Livechat.setUserStatusLivechat(user._id, 'not-available')); + Accounts.onLogout(({ user }) => user?.roles?.includes('livechat-agent') && !user?.roles?.includes('bot') && Livechat.setUserStatusLivechatIf(user._id, 'not-available', {}, { livechatStatusSystemModified: true })); }); diff --git a/app/livechat/server/statistics/LivechatAgentActivityMonitor.js b/app/livechat/server/statistics/LivechatAgentActivityMonitor.js index 1066e112f027e..2c894afe4f1f9 100644 --- a/app/livechat/server/statistics/LivechatAgentActivityMonitor.js +++ b/app/livechat/server/statistics/LivechatAgentActivityMonitor.js @@ -3,7 +3,8 @@ import { Meteor } from 'meteor/meteor'; import { SyncedCron } from 'meteor/littledata:synced-cron'; import { callbacks } from '../../../callbacks/server'; -import { LivechatAgentActivity, Sessions, Users } from '../../../models/server'; +import { LivechatAgentActivity, Users } from '../../../models/server'; +import { Sessions } from '../../../models/server/raw'; const formatDate = (dateTime = new Date()) => ({ date: parseInt(moment(dateTime).format('YYYYMMDD')), @@ -12,7 +13,6 @@ const formatDate = (dateTime = new Date()) => ({ export class LivechatAgentActivityMonitor { constructor() { this._started = false; - this._handleMeteorConnection = this._handleMeteorConnection.bind(this); this._handleAgentStatusChanged = this._handleAgentStatusChanged.bind(this); this._handleUserStatusLivechatChanged = this._handleUserStatusLivechatChanged.bind(this); this._name = 'Livechat Agent Activity Monitor'; @@ -41,7 +41,7 @@ export class LivechatAgentActivityMonitor { return; } this._startMonitoring(); - Meteor.onConnection(this._handleMeteorConnection); + Meteor.onConnection((connection) => this._handleMeteorConnection(connection)); callbacks.add('livechat.agentStatusChanged', this._handleAgentStatusChanged); callbacks.add('livechat.setUserStatusLivechat', this._handleUserStatusLivechatChanged); this._started = true; @@ -75,12 +75,12 @@ export class LivechatAgentActivityMonitor { } } - _handleMeteorConnection(connection) { + async _handleMeteorConnection(connection) { if (!this.isRunning()) { return; } - const session = Sessions.findOne({ sessionId: connection.id }); + const session = await Sessions.findOne({ sessionId: connection.id }); if (!session) { return; } diff --git a/app/mailer/server/api.js b/app/mailer/server/api.js deleted file mode 100644 index 4be41b050884e..0000000000000 --- a/app/mailer/server/api.js +++ /dev/null @@ -1,155 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Email } from 'meteor/email'; -import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; -import _ from 'underscore'; -import s from 'underscore.string'; -import juice from 'juice'; -import stripHtml from 'string-strip-html'; -import { escapeHTML } from '@rocket.chat/string-helpers'; - -import { settings } from '../../settings/server'; -import { replaceVariables } from './utils.js'; - -let contentHeader; -let contentFooter; - -let body; -let Settings = { - get: () => {}, -}; - -// define server language for email translations -// @TODO: change TAPi18n.__ function to use the server language by default -let lng = 'en'; -settings.watch('Language', (value) => { - lng = value || 'en'; -}); - - -export const replacekey = (str, key, value = '') => str.replace( - new RegExp(`(\\[${ key }\\]|__${ key }__)`, 'igm'), - value, -); - -export const translate = (str) => replaceVariables(str, (match, key) => TAPi18n.__(key, { lng })); -export const replace = function replace(str, data = {}) { - if (!str) { - return ''; - } - const options = { - Site_Name: Settings.get('Site_Name'), - Site_URL: Settings.get('Site_Url'), - Site_URL_Slash: Settings.get('Site_Url')?.replace(/\/?$/, '/'), - ...data.name && { - fname: s.strLeft(data.name, ' '), - lname: s.strRightBack(data.name, ' '), - }, - ...data, - }; - return Object.entries(options).reduce((ret, [key, value]) => replacekey(ret, key, value), translate(str)); -}; - -const nonEscapeKeys = ['room_path']; - -export const replaceEscaped = (str, data = {}) => replace(str, { - Site_Name: escapeHTML(settings.get('Site_Name')), - Site_Url: escapeHTML(settings.get('Site_Url')), - ...Object.entries(data).reduce((ret, [key, value]) => { - ret[key] = nonEscapeKeys.includes(key) ? value : escapeHTML(value); - return ret; - }, {}), -}); - -export const wrap = (html, data = {}) => { - if (settings.get('email_plain_text_only')) { - return replace(html, data); - } - - return replaceEscaped(body.replace('{{body}}', html), data); -}; -export const inlinecss = (html) => { - const css = Settings.get('email_style'); - return css ? juice.inlineContent(html, css) : html; -}; -export const getTemplate = (template, fn, escape = true) => { - let html = ''; - Settings.get(template, (key, value) => { - html = value || ''; - fn(escape ? inlinecss(html) : html); - }); - Settings.get('email_style', () => { - fn(escape ? inlinecss(html) : html); - }); -}; -export const getTemplateWrapped = (template, fn) => { - let html = ''; - const wrapInlineCSS = _.debounce(() => fn(wrap(inlinecss(html))), 100); - - Settings.get('Email_Header', () => html && wrapInlineCSS()); - Settings.get('Email_Footer', () => html && wrapInlineCSS()); - Settings.get('email_style', () => html && wrapInlineCSS()); - Settings.get(template, (key, value) => { - html = value || ''; - return html && wrapInlineCSS(); - }); -}; -export const setSettings = (s) => { - Settings = s; - getTemplate('Email_Header', (value) => { - contentHeader = replace(value || ''); - body = inlinecss(`${ contentHeader } {{body}} ${ contentFooter }`); - }, false); - - getTemplate('Email_Footer', (value) => { - contentFooter = replace(value || ''); - body = inlinecss(`${ contentHeader } {{body}} ${ contentFooter }`); - }, false); - - body = inlinecss(`${ contentHeader } {{body}} ${ contentFooter }`); -}; - -export const rfcMailPatternWithName = /^(?:.*<)?([a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?:>?)$/; - -export const checkAddressFormat = (from) => rfcMailPatternWithName.test(from); - -export const sendNoWrap = ({ to, from, replyTo, subject, html, text, headers }) => { - if (!checkAddressFormat(to)) { - return; - } - - if (!text) { - text = stripHtml(html).result; - } - - if (settings.get('email_plain_text_only')) { - html = undefined; - } - - Meteor.defer(() => Email.send({ to, from, replyTo, subject, html, text, headers })); -}; - -export const send = ({ to, from, replyTo, subject, html, text, data, headers }) => - sendNoWrap({ - to, - from, - replyTo, - subject: replace(subject, data), - text: text - ? replace(text, data) - : stripHtml(replace(html, data)).result, - html: wrap(html, data), - headers, - }); - -export const checkAddressFormatAndThrow = (from, func) => { - if (checkAddressFormat(from)) { - return true; - } - throw new Meteor.Error('error-invalid-from-address', 'Invalid from address', { - function: func, - }); -}; - -export const getHeader = () => contentHeader; - -export const getFooter = () => contentFooter; diff --git a/app/mailer/server/api.ts b/app/mailer/server/api.ts new file mode 100644 index 0000000000000..c59cd2151fd39 --- /dev/null +++ b/app/mailer/server/api.ts @@ -0,0 +1,212 @@ +import { Meteor } from 'meteor/meteor'; +import { Email } from 'meteor/email'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; +import _ from 'underscore'; +import s from 'underscore.string'; +import juice from 'juice'; +import stripHtml from 'string-strip-html'; +import { escapeHTML } from '@rocket.chat/string-helpers'; + +import { settings } from '../../settings/server'; +import { ISetting } from '../../../definition/ISetting'; +import { replaceVariables } from './replaceVariables'; + +let contentHeader: string | undefined; +let contentFooter: string | undefined; +let body: string | undefined; + +// define server language for email translations +// @TODO: change TAPi18n.__ function to use the server language by default +let lng = 'en'; +settings.watch('Language', (value) => { + lng = value || 'en'; +}); + +export const replacekey = (str: string, key: string, value = ''): string => + str.replace( + new RegExp(`(\\[${ key }\\]|__${ key }__)`, 'igm'), + value, + ); + +export const translate = (str: string): string => + replaceVariables(str, (_match, key) => TAPi18n.__(key, { lng })); + +export const replace = (str: string, data: { [key: string]: unknown } = {}): string => { + if (!str) { + return ''; + } + + const options = { + // eslint-disable-next-line @typescript-eslint/camelcase + Site_Name: settings.get('Site_Name'), + // eslint-disable-next-line @typescript-eslint/camelcase + Site_URL: settings.get('Site_Url'), + // eslint-disable-next-line @typescript-eslint/camelcase + Site_URL_Slash: settings.get('Site_Url')?.replace(/\/?$/, '/'), + ...data.name ? { + fname: s.strLeft(String(data.name), ' '), + lname: s.strRightBack(String(data.name), ' '), + } : {}, + ...data, + }; + + return Object.entries(options) + .reduce((ret, [key, value]) => replacekey(ret, key, value), translate(str)); +}; + +const nonEscapeKeys = ['room_path']; + +export const replaceEscaped = (str: string, data: { [key: string]: unknown } = {}): string => { + const siteName = settings.get('Site_Name'); + const siteUrl = settings.get('Site_Url'); + + return replace(str, { + // eslint-disable-next-line @typescript-eslint/camelcase + Site_Name: siteName ? escapeHTML(siteName) : undefined, + // eslint-disable-next-line @typescript-eslint/camelcase + Site_Url: siteUrl ? escapeHTML(siteUrl) : undefined, + ...Object.entries(data).reduce<{[key: string]: string}>((ret, [key, value]) => { + if (value !== undefined && value !== null) { + ret[key] = nonEscapeKeys.includes(key) ? String(value) : escapeHTML(String(value)); + } + return ret; + }, {}), + }); +}; + +export const wrap = (html: string, data: { [key: string]: unknown } = {}): string => { + if (settings.get('email_plain_text_only')) { + return replace(html, data); + } + + if (!body) { + throw new Error('`body` is not set yet'); + } + + return replaceEscaped(body.replace('{{body}}', html), data); +}; +export const inlinecss = (html: string): string => { + const css = settings.get('email_style'); + return css ? juice.inlineContent(html, css) : html; +}; + +export const getTemplate = (template: ISetting['_id'], fn: (html: string) => void, escape = true): void => { + let html = ''; + + settings.watch(template, (value) => { + html = value || ''; + fn(escape ? inlinecss(html) : html); + }); + + settings.watch('email_style', () => { + fn(escape ? inlinecss(html) : html); + }); +}; + +export const getTemplateWrapped = (template: ISetting['_id'], fn: (html: string) => void): void => { + let html = ''; + const wrapInlineCSS = _.debounce(() => fn(wrap(inlinecss(html))), 100); + + settings.watch('Email_Header', () => html && wrapInlineCSS()); + settings.watch('Email_Footer', () => html && wrapInlineCSS()); + settings.watch('email_style', () => html && wrapInlineCSS()); + settings.watch(template, (value) => { + html = value || ''; + return html && wrapInlineCSS(); + }); +}; + +settings.watchMultiple(['Email_Header', 'Email_Footer'], () => { + getTemplate('Email_Header', (value) => { + contentHeader = replace(value || ''); + body = inlinecss(`${ contentHeader } {{body}} ${ contentFooter }`); + }, false); + + getTemplate('Email_Footer', (value) => { + contentFooter = replace(value || ''); + body = inlinecss(`${ contentHeader } {{body}} ${ contentFooter }`); + }, false); + + body = inlinecss(`${ contentHeader } {{body}} ${ contentFooter }`); +}); + +export const rfcMailPatternWithName = /^(?:.*<)?([a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?:>?)$/; + +export const checkAddressFormat = (adresses: string | string[]): boolean => ([] as string[]).concat(adresses).every((address) => rfcMailPatternWithName.test(address)); + +export const sendNoWrap = ({ + to, + from, + replyTo, + subject, + html, + text, + headers, +}: { + to: string; + from: string; + replyTo?: string; + subject: string; + html?: string; + text?: string; + headers?: string; +}): void => { + if (!checkAddressFormat(to)) { + throw new Meteor.Error('invalid email'); + } + + if (!text) { + text = html ? stripHtml(html).result : undefined; + } + + if (settings.get('email_plain_text_only')) { + html = undefined; + } + + Meteor.defer(() => Email.send({ to, from, replyTo, subject, html, text, headers })); +}; + +export const send = ({ + to, + from, + replyTo, + subject, + html, + text, + data, + headers, +}: { + to: string; + from: string; + replyTo?: string; + subject: string; + html?: string; + text?: string; + headers?: string; + data?: { [key: string]: unknown }; +}): void => + sendNoWrap({ + to, + from, + replyTo, + subject: replace(subject, data), + text: (text && replace(text, data)) + || (html && stripHtml(replace(html, data)).result) + || undefined, + html: html ? wrap(html, data) : undefined, + headers, + }); + +export const checkAddressFormatAndThrow = (from: string, func: Function): asserts from => { + if (checkAddressFormat(from)) { + return; + } + + throw new Meteor.Error('error-invalid-from-address', 'Invalid from address', { + function: func, + }); +}; + +export const getHeader = (): string | undefined => contentHeader; + +export const getFooter = (): string | undefined => contentFooter; diff --git a/app/mailer/server/replaceVariables.ts b/app/mailer/server/replaceVariables.ts new file mode 100644 index 0000000000000..4681fd1135c3e --- /dev/null +++ b/app/mailer/server/replaceVariables.ts @@ -0,0 +1,5 @@ +export const replaceVariables = ( + str: string, + replacer: (substring: string, key: string) => string, +): string => + str.replace(/\{ *([^\{\} ]+)[^\{\}]*\}/gmi, replacer); diff --git a/app/mailer/server/utils.js b/app/mailer/server/utils.js deleted file mode 100644 index 796215bd89c73..0000000000000 --- a/app/mailer/server/utils.js +++ /dev/null @@ -1 +0,0 @@ -export const replaceVariables = (str, callback) => str.replace(/\{ *([^\{\} ]+)[^\{\}]*\}/gmi, callback); diff --git a/app/mailer/tests/api.tests.js b/app/mailer/tests/api.spec.ts similarity index 52% rename from app/mailer/tests/api.tests.js rename to app/mailer/tests/api.spec.ts index 6ecf3b9f9de17..5bb1397c62e7f 100644 --- a/app/mailer/tests/api.tests.js +++ b/app/mailer/tests/api.spec.ts @@ -1,65 +1,66 @@ -/* eslint-env mocha */ -import assert from 'assert'; +import { expect } from 'chai'; -import { replaceVariables } from '../server/utils.js'; +import { replaceVariables } from '../server/replaceVariables'; describe('Mailer-API', function() { - describe('translate', () => { - const i18n = { + describe('replaceVariables', () => { + const i18n: { + [key: string]: string; + } = { key: 'value', }; describe('single key', function functionName() { it(`should be equal to test ${ i18n.key }`, () => { - assert.equal(`test ${ i18n.key }`, replaceVariables('test {key}', (match, key) => i18n[key])); + expect(`test ${ i18n.key }`).to.be.equal(replaceVariables('test {key}', (_match, key) => i18n[key])); }); }); describe('multiple keys', function functionName() { it(`should be equal to test ${ i18n.key } and ${ i18n.key }`, () => { - assert.equal(`test ${ i18n.key } and ${ i18n.key }`, replaceVariables('test {key} and {key}', (match, key) => i18n[key])); + expect(`test ${ i18n.key } and ${ i18n.key }`).to.be.equal(replaceVariables('test {key} and {key}', (_match, key) => i18n[key])); }); }); describe('key with a trailing space', function functionName() { it(`should be equal to test ${ i18n.key }`, () => { - assert.equal(`test ${ i18n.key }`, replaceVariables('test {key }', (match, key) => i18n[key])); + expect(`test ${ i18n.key }`).to.be.equal(replaceVariables('test {key }', (_match, key) => i18n[key])); }); }); describe('key with a leading space', function functionName() { it(`should be equal to test ${ i18n.key }`, () => { - assert.equal(`test ${ i18n.key }`, replaceVariables('test { key}', (match, key) => i18n[key])); + expect(`test ${ i18n.key }`).to.be.equal(replaceVariables('test { key}', (_match, key) => i18n[key])); }); }); describe('key with leading and trailing spaces', function functionName() { it(`should be equal to test ${ i18n.key }`, () => { - assert.equal(`test ${ i18n.key }`, replaceVariables('test { key }', (match, key) => i18n[key])); + expect(`test ${ i18n.key }`).to.be.equal(replaceVariables('test { key }', (_match, key) => i18n[key])); }); }); describe('key with multiple words', function functionName() { it(`should be equal to test ${ i18n.key }`, () => { - assert.equal(`test ${ i18n.key }`, replaceVariables('test {key ignore}', (match, key) => i18n[key])); + expect(`test ${ i18n.key }`).to.be.equal(replaceVariables('test {key ignore}', (_match, key) => i18n[key])); }); }); describe('key with multiple opening brackets', function functionName() { it(`should be equal to test {${ i18n.key }`, () => { - assert.equal(`test {${ i18n.key }`, replaceVariables('test {{key}', (match, key) => i18n[key])); + expect(`test {${ i18n.key }`).to.be.equal(replaceVariables('test {{key}', (_match, key) => i18n[key])); }); }); describe('key with multiple closing brackets', function functionName() { it(`should be equal to test ${ i18n.key }}`, () => { - assert.equal(`test ${ i18n.key }}`, replaceVariables('test {key}}', (match, key) => i18n[key])); + expect(`test ${ i18n.key }}`).to.be.equal(replaceVariables('test {key}}', (_match, key) => i18n[key])); }); }); describe('key with multiple opening and closing brackets', function functionName() { it(`should be equal to test {${ i18n.key }}`, () => { - assert.equal(`test {${ i18n.key }}`, replaceVariables('test {{key}}', (match, key) => i18n[key])); + expect(`test {${ i18n.key }}`).to.be.equal(replaceVariables('test {{key}}', (_match, key) => i18n[key])); }); }); }); diff --git a/app/markdown/tests/client.tests.js b/app/markdown/tests/client.tests.js index 83567dff63231..8d741f696ddb9 100644 --- a/app/markdown/tests/client.tests.js +++ b/app/markdown/tests/client.tests.js @@ -1,8 +1,6 @@ -/* eslint-env mocha */ -import 'babel-polyfill'; -import assert from 'assert'; - import './client.mocks.js'; + +import { expect } from 'chai'; import { escapeHTML } from '@rocket.chat/string-helpers'; import { original } from '../lib/parser/original/original'; @@ -375,7 +373,7 @@ const blockcodeFiltered = { 'Here```code```lies': 'Herecodelies', }; -const defaultObjectTest = (result, object, objectKey) => assert.equal(result.html, object[objectKey]); +const defaultObjectTest = (result, object, objectKey) => expect(result.html).to.be.equal(object[objectKey]); const testObject = (object, parser = original, test = defaultObjectTest) => { Object.keys(object).forEach((objectKey) => { @@ -435,7 +433,7 @@ describe('Filtered', function() { describe('blockcodeFilter', () => testObject(blockcodeFiltered, filtered)); }); -// describe.only('Marked', function() { +// describe('Marked', function() { // describe('Bold', () => testObject(bold, marked)); // describe('Italic', () => testObject(italic, marked)); diff --git a/app/mentions/tests/client.tests.js b/app/mentions/tests/client.tests.js index 5854ec14ba6ab..e90249dcf23c2 100644 --- a/app/mentions/tests/client.tests.js +++ b/app/mentions/tests/client.tests.js @@ -1,11 +1,9 @@ -/* eslint-env mocha */ -import 'babel-polyfill'; -import assert from 'assert'; +import { expect } from 'chai'; import { MentionsParser } from '../lib/MentionsParser'; let mentionsParser; -beforeEach(function functionName() { +beforeEach(() => { mentionsParser = new MentionsParser({ pattern: '[0-9a-zA-Z-_.]+', me: () => 'me', @@ -17,15 +15,15 @@ describe('Mention', function() { const regexp = '[0-9a-zA-Z-_.]+'; beforeEach(() => { mentionsParser.pattern = () => regexp; }); - describe('by function', function functionName() { + describe('by function', () => { it(`should be equal to ${ regexp }`, () => { - assert.equal(regexp, mentionsParser.pattern); + expect(regexp).to.be.equal(mentionsParser.pattern); }); }); - describe('by const', function functionName() { + describe('by const', () => { it(`should be equal to ${ regexp }`, () => { - assert.equal(regexp, mentionsParser.pattern); + expect(regexp).to.be.equal(mentionsParser.pattern); }); }); }); @@ -33,15 +31,15 @@ describe('Mention', function() { describe('get useRealName', () => { beforeEach(() => { mentionsParser.useRealName = () => true; }); - describe('by function', function functionName() { + describe('by function', () => { it('should be true', () => { - assert.equal(true, mentionsParser.useRealName); + expect(true).to.be.equal(mentionsParser.useRealName); }); }); - describe('by const', function functionName() { + describe('by const', () => { it('should be true', () => { - assert.equal(true, mentionsParser.useRealName); + expect(true).to.be.equal(mentionsParser.useRealName); }); }); }); @@ -49,24 +47,24 @@ describe('Mention', function() { describe('get me', () => { const me = 'me'; - describe('by function', function functionName() { + describe('by function', () => { beforeEach(() => { mentionsParser.me = () => me; }); it(`should be equal to ${ me }`, () => { - assert.equal(me, mentionsParser.me); + expect(me).to.be.equal(mentionsParser.me); }); }); - describe('by const', function functionName() { + describe('by const', () => { beforeEach(() => { mentionsParser.me = me; }); it(`should be equal to ${ me }`, () => { - assert.equal(me, mentionsParser.me); + expect(me).to.be.equal(mentionsParser.me); }); }); }); - describe('getUserMentions', function functionName() { + describe('getUserMentions', () => { describe('for simple text, no mentions', () => { const result = []; [ @@ -75,7 +73,7 @@ describe('Mention', function() { ] .forEach((text) => { it(`should return "${ JSON.stringify(result) }" from "${ text }"`, () => { - assert.deepEqual(result, mentionsParser.getUserMentions(text)); + expect(result).to.be.deep.equal(mentionsParser.getUserMentions(text)); }); }); }); @@ -93,20 +91,20 @@ describe('Mention', function() { ] .forEach((text) => { it(`should return "${ JSON.stringify(result) }" from "${ text }"`, () => { - assert.deepEqual(result, mentionsParser.getUserMentions(text)); + expect(result).to.be.deep.equal(mentionsParser.getUserMentions(text)); }); }); it.skip('should return without the "." from "@rocket.cat."', () => { - assert.deepEqual(result, mentionsParser.getUserMentions('@rocket.cat.')); + expect(result).to.be.deep.equal(mentionsParser.getUserMentions('@rocket.cat.')); }); it.skip('should return without the "_" from "@rocket.cat_"', () => { - assert.deepEqual(result, mentionsParser.getUserMentions('@rocket.cat_')); + expect(result).to.be.deep.equal(mentionsParser.getUserMentions('@rocket.cat_')); }); it.skip('should return without the "-" from "@rocket.cat-"', () => { - assert.deepEqual(result, mentionsParser.getUserMentions('@rocket.cat-')); + expect(result).to.be.deep.equal(mentionsParser.getUserMentions('@rocket.cat-')); }); }); @@ -121,13 +119,13 @@ describe('Mention', function() { ] .forEach((text) => { it(`should return "${ JSON.stringify(result) }" from "${ text }"`, () => { - assert.deepEqual(result, mentionsParser.getUserMentions(text)); + expect(result).to.be.deep.equal(mentionsParser.getUserMentions(text)); }); }); }); }); - describe('getChannelMentions', function functionName() { + describe('getChannelMentions', () => { describe('for simple text, no mentions', () => { const result = []; [ @@ -136,7 +134,7 @@ describe('Mention', function() { ] .forEach((text) => { it(`should return "${ JSON.stringify(result) }" from "${ text }"`, () => { - assert.deepEqual(result, mentionsParser.getChannelMentions(text)); + expect(result).to.be.deep.equal(mentionsParser.getChannelMentions(text)); }); }); }); @@ -151,20 +149,20 @@ describe('Mention', function() { 'hello #general, how are you?', ].forEach((text) => { it(`should return "${ JSON.stringify(result) }" from "${ text }"`, () => { - assert.deepEqual(result, mentionsParser.getChannelMentions(text)); + expect(result).to.be.deep.equal(mentionsParser.getChannelMentions(text)); }); }); it.skip('should return without the "." from "#general."', () => { - assert.deepEqual(result, mentionsParser.getUserMentions('#general.')); + expect(result).to.be.deep.equal(mentionsParser.getUserMentions('#general.')); }); it.skip('should return without the "_" from "#general_"', () => { - assert.deepEqual(result, mentionsParser.getUserMentions('#general_')); + expect(result).to.be.deep.equal(mentionsParser.getUserMentions('#general_')); }); it.skip('should return without the "-" from "#general."', () => { - assert.deepEqual(result, mentionsParser.getUserMentions('#general-')); + expect(result).to.be.deep.equal(mentionsParser.getUserMentions('#general-')); }); }); @@ -178,7 +176,7 @@ describe('Mention', function() { 'hello #general #other, how are you?', ].forEach((text) => { it(`should return "${ JSON.stringify(result) }" from "${ text }"`, () => { - assert.deepEqual(result, mentionsParser.getChannelMentions(text)); + expect(result).to.be.deep.equal(mentionsParser.getChannelMentions(text)); }); }); }); @@ -189,7 +187,7 @@ describe('Mention', function() { 'http://localhost/#general', ].forEach((text) => { it(`should return nothing from "${ text }"`, () => { - assert.deepEqual(result, mentionsParser.getChannelMentions(text)); + expect(result).to.be.deep.equal(mentionsParser.getChannelMentions(text)); }); }); }); @@ -200,7 +198,7 @@ describe('Mention', function() { 'http://localhost/#general #general', ].forEach((text) => { it(`should return "${ JSON.stringify(result) }" from "${ text }"`, () => { - assert.deepEqual(result, mentionsParser.getChannelMentions(text)); + expect(result).to.be.deep.equal(mentionsParser.getChannelMentions(text)); }); }); }); @@ -216,29 +214,29 @@ describe('replace methods', function() { describe('replaceUsers', () => { it('should render for @all', () => { const result = mentionsParser.replaceUsers('@all', message, 'me'); - assert.equal(result, 'all'); + expect(result).to.be.equal('all'); }); const str2 = 'rocket.cat'; it(`should render for "@${ str2 }"`, () => { const result = mentionsParser.replaceUsers(`@${ str2 }`, message, 'me'); - assert.equal(result, `${ str2 }`); + expect(result).to.be.equal(`${ str2 }`); }); it(`should render for "hello ${ str2 }"`, () => { const result = mentionsParser.replaceUsers(`hello @${ str2 }`, message, 'me'); - assert.equal(result, `hello ${ str2 }`); + expect(result).to.be.equal(`hello ${ str2 }`); }); it('should render for unknow/private user "hello @unknow"', () => { const result = mentionsParser.replaceUsers('hello @unknow', message, 'me'); - assert.equal(result, 'hello @unknow'); + expect(result).to.be.equal('hello @unknow'); }); it('should render for me', () => { const result = mentionsParser.replaceUsers('hello @me', message, 'me'); - assert.equal(result, 'hello me'); + expect(result).to.be.equal('hello me'); }); }); @@ -249,7 +247,7 @@ describe('replace methods', function() { it('should render for @all', () => { const result = mentionsParser.replaceUsers('@all', message, 'me'); - assert.equal(result, 'all'); + expect(result).to.be.equal('all'); }); const str2 = 'rocket.cat'; @@ -257,12 +255,12 @@ describe('replace methods', function() { it(`should render for "@${ str2 }"`, () => { const result = mentionsParser.replaceUsers(`@${ str2 }`, message, 'me'); - assert.equal(result, `${ str2Name }`); + expect(result).to.be.equal(`${ str2Name }`); }); it(`should render for "hello @${ str2 }"`, () => { const result = mentionsParser.replaceUsers(`hello @${ str2 }`, message, 'me'); - assert.equal(result, `hello ${ str2Name }`); + expect(result).to.be.equal(`hello ${ str2Name }`); }); const specialchars = 'specialchars'; @@ -270,46 +268,46 @@ describe('replace methods', function() { it(`should escape special characters in "hello @${ specialchars }"`, () => { const result = mentionsParser.replaceUsers(`hello @${ specialchars }`, message, 'me'); - assert.equal(result, `hello ${ specialcharsName }`); + expect(result).to.be.equal(`hello ${ specialcharsName }`); }); it(`should render for "hello
@${ str2 }
"`, () => { const result = mentionsParser.replaceUsers(`hello
@${ str2 }
`, message, 'me'); - assert.equal(result, `hello
${ str2Name }
`); + expect(result).to.be.equal(`hello
${ str2Name }
`); }); it('should render for unknow/private user "hello @unknow"', () => { const result = mentionsParser.replaceUsers('hello @unknow', message, 'me'); - assert.equal(result, 'hello @unknow'); + expect(result).to.be.equal('hello @unknow'); }); it('should render for me', () => { const result = mentionsParser.replaceUsers('hello @me', message, 'me'); - assert.equal(result, 'hello Me'); + expect(result).to.be.equal('hello Me'); }); }); describe('replaceChannels', () => { it('should render for #general', () => { const result = mentionsParser.replaceChannels('#general', message); - assert.equal('#general', result); + expect('<).to.be.equal(class="mention-link mention-link--room" data-channel="42">#general', result); }); const str2 = '#rocket.cat'; it(`should render for ${ str2 }`, () => { const result = mentionsParser.replaceChannels(str2, message); - assert.equal(result, `${ str2 }`); + expect(result).to.be.equal(`${ str2 }`); }); it(`should render for "hello ${ str2 }"`, () => { const result = mentionsParser.replaceChannels(`hello ${ str2 }`, message); - assert.equal(result, `hello ${ str2 }`); + expect(result).to.be.equal(`hello ${ str2 }`); }); it('should render for unknow/private channel "hello #unknow"', () => { const result = mentionsParser.replaceChannels('hello #unknow', message); - assert.equal(result, 'hello #unknow'); + expect(result).to.be.equal('hello #unknow'); }); }); @@ -317,25 +315,25 @@ describe('replace methods', function() { it('should render for #general', () => { message.html = '#general'; const result = mentionsParser.parse(message, 'me'); - assert.equal(result.html, '#general'); + expect(result.html).to.be.equal('#general'); }); it('should render for "#general and @rocket.cat', () => { message.html = '#general and @rocket.cat'; const result = mentionsParser.parse(message, 'me'); - assert.equal(result.html, '#general and rocket.cat'); + expect(result.html).to.be.equal('#general and rocket.cat'); }); it('should render for "', () => { message.html = ''; const result = mentionsParser.parse(message, 'me'); - assert.equal(result.html, ''); + expect(result.html).to.be.equal(''); }); it('should render for "simple text', () => { message.html = 'simple text'; const result = mentionsParser.parse(message, 'me'); - assert.equal(result.html, 'simple text'); + expect(result.html).to.be.equal('simple text'); }); }); @@ -347,25 +345,25 @@ describe('replace methods', function() { it('should render for #general', () => { message.html = '#general'; const result = mentionsParser.parse(message, 'me'); - assert.equal(result.html, '#general'); + expect(result.html).to.be.equal('#general'); }); it('should render for "#general and @rocket.cat', () => { message.html = '#general and @rocket.cat'; const result = mentionsParser.parse(message, 'me'); - assert.equal(result.html, '#general and Rocket.Cat'); + expect(result.html).to.be.equal('#general and Rocket.Cat'); }); it('should render for "', () => { message.html = ''; const result = mentionsParser.parse(message, 'me'); - assert.equal(result.html, ''); + expect(result.html).to.be.equal(''); }); it('should render for "simple text', () => { message.html = 'simple text'; const result = mentionsParser.parse(message, 'me'); - assert.equal(result.html, 'simple text'); + expect(result.html).to.be.equal('simple text'); }); }); }); diff --git a/app/mentions/tests/server.tests.js b/app/mentions/tests/server.tests.js index 30bc075649840..a1a77bac30584 100644 --- a/app/mentions/tests/server.tests.js +++ b/app/mentions/tests/server.tests.js @@ -1,6 +1,4 @@ -/* eslint-env mocha */ -import 'babel-polyfill'; -import assert from 'assert'; +import { expect } from 'chai'; import MentionsServer from '../server/Mentions'; @@ -43,7 +41,7 @@ describe('Mention Server', () => { }; const expected = []; const result = mention.getUsersByMentions(message); - assert.deepEqual(expected, result); + expect(expected).to.be.deep.equal(result); }); }); describe('for one user', () => { @@ -69,7 +67,7 @@ describe('Mention Server', () => { username: 'all', }]; const result = mention.getUsersByMentions(message); - assert.deepEqual(expected, result); + expect(expected).to.be.deep.equal(result); }); it('should return "here"', () => { const message = { @@ -80,7 +78,7 @@ describe('Mention Server', () => { username: 'here', }]; const result = mention.getUsersByMentions(message); - assert.deepEqual(expected, result); + expect(expected).to.be.deep.equal(result); }); it('should return "rocket.cat"', () => { const message = { @@ -91,7 +89,7 @@ describe('Mention Server', () => { username: 'rocket.cat', }]; const result = mention.getUsersByMentions(message); - assert.deepEqual(expected, result); + expect(expected).to.be.deep.equal(result); }); }); describe('for two user', () => { @@ -107,7 +105,7 @@ describe('Mention Server', () => { username: 'here', }]; const result = mention.getUsersByMentions(message); - assert.deepEqual(expected, result); + expect(expected).to.be.deep.equal(result); }); it('should return "here and rocket.cat"', () => { const message = { @@ -121,7 +119,7 @@ describe('Mention Server', () => { username: 'rocket.cat', }]; const result = mention.getUsersByMentions(message); - assert.deepEqual(expected, result); + expect(expected).to.be.deep.equal(result); }); it('should return "here, rocket.cat, jon"', () => { @@ -139,7 +137,7 @@ describe('Mention Server', () => { username: 'jon', }]; const result = mention.getUsersByMentions(message); - assert.deepEqual(expected, result); + expect(expected).to.be.deep.equal(result); }); }); @@ -150,7 +148,7 @@ describe('Mention Server', () => { }; const expected = []; const result = mention.getUsersByMentions(message); - assert.deepEqual(expected, result); + expect(expected).to.be.deep.equal(result); }); }); }); @@ -164,7 +162,7 @@ describe('Mention Server', () => { name: 'general', }]; const result = mention.getChannelbyMentions(message); - assert.deepEqual(result, expected); + expect(result).to.be.deep.equal(expected); }); it('should return nothing"', () => { const message = { @@ -172,7 +170,7 @@ describe('Mention Server', () => { }; const expected = []; const result = mention.getChannelbyMentions(message); - assert.deepEqual(result, expected); + expect(result).to.be.deep.equal(expected); }); }); describe('execute', () => { @@ -185,7 +183,7 @@ describe('Mention Server', () => { name: 'general', }]; const result = mention.getChannelbyMentions(message); - assert.deepEqual(result, expected); + expect(result).to.be.deep.equal(expected); }); it('should return nothing"', () => { const message = { @@ -197,7 +195,7 @@ describe('Mention Server', () => { channels: [], }; const result = mention.execute(message); - assert.deepEqual(result, expected); + expect(result).to.be.deep.equal(expected); }); }); @@ -207,13 +205,13 @@ describe('Mention Server', () => { describe('constant', () => { it('should return the informed value', () => { mention.messageMaxAll = 4; - assert.deepEqual(mention.messageMaxAll, 4); + expect(mention.messageMaxAll).to.be.deep.equal(4); }); }); describe('function', () => { it('should return the informed value', () => { mention.messageMaxAll = () => 4; - assert.deepEqual(mention.messageMaxAll, 4); + expect(mention.messageMaxAll).to.be.deep.equal(4); }); }); }); @@ -222,13 +220,13 @@ describe('Mention Server', () => { describe('constant', () => { it('should return the informed value', () => { mention.getUsers = 4; - assert.deepEqual(mention.getUsers(), 4); + expect(mention.getUsers()).to.be.deep.equal(4); }); }); describe('function', () => { it('should return the informed value', () => { mention.getUsers = () => 4; - assert.deepEqual(mention.getUsers(), 4); + expect(mention.getUsers()).to.be.deep.equal(4); }); }); }); @@ -237,13 +235,13 @@ describe('Mention Server', () => { describe('constant', () => { it('should return the informed value', () => { mention.getChannels = 4; - assert.deepEqual(mention.getChannels(), 4); + expect(mention.getChannels()).to.be.deep.equal(4); }); }); describe('function', () => { it('should return the informed value', () => { mention.getChannels = () => 4; - assert.deepEqual(mention.getChannels(), 4); + expect(mention.getChannels()).to.be.deep.equal(4); }); }); }); @@ -252,13 +250,13 @@ describe('Mention Server', () => { describe('constant', () => { it('should return the informed value', () => { mention.getChannel = true; - assert.deepEqual(mention.getChannel(), true); + expect(mention.getChannel()).to.be.deep.equal(true); }); }); describe('function', () => { it('should return the informed value', () => { mention.getChannel = () => true; - assert.deepEqual(mention.getChannel(), true); + expect(mention.getChannel()).to.be.deep.equal(true); }); }); }); diff --git a/app/message-pin/server/pinMessage.js b/app/message-pin/server/pinMessage.js index 4543496ad2a07..545a350b7d6aa 100644 --- a/app/message-pin/server/pinMessage.js +++ b/app/message-pin/server/pinMessage.js @@ -1,11 +1,11 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; -import { settings } from '../../settings'; -import { callbacks } from '../../callbacks'; -import { isTheLastMessage } from '../../lib'; +import { settings } from '../../settings/server'; +import { callbacks } from '../../callbacks/server'; +import { isTheLastMessage } from '../../lib/server'; import { getUserAvatarURL } from '../../utils/lib/getUserAvatarURL'; -import { hasPermission } from '../../authorization'; +import { canAccessRoom, hasPermission } from '../../authorization/server'; import { Subscriptions, Messages, Users, Rooms } from '../../models'; const recursiveRemove = (msg, deep = 1) => { @@ -72,7 +72,11 @@ Meteor.methods({ if (settings.get('Message_KeepHistory')) { Messages.cloneAndSaveAsHistoryById(message._id, me); } - const room = Meteor.call('canAccessRoom', originalMessage.rid, Meteor.userId()); + + const room = Rooms.findOneById(originalMessage.rid); + if (!canAccessRoom(room, { _id: Meteor.userId() })) { + throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'pinMessage' }); + } originalMessage.pinned = true; originalMessage.pinnedAt = pinnedAt || Date.now; @@ -166,7 +170,12 @@ Meteor.methods({ username: me.username, }; originalMessage = callbacks.run('beforeSaveMessage', originalMessage); - const room = Meteor.call('canAccessRoom', originalMessage.rid, Meteor.userId()); + + const room = Rooms.findOneById(originalMessage.rid, { fields: { lastMessage: 1 } }); + if (!canAccessRoom(room, { _id: Meteor.userId() })) { + throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'unpinMessage' }); + } + if (isTheLastMessage(room, message)) { Rooms.setLastMessagePinned(room._id, originalMessage.pinnedBy, originalMessage.pinned); } diff --git a/app/message-star/server/starMessage.js b/app/message-star/server/starMessage.js index 4d22d67278359..097b640f31efd 100644 --- a/app/message-star/server/starMessage.js +++ b/app/message-star/server/starMessage.js @@ -1,8 +1,9 @@ import { Meteor } from 'meteor/meteor'; -import { settings } from '../../settings'; -import { isTheLastMessage } from '../../lib'; -import { Subscriptions, Rooms, Messages } from '../../models'; +import { settings } from '../../settings/server'; +import { isTheLastMessage } from '../../lib/server'; +import { canAccessRoom } from '../../authorization/server'; +import { Subscriptions, Rooms, Messages } from '../../models/server'; Meteor.methods({ starMessage(message) { @@ -26,7 +27,12 @@ Meteor.methods({ if (!Messages.findOneByRoomIdAndMessageId(message.rid, message._id)) { return false; } - const room = Meteor.call('canAccessRoom', message.rid, Meteor.userId()); + + const room = Rooms.findOneById(message.rid, { fields: { lastMessage: 1 } }); + if (!canAccessRoom(room, { _id: Meteor.userId() })) { + throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'starMessage' }); + } + if (isTheLastMessage(room, message)) { Rooms.updateLastMessageStar(room._id, Meteor.userId(), message.starred); } diff --git a/app/meteor-accounts-saml/server/lib/SAML.ts b/app/meteor-accounts-saml/server/lib/SAML.ts index 5de64393352a5..a0c60ff1d3a59 100644 --- a/app/meteor-accounts-saml/server/lib/SAML.ts +++ b/app/meteor-accounts-saml/server/lib/SAML.ts @@ -8,7 +8,8 @@ import fiber from 'fibers'; import { escapeRegExp, escapeHTML } from '@rocket.chat/string-helpers'; import { settings } from '../../../settings/server'; -import { Users, Rooms, CredentialTokens } from '../../../models/server'; +import { Users, Rooms } from '../../../models/server'; +import { CredentialTokens } from '../../../models/server/raw'; import { IUser } from '../../../../definition/IUser'; import { IIncomingMessage } from '../../../../definition/IIncomingMessage'; import { saveUserIdentity, createRoom, generateUsernameSuggestion, addUserToRoom } from '../../../lib/server/functions'; @@ -55,20 +56,20 @@ export class SAML { } } - public static hasCredential(credentialToken: string): boolean { - return CredentialTokens.findOneById(credentialToken) != null; + public static async hasCredential(credentialToken: string): Promise { + return await CredentialTokens.findOneNotExpiredById(credentialToken) != null; } - public static retrieveCredential(credentialToken: string): Record | undefined { + public static async retrieveCredential(credentialToken: string): Promise | undefined> { // The credentialToken in all these functions corresponds to SAMLs inResponseTo field and is mandatory to check. - const data = CredentialTokens.findOneById(credentialToken); + const data = await CredentialTokens.findOneNotExpiredById(credentialToken); if (data) { return data.userInfo; } } - public static storeCredential(credentialToken: string, loginResult: object): void { - CredentialTokens.create(credentialToken, loginResult); + public static async storeCredential(credentialToken: string, loginResult: {profile: Record}): Promise { + await CredentialTokens.create(credentialToken, loginResult); } public static insertOrUpdateSAMLUser(userObject: ISAMLUser): {userId: string; token: string} { @@ -380,7 +381,7 @@ export class SAML { private static processValidateAction(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions, _samlObject: ISAMLAction): void { const serviceProvider = new SAMLServiceProvider(service); SAMLUtils.relayState = req.body.RelayState; - serviceProvider.validateResponse(req.body.SAMLResponse, (err, profile/* , loggedOut*/) => { + serviceProvider.validateResponse(req.body.SAMLResponse, async (err, profile/* , loggedOut*/) => { try { if (err) { SAMLUtils.error(err); @@ -400,7 +401,7 @@ export class SAML { profile, }; - this.storeCredential(credentialToken, loginResult); + await this.storeCredential(credentialToken, loginResult); const url = `${ Meteor.absoluteUrl('home') }?saml_idp_credentialToken=${ credentialToken }`; res.writeHead(302, { Location: url, diff --git a/app/meteor-accounts-saml/server/lib/parsers/Response.ts b/app/meteor-accounts-saml/server/lib/parsers/Response.ts index 89bbc07580936..f92ba7fedef20 100644 --- a/app/meteor-accounts-saml/server/lib/parsers/Response.ts +++ b/app/meteor-accounts-saml/server/lib/parsers/Response.ts @@ -176,7 +176,7 @@ export class ResponseParser { if (typeof encAssertion !== 'undefined') { const options = { key: this.serviceProviderOptions.privateKey }; const encData = encAssertion.getElementsByTagNameNS('*', 'EncryptedData')[0]; - xmlenc.decrypt(encData, options, function(err: Error, result: string) { + xmlenc.decrypt(encData, options, function(err, result) { if (err) { SAMLUtils.error(err); } @@ -318,7 +318,7 @@ export class ResponseParser { if (typeof encSubject !== 'undefined') { const options = { key: this.serviceProviderOptions.privateKey }; - xmlenc.decrypt(encSubject.getElementsByTagNameNS('*', 'EncryptedData')[0], options, function(err: Error, result: string) { + xmlenc.decrypt(encSubject.getElementsByTagNameNS('*', 'EncryptedData')[0], options, (err, result) => { if (err) { SAMLUtils.error(err); } diff --git a/app/meteor-accounts-saml/server/loginHandler.ts b/app/meteor-accounts-saml/server/loginHandler.ts index 6b73c7f386ec3..edb58716d974d 100644 --- a/app/meteor-accounts-saml/server/loginHandler.ts +++ b/app/meteor-accounts-saml/server/loginHandler.ts @@ -17,7 +17,7 @@ Accounts.registerLoginHandler('saml', function(loginRequest) { return undefined; } - const loginResult = SAML.retrieveCredential(loginRequest.credentialToken); + const loginResult = Promise.await(SAML.retrieveCredential(loginRequest.credentialToken)); SAMLUtils.log({ msg: 'RESULT', loginResult }); if (!loginResult) { diff --git a/app/meteor-accounts-saml/tests/server.tests.ts b/app/meteor-accounts-saml/tests/server.tests.ts index d8ca3ba2f7023..2b64134e22a94 100644 --- a/app/meteor-accounts-saml/tests/server.tests.ts +++ b/app/meteor-accounts-saml/tests/server.tests.ts @@ -1,7 +1,4 @@ -/* eslint-env mocha */ -import 'babel-polyfill'; - -import chai from 'chai'; +import { expect } from 'chai'; import '../../lib/tests/server.mocks.js'; import { AuthorizeRequest } from '../server/lib/generators/AuthorizeRequest'; @@ -38,9 +35,6 @@ import { privateKeyCert, privateKey, } from './data'; -import '../../../definition/xml-encryption'; - -const { expect } = chai; describe('SAML', () => { describe('[AuthorizeRequest]', () => { diff --git a/app/metrics/server/lib/collectMetrics.js b/app/metrics/server/lib/collectMetrics.js index 98267f9ca855f..f72437df2f047 100644 --- a/app/metrics/server/lib/collectMetrics.js +++ b/app/metrics/server/lib/collectMetrics.js @@ -10,7 +10,7 @@ import { Facts } from 'meteor/facts-base'; import { Info, getOplogInfo } from '../../../utils/server'; import { getControl } from '../../../../server/lib/migrations'; import { settings } from '../../../settings/server'; -import { Statistics } from '../../../models/server'; +import { Statistics } from '../../../models/server/raw'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { metrics } from './metrics'; import { getAppsStatistics } from '../../../statistics/server/lib/getAppsStatistics'; @@ -42,7 +42,7 @@ const setPrometheusData = async () => { const oplogQueue = getOplogInfo().mongo._oplogHandle?._entryQueue?.length || 0; metrics.oplogQueue.set(oplogQueue); - const statistics = Statistics.findLast(); + const statistics = await Statistics.findLast(); if (!statistics) { return; } diff --git a/app/models/server/index.js b/app/models/server/index.js index 8c9094d654ef9..5507cf53f0aa9 100644 --- a/app/models/server/index.js +++ b/app/models/server/index.js @@ -1,31 +1,11 @@ import { Base } from './models/_Base'; import { BaseDb } from './models/_BaseDb'; -import Avatars from './models/Avatars'; -import ExportOperations from './models/ExportOperations'; import Messages from './models/Messages'; -import Reports from './models/Reports'; import Rooms from './models/Rooms'; import Settings from './models/Settings'; import Subscriptions from './models/Subscriptions'; -import Uploads from './models/Uploads'; -import UserDataFiles from './models/UserDataFiles'; import Users from './models/Users'; -import Sessions from './models/Sessions'; -import Statistics from './models/Statistics'; -import Permissions from './models/Permissions'; -import Roles from './models/Roles'; -import CustomSounds from './models/CustomSounds'; -import CustomUserStatus from './models/CustomUserStatus'; import Imports from './models/Imports'; -import Integrations from './models/Integrations'; -import IntegrationHistory from './models/IntegrationHistory'; -import Invites from './models/Invites'; -import CredentialTokens from './models/CredentialTokens'; -import EmojiCustom from './models/EmojiCustom'; -import OAuthApps from './models/OAuthApps'; -import OEmbedCache from './models/OEmbedCache'; -import SmarshHistory from './models/SmarshHistory'; -import WebdavAccounts from './models/WebdavAccounts'; import LivechatCustomField from './models/LivechatCustomField'; import LivechatDepartment from './models/LivechatDepartment'; import LivechatDepartmentAgents from './models/LivechatDepartmentAgents'; @@ -35,50 +15,24 @@ import LivechatTrigger from './models/LivechatTrigger'; import LivechatVisitors from './models/LivechatVisitors'; import LivechatAgentActivity from './models/LivechatAgentActivity'; import LivechatInquiry from './models/LivechatInquiry'; -import ReadReceipts from './models/ReadReceipts'; import LivechatExternalMessage from './models/LivechatExternalMessages'; import OmnichannelQueue from './models/OmnichannelQueue'; -import Analytics from './models/Analytics'; -import EmailInbox from './models/EmailInbox'; import ImportData from './models/ImportData'; export { AppsLogsModel } from './models/apps-logs-model'; export { AppsPersistenceModel } from './models/apps-persistence-model'; export { AppsModel } from './models/apps-model'; -export { FederationDNSCache } from './models/FederationDNSCache'; export { FederationRoomEvents } from './models/FederationRoomEvents'; -export { FederationKeys } from './models/FederationKeys'; -export { FederationServers } from './models/FederationServers'; export { Base, BaseDb, - Avatars, - ExportOperations, Messages, - Reports, Rooms, Settings, Subscriptions, - Uploads, - UserDataFiles, Users, - Sessions, - Statistics, - Permissions, - Roles, - CustomSounds, - CustomUserStatus, Imports, - Integrations, - IntegrationHistory, - Invites, - CredentialTokens, - EmojiCustom, - OAuthApps, - OEmbedCache, - SmarshHistory, - WebdavAccounts, LivechatCustomField, LivechatDepartment, LivechatDepartmentAgents, @@ -87,11 +41,8 @@ export { LivechatTrigger, LivechatVisitors, LivechatAgentActivity, - ReadReceipts, LivechatExternalMessage, LivechatInquiry, - Analytics, OmnichannelQueue, - EmailInbox, ImportData, }; diff --git a/app/models/server/models/Analytics.js b/app/models/server/models/Analytics.js deleted file mode 100644 index c521fda8923ed..0000000000000 --- a/app/models/server/models/Analytics.js +++ /dev/null @@ -1,11 +0,0 @@ -import { Base } from './_Base'; - -export class Analytics extends Base { - constructor() { - super('analytics'); - this.tryEnsureIndex({ date: 1 }); - this.tryEnsureIndex({ 'room._id': 1, date: 1 }, { unique: true }); - } -} - -export default new Analytics(); diff --git a/app/models/server/models/Avatars.js b/app/models/server/models/Avatars.js deleted file mode 100644 index b0e7e8cb5da1e..0000000000000 --- a/app/models/server/models/Avatars.js +++ /dev/null @@ -1,92 +0,0 @@ -import _ from 'underscore'; -import s from 'underscore.string'; -import { InstanceStatus } from 'meteor/konecty:multiple-instances-status'; - -import { Base } from './_Base'; - -export class Avatars extends Base { - constructor() { - super('avatars'); - - this.model.before.insert((userId, doc) => { - doc.instanceId = InstanceStatus.id(); - }); - - this.tryEnsureIndex({ name: 1 }, { sparse: true }); - this.tryEnsureIndex({ rid: 1 }, { sparse: true }); - } - - insertAvatarFileInit(name, userId, store, file, extra) { - const fileData = { - _id: name, - name, - userId, - store, - complete: false, - uploading: true, - progress: 0, - extension: s.strRightBack(file.name, '.'), - uploadedAt: new Date(), - }; - - _.extend(fileData, file, extra); - - return this.insertOrUpsert(fileData); - } - - updateFileComplete(fileId, userId, file) { - if (!fileId) { - return; - } - - const filter = { - _id: fileId, - userId, - }; - - const update = { - $set: { - complete: true, - uploading: false, - progress: 1, - }, - }; - - update.$set = _.extend(file, update.$set); - - if (this.model.direct && this.model.direct.update) { - return this.model.direct.update(filter, update); - } - return this.update(filter, update); - } - - findOneByName(name) { - return this.findOne({ name }); - } - - findOneByRoomId(rid) { - return this.findOne({ rid }); - } - - updateFileNameById(fileId, name) { - const filter = { _id: fileId }; - const update = { - $set: { - name, - }, - }; - if (this.model.direct && this.model.direct.update) { - return this.model.direct.update(filter, update); - } - return this.update(filter, update); - } - - deleteFile(fileId) { - if (this.model.direct && this.model.direct.remove) { - return this.model.direct.remove({ _id: fileId }); - } - return this.remove({ _id: fileId }); - } -} - -export default new Avatars(); diff --git a/app/models/server/models/CredentialTokens.js b/app/models/server/models/CredentialTokens.js deleted file mode 100644 index 7659538e032eb..0000000000000 --- a/app/models/server/models/CredentialTokens.js +++ /dev/null @@ -1,32 +0,0 @@ -import { Base } from './_Base'; - -export class CredentialTokens extends Base { - constructor() { - super('credential_tokens'); - - this.tryEnsureIndex({ expireAt: 1 }, { sparse: 1, expireAfterSeconds: 0 }); - } - - create(_id, userInfo) { - const validForMilliseconds = 60000; // Valid for 60 seconds - const token = { - _id, - userInfo, - expireAt: new Date(Date.now() + validForMilliseconds), - }; - - this.insert(token); - return token; - } - - findOneById(_id) { - const query = { - _id, - expireAt: { $gt: new Date() }, - }; - - return this.findOne(query); - } -} - -export default new CredentialTokens(); diff --git a/app/models/server/models/CustomSounds.js b/app/models/server/models/CustomSounds.js deleted file mode 100644 index b9971b9542298..0000000000000 --- a/app/models/server/models/CustomSounds.js +++ /dev/null @@ -1,56 +0,0 @@ -import { Base } from './_Base'; - -class CustomSounds extends Base { - constructor() { - super('custom_sounds'); - - this.tryEnsureIndex({ name: 1 }); - } - - // find one - findOneById(_id, options) { - return this.findOne(_id, options); - } - - // find - findByName(name, options) { - const query = { - name, - }; - - return this.find(query, options); - } - - findByNameExceptId(name, except, options) { - const query = { - _id: { $nin: [except] }, - name, - }; - - return this.find(query, options); - } - - // update - setName(_id, name) { - const update = { - $set: { - name, - }, - }; - - return this.update({ _id }, update); - } - - // INSERT - create(data) { - return this.insert(data); - } - - - // REMOVE - removeById(_id) { - return this.remove(_id); - } -} - -export default new CustomSounds(); diff --git a/app/models/server/models/CustomUserStatus.js b/app/models/server/models/CustomUserStatus.js deleted file mode 100644 index eb3a586da6ba1..0000000000000 --- a/app/models/server/models/CustomUserStatus.js +++ /dev/null @@ -1,71 +0,0 @@ -import { Base } from './_Base'; - -class CustomUserStatus extends Base { - constructor() { - super('custom_user_status'); - - this.tryEnsureIndex({ name: 1 }); - } - - // find one - findOneById(_id, options) { - return this.findOne(_id, options); - } - - // find one by name - findOneByName(name, options) { - return this.findOne({ name }, options); - } - - // find - findByName(name, options) { - const query = { - name, - }; - - return this.find(query, options); - } - - findByNameExceptId(name, except, options) { - const query = { - _id: { $nin: [except] }, - name, - }; - - return this.find(query, options); - } - - // update - setName(_id, name) { - const update = { - $set: { - name, - }, - }; - - return this.update({ _id }, update); - } - - setStatusType(_id, statusType) { - const update = { - $set: { - statusType, - }, - }; - - return this.update({ _id }, update); - } - - // INSERT - create(data) { - return this.insert(data); - } - - - // REMOVE - removeById(_id) { - return this.remove(_id); - } -} - -export default new CustomUserStatus(); diff --git a/app/models/server/models/EmailInbox.js b/app/models/server/models/EmailInbox.js deleted file mode 100644 index 490628be33837..0000000000000 --- a/app/models/server/models/EmailInbox.js +++ /dev/null @@ -1,27 +0,0 @@ -import { Base } from './_Base'; - -export class EmailInbox extends Base { - constructor() { - super('email_inbox'); - - this.tryEnsureIndex({ email: 1 }, { unique: true }); - } - - findOneById(_id, options) { - return this.findOne(_id, options); - } - - create(data) { - return this.insert(data); - } - - updateById(_id, data) { - return this.update({ _id }, data); - } - - removeById(_id) { - return this.remove(_id); - } -} - -export default new EmailInbox(); diff --git a/app/models/server/models/EmojiCustom.js b/app/models/server/models/EmojiCustom.js deleted file mode 100644 index d0cd7d7bc4cba..0000000000000 --- a/app/models/server/models/EmojiCustom.js +++ /dev/null @@ -1,91 +0,0 @@ -import { Base } from './_Base'; - -class EmojiCustom extends Base { - constructor() { - super('custom_emoji'); - - this.tryEnsureIndex({ name: 1 }); - this.tryEnsureIndex({ aliases: 1 }); - this.tryEnsureIndex({ extension: 1 }); - } - - // find one - findOneById(_id, options) { - return this.findOne(_id, options); - } - - // find - findByNameOrAlias(emojiName, options) { - let name = emojiName; - - if (typeof emojiName === 'string') { - name = emojiName.replace(/:/g, ''); - } - - const query = { - $or: [ - { name }, - { aliases: name }, - ], - }; - - return this.find(query, options); - } - - findByNameOrAliasExceptID(name, except, options) { - const query = { - _id: { $nin: [except] }, - $or: [ - { name }, - { aliases: name }, - ], - }; - - return this.find(query, options); - } - - - // update - setName(_id, name) { - const update = { - $set: { - name, - }, - }; - - return this.update({ _id }, update); - } - - setAliases(_id, aliases) { - const update = { - $set: { - aliases, - }, - }; - - return this.update({ _id }, update); - } - - setExtension(_id, extension) { - const update = { - $set: { - extension, - }, - }; - - return this.update({ _id }, update); - } - - // INSERT - create(data) { - return this.insert(data); - } - - - // REMOVE - removeById(_id) { - return this.remove(_id); - } -} - -export default new EmojiCustom(); diff --git a/app/models/server/models/ExportOperations.js b/app/models/server/models/ExportOperations.js deleted file mode 100644 index fb70d38925cab..0000000000000 --- a/app/models/server/models/ExportOperations.js +++ /dev/null @@ -1,108 +0,0 @@ -import _ from 'underscore'; - -import { Base } from './_Base'; - -export class ExportOperations extends Base { - constructor() { - super('export_operations'); - - this.tryEnsureIndex({ userId: 1 }); - this.tryEnsureIndex({ status: 1 }); - } - - // FIND - findById(id) { - const query = { _id: id }; - - return this.find(query); - } - - findLastOperationByUser(userId, fullExport = false, options = {}) { - const query = { - userId, - fullExport, - }; - - options.sort = { createdAt: -1 }; - return this.findOne(query, options); - } - - findPendingByUser(userId, options) { - const query = { - userId, - status: { - $nin: ['completed', 'skipped'], - }, - }; - - return this.find(query, options); - } - - findAllPending(options) { - const query = { - status: { $nin: ['completed', 'skipped'] }, - }; - - return this.find(query, options); - } - - findOnePending(options) { - const query = { - status: { $nin: ['completed', 'skipped'] }, - }; - - return this.findOne(query, options); - } - - findAllPendingBeforeMyRequest(requestDay, options) { - const query = { - status: { $nin: ['completed', 'skipped'] }, - createdAt: { $lt: requestDay }, - }; - - return this.find(query, options); - } - - // UPDATE - updateOperation(data) { - const update = { - $set: { - roomList: data.roomList, - status: data.status, - fileList: data.fileList, - generatedFile: data.generatedFile, - fileId: data.fileId, - userNameTable: data.userNameTable, - userData: data.userData, - generatedUserFile: data.generatedUserFile, - generatedAvatar: data.generatedAvatar, - exportPath: data.exportPath, - assetsPath: data.assetsPath, - }, - }; - - return this.update(data._id, update); - } - - - // INSERT - create(data) { - const exportOperation = { - createdAt: new Date(), - }; - - _.extend(exportOperation, data); - - this.insert(exportOperation); - - return exportOperation._id; - } - - - // REMOVE - removeById(_id) { - return this.remove(_id); - } -} - -export default new ExportOperations(); diff --git a/app/models/server/models/FederationDNSCache.js b/app/models/server/models/FederationDNSCache.js deleted file mode 100644 index 155deed53b956..0000000000000 --- a/app/models/server/models/FederationDNSCache.js +++ /dev/null @@ -1,13 +0,0 @@ -import { Base } from './_Base'; - -class FederationDNSCacheModel extends Base { - constructor() { - super('federation_dns_cache'); - } - - findOneByDomain(domain) { - return this.findOne({ domain }); - } -} - -export const FederationDNSCache = new FederationDNSCacheModel(); diff --git a/app/models/server/models/FederationKeys.js b/app/models/server/models/FederationKeys.js deleted file mode 100644 index 188f7cdc434e4..0000000000000 --- a/app/models/server/models/FederationKeys.js +++ /dev/null @@ -1,58 +0,0 @@ -import NodeRSA from 'node-rsa'; - -import { Base } from './_Base'; - -class FederationKeysModel extends Base { - constructor() { - super('federation_keys'); - } - - getKey(type) { - const keyResource = this.findOne({ type }); - - if (!keyResource) { return null; } - - return keyResource.key; - } - - loadKey(keyData, type) { - return new NodeRSA(keyData, `pkcs8-${ type }-pem`); - } - - generateKeys() { - const key = new NodeRSA({ b: 512 }); - - key.generateKeyPair(); - - this.update({ type: 'private' }, { type: 'private', key: key.exportKey('pkcs8-private-pem').replace(/\n|\r/g, '') }, { upsert: true }); - - this.update({ type: 'public' }, { type: 'public', key: key.exportKey('pkcs8-public-pem').replace(/\n|\r/g, '') }, { upsert: true }); - - return { - privateKey: this.getPrivateKey(), - publicKey: this.getPublicKey(), - }; - } - - getPrivateKey() { - const keyData = this.getKey('private'); - - return keyData && this.loadKey(keyData, 'private'); - } - - getPrivateKeyString() { - return this.getKey('private'); - } - - getPublicKey() { - const keyData = this.getKey('public'); - - return keyData && this.loadKey(keyData, 'public'); - } - - getPublicKeyString() { - return this.getKey('public'); - } -} - -export const FederationKeys = new FederationKeysModel(); diff --git a/app/models/server/models/FederationServers.js b/app/models/server/models/FederationServers.js deleted file mode 100644 index 9daf20d5a1284..0000000000000 --- a/app/models/server/models/FederationServers.js +++ /dev/null @@ -1,26 +0,0 @@ -import { Base } from './_Base'; -import { Users } from '../raw'; - -class FederationServersModel extends Base { - constructor() { - super('federation_servers'); - - this.tryEnsureIndex({ domain: 1 }); - } - - async refreshServers() { - const domains = await Users.getDistinctFederationDomains(); - - domains.forEach((domain) => { - this.update({ domain }, { - $setOnInsert: { - domain, - }, - }, { upsert: true }); - }); - - this.remove({ domain: { $nin: domains } }); - } -} - -export const FederationServers = new FederationServersModel(); diff --git a/app/models/server/models/InstanceStatus.js b/app/models/server/models/InstanceStatus.js deleted file mode 100644 index 344381e442663..0000000000000 --- a/app/models/server/models/InstanceStatus.js +++ /dev/null @@ -1,7 +0,0 @@ -import { InstanceStatus } from 'meteor/konecty:multiple-instances-status'; - -import { Base } from './_Base'; - -export class InstanceStatusModel extends Base {} - -export default new InstanceStatusModel(InstanceStatus.getCollection(), { preventSetUpdatedAt: true }); diff --git a/app/models/server/models/IntegrationHistory.js b/app/models/server/models/IntegrationHistory.js deleted file mode 100644 index 817deae0d789a..0000000000000 --- a/app/models/server/models/IntegrationHistory.js +++ /dev/null @@ -1,43 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { Base } from './_Base'; - -export class IntegrationHistory extends Base { - constructor() { - super('integration_history'); - } - - findByType(type, options) { - if (type !== 'outgoing-webhook' || type !== 'incoming-webhook') { - throw new Meteor.Error('invalid-integration-type'); - } - - return this.find({ type }, options); - } - - findByIntegrationId(id, options) { - return this.find({ 'integration._id': id }, options); - } - - findByIntegrationIdAndCreatedBy(id, creatorId, options) { - return this.find({ 'integration._id': id, 'integration._createdBy._id': creatorId }, options); - } - - findOneByIntegrationIdAndHistoryId(integrationId, historyId) { - return this.findOne({ 'integration._id': integrationId, _id: historyId }); - } - - findByEventName(event, options) { - return this.find({ event }, options); - } - - findFailed(options) { - return this.find({ error: true }, options); - } - - removeByIntegrationId(integrationId) { - return this.remove({ 'integration._id': integrationId }); - } -} - -export default new IntegrationHistory(); diff --git a/app/models/server/models/Integrations.js b/app/models/server/models/Integrations.js deleted file mode 100644 index ffbf40c1dcce4..0000000000000 --- a/app/models/server/models/Integrations.js +++ /dev/null @@ -1,31 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { Base } from './_Base'; - -export class Integrations extends Base { - constructor() { - super('integrations'); - - this.tryEnsureIndex({ type: 1 }); - } - - findByType(type, options) { - if (type !== 'webhook-incoming' && type !== 'webhook-outgoing') { - throw new Meteor.Error('invalid-type-to-find'); - } - - return this.find({ type }, options); - } - - disableByUserId(userId) { - return this.update({ userId }, { $set: { enabled: false } }, { multi: true }); - } - - updateRoomName(oldRoomName, newRoomName) { - const hashedOldRoomName = `#${ oldRoomName }`; - const hashedNewRoomName = `#${ newRoomName }`; - return this.update({ channel: hashedOldRoomName }, { $set: { 'channel.$': hashedNewRoomName } }, { multi: true }); - } -} - -export default new Integrations(); diff --git a/app/models/server/models/Invites.js b/app/models/server/models/Invites.js deleted file mode 100644 index 517c1780f0ba1..0000000000000 --- a/app/models/server/models/Invites.js +++ /dev/null @@ -1,51 +0,0 @@ -import { Base } from './_Base'; - -class Invites extends Base { - constructor() { - super('invites'); - } - - findOneByUserRoomMaxUsesAndExpiration(userId, rid, maxUses, daysToExpire) { - const query = { - rid, - userId, - days: daysToExpire, - maxUses, - }; - - if (daysToExpire > 0) { - query.expires = { - $gt: new Date(), - }; - } - - if (maxUses > 0) { - query.uses = { - $lt: maxUses, - }; - } - - return this.findOne(query); - } - - // INSERT - create(data) { - return this.insert(data); - } - - // REMOVE - removeById(_id) { - return this.remove({ _id }); - } - - // UPDATE - increaseUsageById(_id, uses = 1) { - return this.update({ _id }, { - $inc: { - uses, - }, - }); - } -} - -export default new Invites(); diff --git a/app/models/server/models/LivechatInquiry.js b/app/models/server/models/LivechatInquiry.js index a4d4ec3566079..1994e5056cef6 100644 --- a/app/models/server/models/LivechatInquiry.js +++ b/app/models/server/models/LivechatInquiry.js @@ -48,7 +48,7 @@ export class LivechatInquiry extends Base { this.update({ _id: inquiryId, }, { - $set: { status: 'taken' }, + $set: { status: 'taken', takenAt: new Date() }, $unset: { defaultAgent: 1, estimatedInactivityCloseTimeAt: 1 }, }); } @@ -71,9 +71,17 @@ export class LivechatInquiry extends Base { return this.update({ _id: inquiryId, }, { - $set: { - status: 'queued', - }, + $set: { status: 'queued', queuedAt: new Date() }, + $unset: { takenAt: 1 }, + }); + } + + queueInquiryAndRemoveDefaultAgent(inquiryId) { + return this.update({ + _id: inquiryId, + }, { + $set: { status: 'queued', queuedAt: new Date() }, + $unset: { takenAt: 1, defaultAgent: 1 }, }); } diff --git a/app/models/server/models/LivechatRooms.js b/app/models/server/models/LivechatRooms.js index 1d71be35aad0b..d899284b46863 100644 --- a/app/models/server/models/LivechatRooms.js +++ b/app/models/server/models/LivechatRooms.js @@ -20,6 +20,8 @@ export class LivechatRooms extends Base { this.tryEnsureIndex({ 'v.token': 1 }, { sparse: true }); this.tryEnsureIndex({ 'v.token': 1, 'email.thread': 1 }, { sparse: true }); this.tryEnsureIndex({ 'v._id': 1 }, { sparse: true }); + this.tryEnsureIndex({ t: 1, departmentId: 1, closedAt: 1 }, { partialFilterExpression: { closedAt: { $exists: true } } }); + this.tryEnsureIndex({ source: 1 }, { sparse: true }); } findLivechat(filter = {}, offset = 0, limit = 20) { @@ -279,6 +281,17 @@ export class LivechatRooms extends Base { return this.findOne(query, options); } + findOneOpenByVisitorTokenAndDepartmentId(visitorToken, departmentId, options) { + const query = { + t: 'l', + open: true, + 'v.token': visitorToken, + departmentId, + }; + + return this.findOne(query, options); + } + findOpenByVisitorTokenAndDepartmentId(visitorToken, departmentId, options) { const query = { t: 'l', @@ -563,6 +576,7 @@ export class LivechatRooms extends Base { open: '$open', servedBy: '$servedBy', metrics: '$metrics', + onHold: '$onHold', }, messagesCount: { $sum: 1, @@ -578,6 +592,7 @@ export class LivechatRooms extends Base { servedBy: '$_id.servedBy', metrics: '$_id.metrics', msgs: '$messagesCount', + onHold: '$_id.onHold', }, }, ]); @@ -717,9 +732,8 @@ export class LivechatRooms extends Base { t: 'l', }; const update = { - $unset: { - servedBy: 1, - }, + $set: { queuedAt: new Date() }, + $unset: { servedBy: 1 }, }; this.update(query, update); diff --git a/app/models/server/models/Messages.js b/app/models/server/models/Messages.js index de09ad1c0b04b..3bfff331c4ef4 100644 --- a/app/models/server/models/Messages.js +++ b/app/models/server/models/Messages.js @@ -246,6 +246,17 @@ export class Messages extends Base { return this.find(query, options); } + findVisibleByIds(ids, options) { + const query = { + _id: { $in: ids }, + _hidden: { + $ne: true, + }, + }; + + return this.find(query, options); + } + findVisibleThreadByThreadId(tmid, options) { const query = { _hidden: { diff --git a/app/models/server/models/NotificationQueue.js b/app/models/server/models/NotificationQueue.js deleted file mode 100644 index 32eb7524c2c29..0000000000000 --- a/app/models/server/models/NotificationQueue.js +++ /dev/null @@ -1,14 +0,0 @@ -import { Base } from './_Base'; - -export class NotificationQueue extends Base { - constructor() { - super('notification_queue'); - this.tryEnsureIndex({ uid: 1 }); - this.tryEnsureIndex({ ts: 1 }, { expireAfterSeconds: 2 * 60 * 60 }); - this.tryEnsureIndex({ schedule: 1 }, { sparse: true }); - this.tryEnsureIndex({ sending: 1 }, { sparse: true }); - this.tryEnsureIndex({ error: 1 }, { sparse: true }); - } -} - -export default new NotificationQueue(); diff --git a/app/models/server/models/OAuthApps.js b/app/models/server/models/OAuthApps.js deleted file mode 100644 index 6aedffb63ae07..0000000000000 --- a/app/models/server/models/OAuthApps.js +++ /dev/null @@ -1,9 +0,0 @@ -import { Base } from './_Base'; - -export class OAuthApps extends Base { - constructor() { - super('oauth_apps'); - } -} - -export default new OAuthApps(); diff --git a/app/models/server/models/OEmbedCache.js b/app/models/server/models/OEmbedCache.js deleted file mode 100644 index db4383b9cd34c..0000000000000 --- a/app/models/server/models/OEmbedCache.js +++ /dev/null @@ -1,39 +0,0 @@ -import { Base } from './_Base'; - -export class OEmbedCache extends Base { - constructor() { - super('oembed_cache'); - this.tryEnsureIndex({ updatedAt: 1 }); - } - - // FIND ONE - findOneById(_id, options) { - const query = { - _id, - }; - return this.findOne(query, options); - } - - // INSERT - createWithIdAndData(_id, data) { - const record = { - _id, - data, - updatedAt: new Date(), - }; - record._id = this.insert(record); - return record; - } - - // REMOVE - removeAfterDate(date) { - const query = { - updatedAt: { - $lte: date, - }, - }; - return this.remove(query); - } -} - -export default new OEmbedCache(); diff --git a/app/models/server/models/Permissions.js b/app/models/server/models/Permissions.js deleted file mode 100644 index 009f29d37f9df..0000000000000 --- a/app/models/server/models/Permissions.js +++ /dev/null @@ -1,49 +0,0 @@ -import { Base } from './_Base'; - -export class Permissions extends Base { - // FIND - findByRole(role, options) { - const query = { - roles: role, - }; - - return this.find(query, options); - } - - findOneById(_id) { - return this.findOne({ _id }); - } - - createOrUpdate(name, roles) { - const exists = this.findOne({ - _id: name, - roles, - }, { fields: { _id: 1 } }); - - if (exists) { - return exists._id; - } - - this.upsert({ _id: name }, { $set: { roles } }); - } - - create(name, roles) { - const exists = this.findOneById(name, { fields: { _id: 1 } }); - - if (exists) { - return exists._id; - } - - this.upsert({ _id: name }, { $set: { roles } }); - } - - addRole(permission, role) { - this.update({ _id: permission, roles: { $ne: role } }, { $addToSet: { roles: role } }); - } - - removeRole(permission, role) { - this.update({ _id: permission, roles: role }, { $pull: { roles: role } }); - } -} - -export default new Permissions('permissions'); diff --git a/app/models/server/models/ReadReceipts.js b/app/models/server/models/ReadReceipts.js deleted file mode 100644 index d830f400669f7..0000000000000 --- a/app/models/server/models/ReadReceipts.js +++ /dev/null @@ -1,25 +0,0 @@ -import { Base } from './_Base'; - -export class ReadReceipts extends Base { - constructor(...args) { - super(...args); - - this.tryEnsureIndex({ - roomId: 1, - userId: 1, - messageId: 1, - }, { - unique: 1, - }); - - this.tryEnsureIndex({ - messageId: 1, - }); - } - - findByMessageId(messageId) { - return this.find({ messageId }); - } -} - -export default new ReadReceipts('message_read_receipt'); diff --git a/app/models/server/models/Reports.js b/app/models/server/models/Reports.js deleted file mode 100644 index 4d2ab019f19b7..0000000000000 --- a/app/models/server/models/Reports.js +++ /dev/null @@ -1,23 +0,0 @@ -import _ from 'underscore'; - -import { Base } from './_Base'; - -export class Reports extends Base { - constructor() { - super('reports'); - } - - createWithMessageDescriptionAndUserId(message, description, userId, extraData) { - const record = { - message, - description, - ts: new Date(), - userId, - }; - _.extend(record, extraData); - record._id = this.insert(record); - return record; - } -} - -export default new Reports(); diff --git a/app/models/server/models/Roles.js b/app/models/server/models/Roles.js deleted file mode 100644 index e7576191d0709..0000000000000 --- a/app/models/server/models/Roles.js +++ /dev/null @@ -1,134 +0,0 @@ -import { Base } from './_Base'; -import * as Models from '..'; - - -export class Roles extends Base { - constructor(...args) { - super(...args); - this.tryEnsureIndex({ name: 1 }); - this.tryEnsureIndex({ scope: 1 }); - } - - findUsersInRole(name, scope, options) { - const role = this.findOneByName(name); - const roleScope = (role && role.scope) || 'Users'; - const model = Models[roleScope]; - - return model && model.findUsersInRoles && model.findUsersInRoles(name, scope, options); - } - - isUserInRoles(userId, roles, scope) { - roles = [].concat(roles); - return roles.some((roleName) => { - const role = this.findOneByName(roleName); - const roleScope = (role && role.scope) || 'Users'; - const model = Models[roleScope]; - - return model && model.isUserInRole && model.isUserInRole(userId, roleName, scope); - }); - } - - updateById(_id, name, scope, description, mandatory2fa) { - const queryData = { - name, - scope, - description, - mandatory2fa, - }; - - this.upsert({ _id }, { $set: queryData }); - } - - createWithRandomId(name, scope = 'Users', description = '', protectedRole = true, mandatory2fa = false) { - const role = { - name, - scope, - description, - protected: protectedRole, - mandatory2fa, - }; - - return this.insert(role); - } - - createOrUpdate(name, scope = 'Users', description = '', protectedRole = true, mandatory2fa = false) { - const queryData = { - name, - scope, - description, - protected: protectedRole, - mandatory2fa, - }; - - this.upsert({ _id: name }, { $set: queryData }); - } - - addUserRoles(userId, roles, scope) { - roles = [].concat(roles); - for (const roleName of roles) { - const role = this.findOneByName(roleName); - const roleScope = (role && role.scope) || 'Users'; - const model = Models[roleScope]; - - model && model.addRolesByUserId && model.addRolesByUserId(userId, roleName, scope); - } - return true; - } - - removeUserRoles(userId, roles, scope) { - roles = [].concat(roles); - for (const roleName of roles) { - const role = this.findOneByName(roleName); - const roleScope = (role && role.scope) || 'Users'; - const model = Models[roleScope]; - - model && model.removeRolesByUserId && model.removeRolesByUserId(userId, roleName, scope); - } - return true; - } - - findOneByIdOrName(_idOrName, options) { - const query = { - $or: [{ - _id: _idOrName, - }, { - name: _idOrName, - }], - }; - - return this.findOne(query, options); - } - - findOneByName(name, options) { - const query = { - name, - }; - - return this.findOne(query, options); - } - - findByUpdatedDate(updatedAfterDate, options) { - const query = { - _updatedAt: { $gte: new Date(updatedAfterDate) }, - }; - - return this.find(query, options); - } - - canAddUserToRole(uid, roleName, scope) { - const role = this.findOne({ name: roleName }, { fields: { scope: 1 } }); - if (!role) { - return false; - } - - const model = Models[role.scope]; - if (!model) { - return; - } - - const user = model.isUserInRoleScope(uid, scope); - return !!user; - } -} - -export default new Roles('roles'); diff --git a/app/models/server/models/Rooms.js b/app/models/server/models/Rooms.js index d884208317fc3..86076aaa43689 100644 --- a/app/models/server/models/Rooms.js +++ b/app/models/server/models/Rooms.js @@ -5,7 +5,6 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Base } from './_Base'; import Messages from './Messages'; import Subscriptions from './Subscriptions'; -import { getValidRoomName } from '../../../utils'; export class Rooms extends Base { constructor(...args) { @@ -59,6 +58,35 @@ export class Rooms extends Base { return this.update(query, update); } + setCallStatus(_id, status) { + const query = { + _id, + }; + + const update = { + $set: { + callStatus: status, + }, + }; + + return this.update(query, update); + } + + setCallStatusAndCallStartTime(_id, status) { + const query = { + _id, + }; + + const update = { + $set: { + callStatus: status, + webRtcCallStartTime: new Date(), + }, + }; + + return this.update(query, update); + } + findByTokenpass(tokens) { const query = { 'tokenpass.tokens.token': { @@ -335,6 +363,7 @@ export class Rooms extends Base { let channelName = s.trim(name); try { // TODO evaluate if this function call should be here + const { getValidRoomName } = import('../../../utils/lib/getValidRoomName'); channelName = getValidRoomName(channelName, null, { allowDuplicates: true }); } catch (e) { console.error(e); diff --git a/app/models/server/models/ServerEvents.ts b/app/models/server/models/ServerEvents.ts deleted file mode 100644 index 09b17ac510676..0000000000000 --- a/app/models/server/models/ServerEvents.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Base } from './_Base'; - -export class ServerEvents extends Base { - constructor() { - super('server_events'); - this.tryEnsureIndex({ t: 1, ip: 1, ts: -1 }); - this.tryEnsureIndex({ t: 1, 'u.username': 1, ts: -1 }); - } -} - -export default new ServerEvents(); diff --git a/app/models/server/models/Sessions.js b/app/models/server/models/Sessions.js deleted file mode 100644 index ffb43d6566a5e..0000000000000 --- a/app/models/server/models/Sessions.js +++ /dev/null @@ -1,717 +0,0 @@ -import { Base } from './_Base'; -import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; - -export const aggregates = { - dailySessionsOfYesterday(collection, { year, month, day }) { - return collection.aggregate([{ - $match: { - userId: { $exists: true }, - lastActivityAt: { $exists: true }, - device: { $exists: true }, - type: 'session', - $or: [{ - year: { $lt: year }, - }, { - year, - month: { $lt: month }, - }, { - year, - month, - day: { $lte: day }, - }], - }, - }, { - $project: { - userId: 1, - device: 1, - day: 1, - month: 1, - year: 1, - mostImportantRole: 1, - time: { $trunc: { $divide: [{ $subtract: ['$lastActivityAt', '$loginAt'] }, 1000] } }, - }, - }, { - $match: { - time: { $gt: 0 }, - }, - }, { - $group: { - _id: { - userId: '$userId', - device: '$device', - day: '$day', - month: '$month', - year: '$year', - }, - mostImportantRole: { $first: '$mostImportantRole' }, - time: { $sum: '$time' }, - sessions: { $sum: 1 }, - }, - }, { - $sort: { - time: -1, - }, - }, { - $group: { - _id: { - userId: '$_id.userId', - day: '$_id.day', - month: '$_id.month', - year: '$_id.year', - }, - mostImportantRole: { $first: '$mostImportantRole' }, - time: { $sum: '$time' }, - sessions: { $sum: '$sessions' }, - devices: { - $push: { - sessions: '$sessions', - time: '$time', - device: '$_id.device', - }, - }, - }, - }, { - $sort: { - _id: 1, - }, - }, { - $project: { - _id: 0, - type: { $literal: 'user_daily' }, - _computedAt: { $literal: new Date() }, - day: '$_id.day', - month: '$_id.month', - year: '$_id.year', - userId: '$_id.userId', - mostImportantRole: 1, - time: 1, - sessions: 1, - devices: 1, - }, - }], { allowDiskUse: true }); - }, - - getUniqueUsersOfYesterday(collection, { year, month, day }) { - return collection.aggregate([{ - $match: { - year, - month, - day, - type: 'user_daily', - }, - }, { - $group: { - _id: { - day: '$day', - month: '$month', - year: '$year', - mostImportantRole: '$mostImportantRole', - }, - count: { - $sum: 1, - }, - sessions: { - $sum: '$sessions', - }, - time: { - $sum: '$time', - }, - }, - }, { - $group: { - _id: { - day: '$day', - month: '$month', - year: '$year', - }, - roles: { - $push: { - role: '$_id.mostImportantRole', - count: '$count', - sessions: '$sessions', - time: '$time', - }, - }, - count: { - $sum: '$count', - }, - sessions: { - $sum: '$sessions', - }, - time: { - $sum: '$time', - }, - }, - }, { - $project: { - _id: 0, - count: 1, - sessions: 1, - time: 1, - roles: 1, - }, - }]).toArray(); - }, - - getUniqueUsersOfLastMonthOrWeek(collection, { year, month, day, type = 'month' }) { - return collection.aggregate([{ - $match: { - type: 'user_daily', - ...aggregates.getMatchOfLastMonthOrWeek({ year, month, day, type }), - }, - }, { - $group: { - _id: { - userId: '$userId', - }, - mostImportantRole: { $first: '$mostImportantRole' }, - sessions: { - $sum: '$sessions', - }, - time: { - $sum: '$time', - }, - }, - }, { - $group: { - _id: { - mostImportantRole: '$mostImportantRole', - }, - count: { - $sum: 1, - }, - sessions: { - $sum: '$sessions', - }, - time: { - $sum: '$time', - }, - }, - }, { - $sort: { - time: -1, - }, - }, { - $group: { - _id: 1, - roles: { - $push: { - role: '$_id.mostImportantRole', - count: '$count', - sessions: '$sessions', - time: '$time', - }, - }, - count: { - $sum: '$count', - }, - sessions: { - $sum: '$sessions', - }, - time: { - $sum: '$time', - }, - }, - }, { - $project: { - _id: 0, - count: 1, - roles: 1, - sessions: 1, - time: 1, - }, - }], { allowDiskUse: true }).toArray(); - }, - - getMatchOfLastMonthOrWeek({ year, month, day, type = 'month' }) { - let startOfPeriod; - - if (type === 'month') { - const pastMonthLastDay = new Date(year, month - 1, 0).getDate(); - const currMonthLastDay = new Date(year, month, 0).getDate(); - - startOfPeriod = new Date(year, month - 1, day); - startOfPeriod.setMonth(startOfPeriod.getMonth() - 1, (currMonthLastDay === day ? pastMonthLastDay : Math.min(pastMonthLastDay, day)) + 1); - } else { - startOfPeriod = new Date(year, month - 1, day - 6); - } - - const startOfPeriodObject = { - year: startOfPeriod.getFullYear(), - month: startOfPeriod.getMonth() + 1, - day: startOfPeriod.getDate(), - }; - - if (year === startOfPeriodObject.year && month === startOfPeriodObject.month) { - return { - year, - month, - day: { $gte: startOfPeriodObject.day, $lte: day }, - }; - } - - if (year === startOfPeriodObject.year) { - return { - year, - $and: [{ - $or: [{ - month: { $gt: startOfPeriodObject.month }, - }, { - month: startOfPeriodObject.month, - day: { $gte: startOfPeriodObject.day }, - }], - }, { - $or: [{ - month: { $lt: month }, - }, { - month, - day: { $lte: day }, - }], - }], - }; - } - - return { - $and: [{ - $or: [{ - year: { $gt: startOfPeriodObject.year }, - }, { - year: startOfPeriodObject.year, - month: { $gt: startOfPeriodObject.month }, - }, { - year: startOfPeriodObject.year, - month: startOfPeriodObject.month, - day: { $gte: startOfPeriodObject.day }, - }], - }, { - $or: [{ - year: { $lt: year }, - }, { - year, - month: { $lt: month }, - }, { - year, - month, - day: { $lte: day }, - }], - }], - }; - }, - - getUniqueDevicesOfLastMonthOrWeek(collection, { year, month, day, type = 'month' }) { - return collection.aggregate([{ - $match: { - type: 'user_daily', - ...aggregates.getMatchOfLastMonthOrWeek({ year, month, day, type }), - }, - }, { - $unwind: '$devices', - }, { - $group: { - _id: { - type: '$devices.device.type', - name: '$devices.device.name', - version: '$devices.device.version', - }, - count: { - $sum: '$devices.sessions', - }, - time: { - $sum: '$devices.time', - }, - }, - }, { - $sort: { - time: -1, - }, - }, { - $project: { - _id: 0, - type: '$_id.type', - name: '$_id.name', - version: '$_id.version', - count: 1, - time: 1, - }, - }], { allowDiskUse: true }).toArray(); - }, - - getUniqueDevicesOfYesterday(collection, { year, month, day }) { - return collection.aggregate([{ - $match: { - year, - month, - day, - type: 'user_daily', - }, - }, { - $unwind: '$devices', - }, { - $group: { - _id: { - type: '$devices.device.type', - name: '$devices.device.name', - version: '$devices.device.version', - }, - count: { - $sum: '$devices.sessions', - }, - time: { - $sum: '$devices.time', - }, - }, - }, { - $sort: { - time: -1, - }, - }, { - $project: { - _id: 0, - type: '$_id.type', - name: '$_id.name', - version: '$_id.version', - count: 1, - time: 1, - }, - }]).toArray(); - }, - - getUniqueOSOfLastMonthOrWeek(collection, { year, month, day, type = 'month' }) { - return collection.aggregate([{ - $match: { - type: 'user_daily', - 'devices.device.os.name': { - $exists: true, - }, - ...aggregates.getMatchOfLastMonthOrWeek({ year, month, day, type }), - }, - }, { - $unwind: '$devices', - }, { - $group: { - _id: { - name: '$devices.device.os.name', - version: '$devices.device.os.version', - }, - count: { - $sum: '$devices.sessions', - }, - time: { - $sum: '$devices.time', - }, - }, - }, { - $sort: { - time: -1, - }, - }, { - $project: { - _id: 0, - name: '$_id.name', - version: '$_id.version', - count: 1, - time: 1, - }, - }], { allowDiskUse: true }).toArray(); - }, - - getUniqueOSOfYesterday(collection, { year, month, day }) { - return collection.aggregate([{ - $match: { - year, - month, - day, - type: 'user_daily', - 'devices.device.os.name': { - $exists: true, - }, - }, - }, { - $unwind: '$devices', - }, { - $group: { - _id: { - name: '$devices.device.os.name', - version: '$devices.device.os.version', - }, - count: { - $sum: '$devices.sessions', - }, - time: { - $sum: '$devices.time', - }, - }, - }, { - $sort: { - time: -1, - }, - }, { - $project: { - _id: 0, - name: '$_id.name', - version: '$_id.version', - count: 1, - time: 1, - }, - }]).toArray(); - }, -}; - -export class Sessions extends Base { - constructor(...args) { - super(...args); - - this.tryEnsureIndex({ instanceId: 1, sessionId: 1, year: 1, month: 1, day: 1 }); - this.tryEnsureIndex({ instanceId: 1, sessionId: 1, userId: 1 }); - this.tryEnsureIndex({ instanceId: 1, sessionId: 1 }); - this.tryEnsureIndex({ sessionId: 1 }); - this.tryEnsureIndex({ userId: 1 }); - this.tryEnsureIndex({ year: 1, month: 1, day: 1, type: 1 }); - this.tryEnsureIndex({ type: 1 }); - this.tryEnsureIndex({ ip: 1, loginAt: 1 }); - this.tryEnsureIndex({ _computedAt: 1 }, { expireAfterSeconds: 60 * 60 * 24 * 45 }); - - const db = this.model.rawDatabase(); - this.secondaryCollection = db.collection(this.model._name, { readPreference: readSecondaryPreferred(db) }); - } - - getUniqueUsersOfYesterday() { - const date = new Date(); - date.setDate(date.getDate() - 1); - - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - - return { - year, - month, - day, - data: Promise.await(aggregates.getUniqueUsersOfYesterday(this.secondaryCollection, { year, month, day })), - }; - } - - getUniqueUsersOfLastMonth() { - const date = new Date(); - date.setDate(date.getDate() - 1); - - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - - return { - year, - month, - day, - data: Promise.await(aggregates.getUniqueUsersOfLastMonthOrWeek(this.secondaryCollection, { year, month, day })), - }; - } - - getUniqueUsersOfLastWeek() { - const date = new Date(); - date.setDate(date.getDate() - 1); - - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - - return { - year, - month, - day, - data: Promise.await(aggregates.getUniqueUsersOfLastMonthOrWeek(this.secondaryCollection, { year, month, day, type: 'week' })), - }; - } - - getUniqueDevicesOfYesterday() { - const date = new Date(); - date.setDate(date.getDate() - 1); - - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - - return { - year, - month, - day, - data: Promise.await(aggregates.getUniqueDevicesOfYesterday(this.secondaryCollection, { year, month, day })), - }; - } - - getUniqueDevicesOfLastMonth() { - const date = new Date(); - date.setDate(date.getDate() - 1); - - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - - return { - year, - month, - day, - data: Promise.await(aggregates.getUniqueDevicesOfLastMonthOrWeek(this.secondaryCollection, { year, month, day })), - }; - } - - getUniqueDevicesOfLastWeek() { - const date = new Date(); - date.setDate(date.getDate() - 1); - - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - - return { - year, - month, - day, - data: Promise.await(aggregates.getUniqueDevicesOfLastMonthOrWeek(this.secondaryCollection, { year, month, day, type: 'week' })), - }; - } - - getUniqueOSOfYesterday() { - const date = new Date(); - date.setDate(date.getDate() - 1); - - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - - return { - year, - month, - day, - data: Promise.await(aggregates.getUniqueOSOfYesterday(this.secondaryCollection, { year, month, day })), - }; - } - - getUniqueOSOfLastMonth() { - const date = new Date(); - date.setDate(date.getDate() - 1); - - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - - return { - year, - month, - day, - data: Promise.await(aggregates.getUniqueOSOfLastMonthOrWeek(this.secondaryCollection, { year, month, day })), - }; - } - - getUniqueOSOfLastWeek() { - const date = new Date(); - date.setDate(date.getDate() - 1); - - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - - return { - year, - month, - day, - data: Promise.await(aggregates.getUniqueOSOfLastMonthOrWeek(this.secondaryCollection, { year, month, day, type: 'week' })), - }; - } - - createOrUpdate(data = {}) { - const { year, month, day, sessionId, instanceId } = data; - - if (!year || !month || !day || !sessionId || !instanceId) { - return; - } - - const now = new Date(); - - return this.upsert({ instanceId, sessionId, year, month, day }, { - $set: data, - $setOnInsert: { - createdAt: now, - }, - }); - } - - closeByInstanceIdAndSessionId(instanceId, sessionId) { - const query = { - instanceId, - sessionId, - closedAt: { $exists: 0 }, - }; - - const closeTime = new Date(); - const update = { - $set: { - closedAt: closeTime, - lastActivityAt: closeTime, - }, - }; - - return this.update(query, update); - } - - updateActiveSessionsByDateAndInstanceIdAndIds({ year, month, day } = {}, instanceId, sessions, data = {}) { - const query = { - instanceId, - year, - month, - day, - sessionId: { $in: sessions }, - closedAt: { $exists: 0 }, - }; - - const update = { - $set: data, - }; - - return this.update(query, update, { multi: true }); - } - - logoutByInstanceIdAndSessionIdAndUserId(instanceId, sessionId, userId) { - const query = { - instanceId, - sessionId, - userId, - logoutAt: { $exists: 0 }, - }; - - const logoutAt = new Date(); - const update = { - $set: { - logoutAt, - }, - }; - - return this.update(query, update, { multi: true }); - } - - createBatch(sessions) { - if (!sessions || sessions.length === 0) { - return; - } - - const ops = []; - sessions.forEach((doc) => { - const { year, month, day, sessionId, instanceId } = doc; - delete doc._id; - - ops.push({ - updateOne: { - filter: { year, month, day, sessionId, instanceId }, - update: { - $set: doc, - }, - upsert: true, - }, - }); - }); - - return this.model.rawCollection().bulkWrite(ops, { ordered: false }); - } -} - -export default new Sessions('sessions'); diff --git a/app/models/server/models/Sessions.mocks.js b/app/models/server/models/Sessions.mocks.js deleted file mode 100644 index ac4b22bf7780b..0000000000000 --- a/app/models/server/models/Sessions.mocks.js +++ /dev/null @@ -1,16 +0,0 @@ -import mock from 'mock-require'; - -mock('./_Base', { - Base: class Base { - model = { - rawDatabase() { - return { - collection() {}, - options: {}, - }; - }, - } - - tryEnsureIndex() {} - }, -}); diff --git a/app/models/server/models/SmarshHistory.js b/app/models/server/models/SmarshHistory.js deleted file mode 100644 index 9b2b7abc50433..0000000000000 --- a/app/models/server/models/SmarshHistory.js +++ /dev/null @@ -1,9 +0,0 @@ -import { Base } from './_Base'; - -export class SmarshHistory extends Base { - constructor() { - super('smarsh_history'); - } -} - -export default new SmarshHistory(); diff --git a/app/models/server/models/Statistics.js b/app/models/server/models/Statistics.js deleted file mode 100644 index 014f8c8180d2a..0000000000000 --- a/app/models/server/models/Statistics.js +++ /dev/null @@ -1,28 +0,0 @@ -import { Base } from './_Base'; - -export class Statistics extends Base { - constructor() { - super('statistics'); - - this.tryEnsureIndex({ createdAt: -1 }); - } - - // FIND ONE - findOneById(_id, options) { - const query = { _id }; - return this.findOne(query, options); - } - - findLast() { - const options = { - sort: { - createdAt: -1, - }, - limit: 1, - }; - const records = this.find({}, options).fetch(); - return records && records[0]; - } -} - -export default new Statistics(); diff --git a/app/models/server/models/Uploads.js b/app/models/server/models/Uploads.js deleted file mode 100644 index ce56ea6d0c0f6..0000000000000 --- a/app/models/server/models/Uploads.js +++ /dev/null @@ -1,146 +0,0 @@ -import _ from 'underscore'; -import s from 'underscore.string'; -import { InstanceStatus } from 'meteor/konecty:multiple-instances-status'; -import { escapeRegExp } from '@rocket.chat/string-helpers'; - -import { Base } from './_Base'; - -const fillTypeGroup = (fileData) => { - if (!fileData.type) { - return; - } - - fileData.typeGroup = fileData.type.split('/').shift(); -}; - -export class Uploads extends Base { - constructor() { - super('uploads'); - - this.model.before.insert((userId, doc) => { - doc.instanceId = InstanceStatus.id(); - }); - - this.tryEnsureIndex({ rid: 1 }); - this.tryEnsureIndex({ uploadedAt: 1 }); - this.tryEnsureIndex({ typeGroup: 1 }); - } - - findNotHiddenFilesOfRoom(roomId, searchText, fileType, limit) { - const fileQuery = { - rid: roomId, - complete: true, - uploading: false, - _hidden: { - $ne: true, - }, - }; - - if (searchText) { - fileQuery.name = { $regex: new RegExp(escapeRegExp(searchText), 'i') }; - } - - if (fileType && fileType !== 'all') { - fileQuery.typeGroup = fileType; - } - - const fileOptions = { - limit, - sort: { - uploadedAt: -1, - }, - fields: { - _id: 1, - userId: 1, - rid: 1, - name: 1, - description: 1, - type: 1, - url: 1, - uploadedAt: 1, - typeGroup: 1, - }, - }; - - return this.find(fileQuery, fileOptions); - } - - insert(fileData, ...args) { - fillTypeGroup(fileData); - return super.insert(fileData, ...args); - } - - update(filter, update, ...args) { - if (update.$set) { - fillTypeGroup(update.$set); - } else if (update.type) { - fillTypeGroup(update); - } - - return super.update(filter, update, ...args); - } - - insertFileInit(userId, store, file, extra) { - const fileData = { - userId, - store, - complete: false, - uploading: true, - progress: 0, - extension: s.strRightBack(file.name, '.'), - uploadedAt: new Date(), - }; - - _.extend(fileData, file, extra); - - if (this.model.direct && this.model.direct.insert != null) { - fillTypeGroup(fileData); - file = this.model.direct.insert(fileData); - } else { - file = this.insert(fileData); - } - - return file; - } - - updateFileComplete(fileId, userId, file) { - let result; - if (!fileId) { - return; - } - - const filter = { - _id: fileId, - userId, - }; - - const update = { - $set: { - complete: true, - uploading: false, - progress: 1, - }, - }; - - update.$set = _.extend(file, update.$set); - - if (this.model.direct && this.model.direct.update != null) { - fillTypeGroup(update.$set); - - result = this.model.direct.update(filter, update); - } else { - result = this.update(filter, update); - } - - return result; - } - - deleteFile(fileId) { - if (this.model.direct && this.model.direct.remove != null) { - return this.model.direct.remove({ _id: fileId }); - } - return this.remove({ _id: fileId }); - } -} - -export default new Uploads(); diff --git a/app/models/server/models/UserDataFiles.js b/app/models/server/models/UserDataFiles.js deleted file mode 100644 index a877188ff03bc..0000000000000 --- a/app/models/server/models/UserDataFiles.js +++ /dev/null @@ -1,44 +0,0 @@ -import _ from 'underscore'; - -import { Base } from './_Base'; - -export class UserDataFiles extends Base { - constructor() { - super('user_data_files'); - - this.tryEnsureIndex({ userId: 1 }); - } - - // FIND - findById(id) { - const query = { _id: id }; - return this.find(query); - } - - findLastFileByUser(userId, options = {}) { - const query = { - userId, - }; - - options.sort = { _updatedAt: -1 }; - return this.findOne(query, options); - } - - // INSERT - create(data) { - const userDataFile = { - createdAt: new Date(), - }; - - _.extend(userDataFile, data); - - return this.insert(userDataFile); - } - - // REMOVE - removeById(_id) { - return this.remove(_id); - } -} - -export default new UserDataFiles(); diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index f74d4de0c90f5..f33198de0abe6 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -262,6 +262,7 @@ export class Users extends Base { const update = { $set: { statusLivechat: status, + livechatStatusSystemModified: false, }, }; @@ -1435,23 +1436,6 @@ export class Users extends Base { return this.find(query).count() !== 0; } - addBannerById(_id, banner) { - const query = { - _id, - [`banners.${ banner.id }.read`]: { - $ne: true, - }, - }; - - const update = { - $set: { - [`banners.${ banner.id }`]: banner, - }, - }; - - return this.update(query, update); - } - setBannerReadById(_id, bannerId) { const update = { $set: { diff --git a/app/models/server/models/UsersSessions.js b/app/models/server/models/UsersSessions.js deleted file mode 100644 index 43aec902d3432..0000000000000 --- a/app/models/server/models/UsersSessions.js +++ /dev/null @@ -1,7 +0,0 @@ -import { UsersSessions } from 'meteor/konecty:user-presence'; - -import { Base } from './_Base'; - -export class UsersSessionsModel extends Base {} - -export default new UsersSessionsModel(UsersSessions, { preventSetUpdatedAt: true }); diff --git a/app/models/server/models/WebdavAccounts.js b/app/models/server/models/WebdavAccounts.js deleted file mode 100644 index 09df0b64a3a69..0000000000000 --- a/app/models/server/models/WebdavAccounts.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Webdav Accounts model - */ -import { Base } from './_Base'; - -export class WebdavAccounts extends Base { - constructor() { - super('webdav_accounts'); - - this.tryEnsureIndex({ user_id: 1 }); - } - - findWithUserId(user_id, options) { - const query = { user_id }; - return this.find(query, options); - } - - removeByUserAndId(_id, user_id) { - return this.remove({ _id, user_id }); - } - - removeById(_id) { - return this.remove({ _id }); - } -} - -export default new WebdavAccounts(); diff --git a/app/models/server/models/_BaseDb.js b/app/models/server/models/_BaseDb.js index 6c9473c81fb84..f72bb6c845577 100644 --- a/app/models/server/models/_BaseDb.js +++ b/app/models/server/models/_BaseDb.js @@ -30,31 +30,14 @@ const actions = { d: 'remove', }; -export class BaseDb extends EventEmitter { - constructor(model, baseModel, options = {}) { +export class BaseDbWatch extends EventEmitter { + constructor(collectionName) { super(); - - if (Match.test(model, String)) { - this.name = model; - this.collectionName = this.baseName + this.name; - this.model = new Mongo.Collection(this.collectionName); - } else { - this.name = model._name; - this.collectionName = this.name; - this.model = model; - } - - this.baseModel = baseModel; - - this.preventSetUpdatedAt = !!options.preventSetUpdatedAt; - - this.wrapModel(); + this.collectionName = collectionName; if (!process.env.DISABLE_DB_WATCH) { this.initDbWatch(); } - - this.tryEnsureIndex({ _updatedAt: 1 }, options._updatedAtIndexOptions); } initDbWatch() { @@ -97,6 +80,104 @@ export class BaseDb extends EventEmitter { } } + processOplogRecord({ id, op }) { + const action = actions[op.op]; + metrics.oplog.inc({ + collection: this.collectionName, + op: action, + }); + + if (action === 'insert') { + this.emit('change', { + action, + clientAction: 'inserted', + id: op.o._id, + data: op.o, + oplog: true, + }); + return; + } + + if (action === 'update') { + if (!op.o.$set && !op.o.$unset) { + this.emit('change', { + action, + clientAction: 'updated', + id, + data: op.o, + oplog: true, + }); + return; + } + + const diff = {}; + if (op.o.$set) { + for (const key in op.o.$set) { + if (op.o.$set.hasOwnProperty(key)) { + diff[key] = op.o.$set[key]; + } + } + } + const unset = {}; + if (op.o.$unset) { + for (const key in op.o.$unset) { + if (op.o.$unset.hasOwnProperty(key)) { + diff[key] = undefined; + unset[key] = 1; + } + } + } + + this.emit('change', { + action, + clientAction: 'updated', + id, + diff, + unset, + oplog: true, + }); + return; + } + + if (action === 'remove') { + this.emit('change', { + action, + clientAction: 'removed', + id, + oplog: true, + }); + } + } +} + + +export class BaseDb extends BaseDbWatch { + constructor(model, baseModel, options = {}) { + const collectionName = Match.test(model, String) ? baseName + model : model._name; + + super(collectionName); + + this.collectionName = collectionName; + + if (Match.test(model, String)) { + this.name = model; + this.collectionName = this.baseName + this.name; + this.model = new Mongo.Collection(this.collectionName); + } else { + this.name = model._name; + this.collectionName = this.name; + this.model = model; + } + + this.baseModel = baseModel; + + this.preventSetUpdatedAt = !!options.preventSetUpdatedAt; + + this.wrapModel(); + + this.tryEnsureIndex({ _updatedAt: 1 }, options._updatedAtIndexOptions); + } + get baseName() { return baseName; } @@ -204,75 +285,6 @@ export class BaseDb extends EventEmitter { ); } - processOplogRecord({ id, op }) { - const action = actions[op.op]; - metrics.oplog.inc({ - collection: this.collectionName, - op: action, - }); - - if (action === 'insert') { - this.emit('change', { - action, - clientAction: 'inserted', - id: op.o._id, - data: op.o, - oplog: true, - }); - return; - } - - if (action === 'update') { - if (!op.o.$set && !op.o.$unset) { - this.emit('change', { - action, - clientAction: 'updated', - id, - data: op.o, - oplog: true, - }); - return; - } - - const diff = {}; - if (op.o.$set) { - for (const key in op.o.$set) { - if (op.o.$set.hasOwnProperty(key)) { - diff[key] = op.o.$set[key]; - } - } - } - const unset = {}; - if (op.o.$unset) { - for (const key in op.o.$unset) { - if (op.o.$unset.hasOwnProperty(key)) { - diff[key] = undefined; - unset[key] = 1; - } - } - } - - this.emit('change', { - action, - clientAction: 'updated', - id, - diff, - unset, - oplog: true, - }); - return; - } - - if (action === 'remove') { - this.emit('change', { - action, - clientAction: 'removed', - id, - oplog: true, - }); - } - } - insert(record, ...args) { this.setUpdatedAt(record); diff --git a/app/models/server/models/_oplogHandle.ts b/app/models/server/models/_oplogHandle.ts index 997da3c34eb75..936881eeecaae 100644 --- a/app/models/server/models/_oplogHandle.ts +++ b/app/models/server/models/_oplogHandle.ts @@ -1,5 +1,4 @@ import { Meteor } from 'meteor/meteor'; -import { Promise } from 'meteor/promise'; import { MongoInternals, OplogHandle } from 'meteor/mongo'; import semver from 'semver'; import { MongoClient, Cursor, Timestamp, Db } from 'mongodb'; @@ -193,12 +192,12 @@ class CustomOplogHandle { } } -let oplogHandle: Promise; +let oplogHandle: CustomOplogHandle; if (!process.env.DISABLE_DB_WATCH) { - // @ts-ignore - // eslint-disable-next-line no-undef - if (Package['disable-oplog']) { + const disableOplog = !!(global.Package as any)['disable-oplog']; + + if (disableOplog) { try { oplogHandle = Promise.await(new CustomOplogHandle().start()); } catch (e) { diff --git a/app/models/server/models/apps-persistence-model.js b/app/models/server/models/apps-persistence-model.js index da178a390c327..dd01197abbc94 100644 --- a/app/models/server/models/apps-persistence-model.js +++ b/app/models/server/models/apps-persistence-model.js @@ -4,7 +4,7 @@ export class AppsPersistenceModel extends Base { constructor() { super('apps_persistence'); - this.tryEnsureIndex({ appId: 1 }); + this.tryEnsureIndex({ appId: 1, associations: 1 }); } // Bypass trash collection diff --git a/app/models/server/raw/Analytics.js b/app/models/server/raw/Analytics.js deleted file mode 100644 index 81e0ad335c26f..0000000000000 --- a/app/models/server/raw/Analytics.js +++ /dev/null @@ -1,151 +0,0 @@ -import { Random } from 'meteor/random'; - -import { BaseRaw } from './BaseRaw'; -import Analytics from '../models/Analytics'; -import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; - -export class AnalyticsRaw extends BaseRaw { - saveMessageSent({ room, date }) { - return this.update({ date, 'room._id': room._id, type: 'messages' }, { - $set: { - room: { _id: room._id, name: room.fname || room.name, t: room.t, usernames: room.usernames || [] }, - }, - $setOnInsert: { - _id: Random.id(), - date, - type: 'messages', - }, - $inc: { messages: 1 }, - }, { upsert: true }); - } - - saveUserData({ date }) { - return this.update({ date, type: 'users' }, { - $setOnInsert: { - _id: Random.id(), - date, - type: 'users', - }, - $inc: { users: 1 }, - }, { upsert: true }); - } - - saveMessageDeleted({ room, date }) { - return this.update({ date, 'room._id': room._id }, { - $inc: { messages: -1 }, - }); - } - - getMessagesSentTotalByDate({ start, end, options = {} }) { - const params = [ - { - $match: { - type: 'messages', - date: { $gte: start, $lte: end }, - }, - }, - { - $group: { - _id: '$date', - messages: { $sum: '$messages' }, - }, - }, - ]; - if (options.sort) { - params.push({ $sort: options.sort }); - } - if (options.count) { - params.push({ $limit: options.count }); - } - return this.col.aggregate(params).toArray(); - } - - getMessagesOrigin({ start, end }) { - const params = [ - { - $match: { - type: 'messages', - date: { $gte: start, $lte: end }, - }, - }, - { - $group: { - _id: { t: '$room.t' }, - messages: { $sum: '$messages' }, - }, - }, - { - $project: { - _id: 0, - t: '$_id.t', - messages: 1, - }, - }, - ]; - return this.col.aggregate(params).toArray(); - } - - getMostPopularChannelsByMessagesSentQuantity({ start, end, options = {} }) { - const params = [ - { - $match: { - type: 'messages', - date: { $gte: start, $lte: end }, - }, - }, - { - $group: { - _id: { t: '$room.t', name: '$room.name', usernames: '$room.usernames' }, - messages: { $sum: '$messages' }, - }, - }, - { - $project: { - _id: 0, - t: '$_id.t', - name: '$_id.name', - usernames: '$_id.usernames', - messages: 1, - }, - }, - ]; - if (options.sort) { - params.push({ $sort: options.sort }); - } - if (options.count) { - params.push({ $limit: options.count }); - } - return this.col.aggregate(params).toArray(); - } - - getTotalOfRegisteredUsersByDate({ start, end, options = {} }) { - const params = [ - { - $match: { - type: 'users', - date: { $gte: start, $lte: end }, - }, - }, - { - $group: { - _id: '$date', - users: { $sum: '$users' }, - }, - }, - ]; - if (options.sort) { - params.push({ $sort: options.sort }); - } - if (options.count) { - params.push({ $limit: options.count }); - } - return this.col.aggregate(params).toArray(); - } - - findByTypeBeforeDate({ type, date }) { - return this.find({ type, date: { $lte: date } }); - } -} - -const db = Analytics.model.rawDatabase(); -export default new AnalyticsRaw(db.collection(Analytics.model._name, { readPreference: readSecondaryPreferred(db) })); diff --git a/app/models/server/raw/Analytics.ts b/app/models/server/raw/Analytics.ts new file mode 100644 index 0000000000000..4c2b1fa46cc6c --- /dev/null +++ b/app/models/server/raw/Analytics.ts @@ -0,0 +1,166 @@ +import { Random } from 'meteor/random'; +import { AggregationCursor, Cursor, SortOptionObject, UpdateWriteOpResult } from 'mongodb'; + +import { BaseRaw, IndexSpecification } from './BaseRaw'; +import { IAnalytic } from '../../../../definition/IAnalytic'; +import { IRoom } from '../../../../definition/IRoom'; + +type T = IAnalytic; + +export class AnalyticsRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { date: 1 } }, + { key: { 'room._id': 1, date: 1 }, unique: true }, + ]; + + saveMessageSent({ room, date }: { room: IRoom; date: IAnalytic['date'] }): Promise { + return this.updateMany({ date, 'room._id': room._id, type: 'messages' }, { + $set: { + room: { + _id: room._id, + name: room.fname || room.name, + t: room.t, + usernames: room.usernames || [], + }, + }, + $setOnInsert: { + _id: Random.id(), + date, + type: 'messages', + }, + $inc: { messages: 1 }, + }, { upsert: true }); + } + + saveUserData({ date }: { date: IAnalytic['date'] }): Promise { + return this.updateMany({ date, type: 'users' }, { + $setOnInsert: { + _id: Random.id(), + date, + type: 'users', + }, + $inc: { users: 1 }, + }, { upsert: true }); + } + + saveMessageDeleted({ room, date }: { room: { _id: string }; date: IAnalytic['date'] }): Promise { + return this.updateMany({ date, 'room._id': room._id }, { + $inc: { messages: -1 }, + }); + } + + getMessagesSentTotalByDate({ start, end, options = {} }: { start: IAnalytic['date']; end: IAnalytic['date']; options?: { sort?: SortOptionObject; count?: number } }): AggregationCursor<{ + _id: IAnalytic['date']; + messages: number; + }> { + return this.col.aggregate<{ + _id: IAnalytic['date']; + messages: number; + }>([ + { + $match: { + type: 'messages', + date: { $gte: start, $lte: end }, + }, + }, + { + $group: { + _id: '$date', + messages: { $sum: '$messages' }, + }, + }, + ...options.sort ? [{ $sort: options.sort }] : [], + ...options.count ? [{ $limit: options.count }] : [], + ]); + } + + getMessagesOrigin({ start, end }: { start: IAnalytic['date']; end: IAnalytic['date'] }): AggregationCursor<{ + t: IRoom['t']; + messages: number; + }> { + const params = [ + { + $match: { + type: 'messages', + date: { $gte: start, $lte: end }, + }, + }, + { + $group: { + _id: { t: '$room.t' }, + messages: { $sum: '$messages' }, + }, + }, + { + $project: { + _id: 0, + t: '$_id.t', + messages: 1, + }, + }, + ]; + return this.col.aggregate(params); + } + + getMostPopularChannelsByMessagesSentQuantity({ start, end, options = {} }: { start: IAnalytic['date']; end: IAnalytic['date']; options?: { sort?: SortOptionObject; count?: number } }): AggregationCursor<{ + t: IRoom['t']; + name: string; + messages: number; + usernames: string[]; + }> { + return this.col.aggregate([ + { + $match: { + type: 'messages', + date: { $gte: start, $lte: end }, + }, + }, + { + $group: { + _id: { t: '$room.t', name: '$room.name', usernames: '$room.usernames' }, + messages: { $sum: '$messages' }, + }, + }, + { + $project: { + _id: 0, + t: '$_id.t', + name: '$_id.name', + usernames: '$_id.usernames', + messages: 1, + }, + }, + ...options.sort ? [{ $sort: options.sort }] : [], + ...options.count ? [{ $limit: options.count }] : [], + ]); + } + + getTotalOfRegisteredUsersByDate({ start, end, options = {} }: { start: IAnalytic['date']; end: IAnalytic['date']; options?: { sort?: SortOptionObject; count?: number } }): AggregationCursor<{ + _id: IAnalytic['date']; + users: number; + }> { + return this.col.aggregate<{ + _id: IAnalytic['date']; + users: number; + }>([ + { + $match: { + type: 'users', + date: { $gte: start, $lte: end }, + }, + }, + { + $group: { + _id: '$date', + users: { $sum: '$users' }, + }, + }, + ...options.sort ? [{ $sort: options.sort }] : [], + ...options.count ? [{ $limit: options.count }] : [], + ]); + } + + findByTypeBeforeDate({ type, date }: { type: T['type']; date: T['date'] }): Cursor { + return this.find({ type, date: { $lte: date } }); + } +} diff --git a/app/models/server/raw/Avatars.ts b/app/models/server/raw/Avatars.ts new file mode 100644 index 0000000000000..cc9c5939f3b32 --- /dev/null +++ b/app/models/server/raw/Avatars.ts @@ -0,0 +1,73 @@ +import { DeleteWriteOpResultObject, UpdateWriteOpResult } from 'mongodb'; + +import { BaseRaw, IndexSpecification } from './BaseRaw'; +import { IAvatar as T } from '../../../../definition/IAvatar'; + +export class AvatarsRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { name: 1 }, sparse: true }, + { key: { rid: 1 }, sparse: true }, + ]; + + insertAvatarFileInit(name: string, userId: string, store: string, file: {name: string}, extra: object): Promise { + const fileData = { + name, + userId, + store, + complete: false, + uploading: true, + progress: 0, + extension: file.name.split('.').pop(), + uploadedAt: new Date(), + }; + + Object.assign(fileData, file, extra); + + return this.updateOne({ _id: name }, fileData, { upsert: true }); + } + + updateFileComplete(fileId: string, userId: string, file: object): Promise | undefined { + if (!fileId) { + return; + } + + const filter = { + _id: fileId, + userId, + }; + + const update = { + $set: { + complete: true, + uploading: false, + progress: 1, + }, + }; + + update.$set = Object.assign(file, update.$set); + + return this.updateOne(filter, update); + } + + async findOneByName(name: string): Promise { + return this.findOne({ name }); + } + + async findOneByRoomId(rid: string): Promise { + return this.findOne({ rid }); + } + + async updateFileNameById(fileId: string, name: string): Promise { + const filter = { _id: fileId }; + const update = { + $set: { + name, + }, + }; + return this.updateOne(filter, update); + } + + async deleteFile(fileId: string): Promise { + return this.deleteOne({ _id: fileId }); + } +} diff --git a/app/models/server/raw/Banners.ts b/app/models/server/raw/Banners.ts index 301ea579bc008..a414332e2fa95 100644 --- a/app/models/server/raw/Banners.ts +++ b/app/models/server/raw/Banners.ts @@ -7,7 +7,7 @@ type T = IBanner; export class BannersRaw extends BaseRaw { constructor( public readonly col: Collection, - public readonly trash?: Collection, + trash?: Collection, ) { super(col, trash); diff --git a/app/models/server/raw/BannersDismiss.ts b/app/models/server/raw/BannersDismiss.ts index bea87b6876113..6c4e69f20b969 100644 --- a/app/models/server/raw/BannersDismiss.ts +++ b/app/models/server/raw/BannersDismiss.ts @@ -6,7 +6,7 @@ import { BaseRaw } from './BaseRaw'; export class BannersDismissRaw extends BaseRaw { constructor( public readonly col: Collection, - public readonly trash?: Collection, + trash?: Collection, ) { super(col, trash); diff --git a/app/models/server/raw/BaseRaw.ts b/app/models/server/raw/BaseRaw.ts index 602d8a62542a7..47fd8bbdbdd17 100644 --- a/app/models/server/raw/BaseRaw.ts +++ b/app/models/server/raw/BaseRaw.ts @@ -1,10 +1,14 @@ import { Collection, CollectionInsertOneOptions, + CommonOptions, Cursor, DeleteWriteOpResultObject, FilterQuery, + FindAndModifyWriteOpResultObject, + FindOneAndUpdateOption, FindOneOptions, + IndexSpecification, InsertOneWriteOpResult, InsertWriteOpResult, ObjectID, @@ -19,8 +23,14 @@ import { WriteOpResult, } from 'mongodb'; +import { + IRocketChatRecord, + RocketChatRecordDeleted, +} from '../../../../definition/IRocketChatRecord'; import { setUpdatedAt } from '../lib/setUpdatedAt'; +export { IndexSpecification } from 'mongodb'; + // [extracted from @types/mongo] TypeScript Omit (Exclude to be specific) does not work for objects with an "any" indexed type, and breaks discriminated unions type EnhancedOmit = string | number extends keyof T ? T // T has indexed type e.g. { _id: string; [k: string]: any; } or it is "any" @@ -37,120 +47,206 @@ type ExtractIdType = TSchema extends { _id: infer U } // user has defin : U : ObjectId; -type ModelOptionalId = EnhancedOmit & { _id?: ExtractIdType }; +export type ModelOptionalId = EnhancedOmit & { _id?: ExtractIdType }; // InsertionModel forces both _id and _updatedAt to be optional, regardless of how they are declared in T -export type InsertionModel = EnhancedOmit, '_updatedAt'> & { _updatedAt?: Date }; +export type InsertionModel = EnhancedOmit, '_updatedAt'> & { + _updatedAt?: Date; +}; export interface IBaseRaw { col: Collection; } const baseName = 'rocketchat_'; -const isWithoutProjection = (props: T): props is WithoutProjection => !('projection' in props) && !('fields' in props); type DefaultFields = Record | Record | void; -type ResultFields = Defaults extends void ? Base : Defaults[keyof Defaults] extends 1 ? Pick : Omit; +type ResultFields = Defaults extends void + ? Base + : Defaults[keyof Defaults] extends 1 + ? Pick + : Omit; const warnFields = process.env.NODE_ENV !== 'production' - ? (...rest: any): void => { console.warn(...rest, new Error().stack); } + ? (...rest: any): void => { + console.warn(...rest, new Error().stack); + } : new Function(); export class BaseRaw = undefined> implements IBaseRaw { public readonly defaultFields: C; + protected indexes?: IndexSpecification[]; + protected name: string; + private preventSetUpdatedAt: boolean; + + public readonly trash?: Collection>; + constructor( public readonly col: Collection, - public readonly trash?: Collection, + trash?: Collection, + options?: { preventSetUpdatedAt?: boolean }, ) { this.name = this.col.collectionName.replace(baseName, ''); + this.trash = trash as unknown as Collection>; + + if (this.indexes?.length) { + this.col.createIndexes(this.indexes); + } + + this.preventSetUpdatedAt = options?.preventSetUpdatedAt ?? false; + } + + private doNotMixInclusionAndExclusionFields(options: FindOneOptions = {}): FindOneOptions { + const optionsDef = this.ensureDefaultFields(options); + if (optionsDef?.projection === undefined) { + return optionsDef; + } + + const projection: Record = optionsDef?.projection; + const keys = Object.keys(projection); + const removeKeys = keys.filter((key) => projection[key] === 0); + if (keys.length > removeKeys.length) { + removeKeys.forEach((key) => delete projection[key]); + } + + return { + ...optionsDef, + projection, + }; } - private ensureDefaultFields(options?: undefined): C extends void ? undefined : WithoutProjection>; + private ensureDefaultFields( + options?: undefined, + ): C extends void ? undefined : WithoutProjection>; - private ensureDefaultFields(options: WithoutProjection>): WithoutProjection>; + private ensureDefaultFields( + options: WithoutProjection>, + ): WithoutProjection>; private ensureDefaultFields

(options: FindOneOptions

): FindOneOptions

; - private ensureDefaultFields

(options?: any): FindOneOptions

| undefined | WithoutProjection> { + private ensureDefaultFields

( + options?: any, + ): FindOneOptions

| undefined | WithoutProjection> { if (this.defaultFields === undefined) { return options; } - const { fields, ...rest } = options || {}; + const { fields: deprecatedFields, projection, ...rest } = options || {}; - if (fields) { - warnFields('Using \'fields\' in models is deprecated.', options); + if (deprecatedFields) { + warnFields("Using 'fields' in models is deprecated.", options); } + const fields = { ...deprecatedFields, ...projection }; + return { projection: this.defaultFields, - ...fields && { projection: fields }, + ...fields && Object.values(fields).length && { projection: fields }, ...rest, }; } - async findOneById(_id: string, options?: WithoutProjection> | undefined): Promise; + public findOneAndUpdate( + query: FilterQuery, + update: UpdateQuery | T, + options?: FindOneAndUpdateOption, + ): Promise> { + return this.col.findOneAndUpdate(query, update, options); + } + + async findOneById( + _id: string, + options?: WithoutProjection> | undefined, + ): Promise; - async findOneById

(_id: string, options: FindOneOptions

): Promise

; + async findOneById

( + _id: string, + options: FindOneOptions

, + ): Promise

; async findOneById

(_id: string, options?: any): Promise { const query = { _id } as FilterQuery; - const optionsDef = this.ensureDefaultFields(options); + const optionsDef = this.doNotMixInclusionAndExclusionFields(options); return this.col.findOne(query, optionsDef); } async findOne(query?: FilterQuery | string, options?: undefined): Promise; - async findOne(query: FilterQuery | string, options: WithoutProjection>): Promise; + async findOne( + query: FilterQuery | string, + options: WithoutProjection>, + ): Promise; - async findOne

(query: FilterQuery | string, options: FindOneOptions

): Promise

; + async findOne

( + query: FilterQuery | string, + options: FindOneOptions

, + ): Promise

; async findOne

(query: FilterQuery | string = {}, options?: any): Promise { - const q = typeof query === 'string' ? { _id: query } as FilterQuery : query; + const q = typeof query === 'string' ? ({ _id: query } as FilterQuery) : query; - const optionsDef = this.ensureDefaultFields(options); + const optionsDef = this.doNotMixInclusionAndExclusionFields(options); return this.col.findOne(q, optionsDef); } - findUsersInRoles(): void { - throw new Error('[overwrite-function] You must overwrite this function in the extended classes'); - } + // findUsersInRoles(): void { + // throw new Error('[overwrite-function] You must overwrite this function in the extended classes'); + // } find(query?: FilterQuery): Cursor>; - find(query: FilterQuery, options: WithoutProjection>): Cursor>; + find( + query: FilterQuery, + options: WithoutProjection>, + ): Cursor>; - find

(query: FilterQuery, options: FindOneOptions

): Cursor

; + find

(query: FilterQuery, options: FindOneOptions

): Cursor

; find

(query: FilterQuery | undefined = {}, options?: any): Cursor

| Cursor { - const optionsDef = this.ensureDefaultFields(options); + const optionsDef = this.doNotMixInclusionAndExclusionFields(options); return this.col.find(query, optionsDef); } - update(filter: FilterQuery, update: UpdateQuery | Partial, options?: UpdateOneOptions & { multi?: boolean }): Promise { - setUpdatedAt(update); + update( + filter: FilterQuery, + update: UpdateQuery | Partial, + options?: UpdateOneOptions & { multi?: boolean }, + ): Promise { + this.setUpdatedAt(update); return this.col.update(filter, update, options); } - updateOne(filter: FilterQuery, update: UpdateQuery | Partial, options?: UpdateOneOptions & { multi?: boolean }): Promise { - setUpdatedAt(update); + updateOne( + filter: FilterQuery, + update: UpdateQuery | Partial, + options?: UpdateOneOptions & { multi?: boolean }, + ): Promise { + this.setUpdatedAt(update); return this.col.updateOne(filter, update, options); } - updateMany(filter: FilterQuery, update: UpdateQuery | Partial, options?: UpdateManyOptions): Promise { - setUpdatedAt(update); + updateMany( + filter: FilterQuery, + update: UpdateQuery | Partial, + options?: UpdateManyOptions, + ): Promise { + this.setUpdatedAt(update); return this.col.updateMany(filter, update, options); } - insertMany(docs: Array>, options?: CollectionInsertOneOptions): Promise>> { + insertMany( + docs: Array>, + options?: CollectionInsertOneOptions, + ): Promise>> { docs = docs.map((doc) => { if (!doc._id || typeof doc._id !== 'string') { const oid = new ObjectID(); return { _id: oid.toHexString(), ...doc }; } - setUpdatedAt(doc); + this.setUpdatedAt(doc); return doc; }); @@ -158,60 +254,187 @@ export class BaseRaw = undefined> implements IBase return this.col.insertMany(docs as unknown as Array>, options); } - insertOne(doc: InsertionModel, options?: CollectionInsertOneOptions): Promise>> { + insertOne( + doc: InsertionModel, + options?: CollectionInsertOneOptions, + ): Promise>> { if (!doc._id || typeof doc._id !== 'string') { const oid = new ObjectID(); doc = { _id: oid.toHexString(), ...doc }; } - setUpdatedAt(doc); + this.setUpdatedAt(doc); // TODO reavaluate following type casting return this.col.insertOne(doc as unknown as OptionalId, options); } removeById(_id: string): Promise { - const query: object = { _id }; - return this.col.deleteOne(query); + return this.deleteOne({ _id } as FilterQuery); } - // Trash - trashFind

(query: FilterQuery, options: FindOneOptions

): Cursor

| undefined { + async deleteOne( + filter: FilterQuery, + options?: CommonOptions & { bypassDocumentValidation?: boolean }, + ): Promise { if (!this.trash) { - return undefined; + return this.col.deleteOne(filter, options); } - const { trash } = this; - return trash.find({ - __collection__: this.name, - ...query, - }, options); + const doc = (await this.findOne(filter)) as unknown as (IRocketChatRecord & T) | undefined; + + if (doc) { + const { _id, ...record } = doc; + + const trash = { + ...record, + + _deletedAt: new Date(), + __collection__: this.name, + } as RocketChatRecordDeleted; + + // since the operation is not atomic, we need to make sure that the record is not already deleted/inserted + await this.trash?.updateOne( + { _id } as FilterQuery>, + { $set: trash }, + { + upsert: true, + }, + ); + } + + return this.col.deleteOne(filter, options); } + async deleteMany( + filter: FilterQuery, + options?: CommonOptions, + ): Promise { + if (!this.trash) { + return this.col.deleteMany(filter, options); + } + + const cursor = this.find(filter); + + const ids: string[] = []; + for await (const doc of cursor) { + const { _id, ...record } = doc as unknown as IRocketChatRecord & T; - trashFindOneById(_id: string): Promise; + const trash = { + ...record, - trashFindOneById(_id: string, options: WithoutProjection>): Promise; + _deletedAt: new Date(), + __collection__: this.name, + } as RocketChatRecordDeleted; + + ids.push(_id); + + // since the operation is not atomic, we need to make sure that the record is not already deleted/inserted + await this.trash?.updateOne( + { _id } as FilterQuery>, + { $set: trash }, + { + upsert: true, + }, + ); + } + + return this.col.deleteMany({ _id: { $in: ids } } as unknown as FilterQuery, options); + } + + // Trash + trashFind

>( + query: FilterQuery>, + options: FindOneOptions

? RocketChatRecordDeleted : P>, + ): Cursor> | undefined { + if (!this.trash) { + return undefined; + } + const { trash } = this; - trashFindOneById

(_id: string, options: FindOneOptions

): Promise

; + return trash.find( + { + __collection__: this.name, + ...query, + }, + options, + ); + } - async trashFindOneById

(_id: string, options?: undefined | WithoutProjection> | FindOneOptions

): Promise { + trashFindOneById(_id: string): Promise | null>; + + trashFindOneById( + _id: string, + options: WithoutProjection>, + ): Promise> | null>; + + trashFindOneById

( + _id: string, + options: FindOneOptions

? RocketChatRecordDeleted : P>, + ): Promise

; + + async trashFindOneById

>( + _id: string, + options?: + | undefined + | WithoutProjection> + | FindOneOptions

? RocketChatRecordDeleted : P>, + ): Promise | null> { const query = { _id, __collection__: this.name, - } as FilterQuery; + } as FilterQuery>; if (!this.trash) { return null; } const { trash } = this; - if (options === undefined) { - return trash.findOne(query); + return trash.findOne(query, options); + } + + private setUpdatedAt(record: UpdateQuery | InsertionModel): void { + if (this.preventSetUpdatedAt) { + return; } - if (isWithoutProjection(options)) { - return trash.findOne(query, options); + setUpdatedAt(record); + } + + trashFindDeletedAfter(deletedAt: Date): Cursor>; + + trashFindDeletedAfter( + deletedAt: Date, + query: FilterQuery>, + options: WithoutProjection>, + ): Cursor>; + + trashFindDeletedAfter

>( + deletedAt: Date, + query: FilterQuery

, + options: FindOneOptions

? RocketChatRecordDeleted : P>, + ): Cursor>; + + trashFindDeletedAfter

>( + deletedAt: Date, + query?: FilterQuery>, + options?: + | WithoutProjection> + | FindOneOptions

? RocketChatRecordDeleted : P>, + ): Cursor> { + const q = { + __collection__: this.name, + _deletedAt: { + $gt: deletedAt, + }, + ...query, + } as FilterQuery>; + + const { trash } = this; + + if (!trash) { + throw new Error('Trash is not enabled for this collection'); } - return trash.findOne(query, options); + + return trash.find(q, options as any); } } diff --git a/app/models/server/raw/CredentialTokens.ts b/app/models/server/raw/CredentialTokens.ts new file mode 100644 index 0000000000000..eb6db2786682f --- /dev/null +++ b/app/models/server/raw/CredentialTokens.ts @@ -0,0 +1,29 @@ +import { BaseRaw, IndexSpecification } from './BaseRaw'; +import { ICredentialToken as T } from '../../../../definition/ICredentialToken'; + +export class CredentialTokensRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { expireAt: 1 }, sparse: true, expireAfterSeconds: 0 }, + ] + + async create(_id: string, userInfo: T['userInfo']): Promise { + const validForMilliseconds = 60000; // Valid for 60 seconds + const token = { + _id, + userInfo, + expireAt: new Date(Date.now() + validForMilliseconds), + }; + + await this.insertOne(token); + return token; + } + + findOneNotExpiredById(_id: string): Promise { + const query = { + _id, + expireAt: { $gt: new Date() }, + }; + + return this.findOne(query); + } +} diff --git a/app/models/server/raw/CustomSounds.js b/app/models/server/raw/CustomSounds.js deleted file mode 100644 index 54e96f0645129..0000000000000 --- a/app/models/server/raw/CustomSounds.js +++ /dev/null @@ -1,5 +0,0 @@ -import { BaseRaw } from './BaseRaw'; - -export class CustomSoundsRaw extends BaseRaw { - -} diff --git a/app/models/server/raw/CustomSounds.ts b/app/models/server/raw/CustomSounds.ts new file mode 100644 index 0000000000000..c46b7f4b41411 --- /dev/null +++ b/app/models/server/raw/CustomSounds.ts @@ -0,0 +1,44 @@ +import { Cursor, FindOneOptions, InsertOneWriteOpResult, UpdateWriteOpResult, WithId, WithoutProjection } from 'mongodb'; + +import { BaseRaw, IndexSpecification } from './BaseRaw'; +import { ICustomSound as T } from '../../../../definition/ICustomSound'; + +export class CustomSoundsRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { name: 1 } }, + ] + + // find + findByName(name: string, options: WithoutProjection>): Cursor { + const query = { + name, + }; + + return this.find(query, options); + } + + findByNameExceptId(name: string, except: string, options: WithoutProjection>): Cursor { + const query = { + _id: { $nin: [except] }, + name, + }; + + return this.find(query, options); + } + + // update + setName(_id: string, name: string): Promise { + const update = { + $set: { + name, + }, + }; + + return this.updateOne({ _id }, update); + } + + // INSERT + create(data: T): Promise>> { + return this.insertOne(data); + } +} diff --git a/app/models/server/raw/CustomUserStatus.js b/app/models/server/raw/CustomUserStatus.js deleted file mode 100644 index 0ffc78d4b3961..0000000000000 --- a/app/models/server/raw/CustomUserStatus.js +++ /dev/null @@ -1,5 +0,0 @@ -import { BaseRaw } from './BaseRaw'; - -export class CustomUserStatusRaw extends BaseRaw { - -} diff --git a/app/models/server/raw/CustomUserStatus.ts b/app/models/server/raw/CustomUserStatus.ts new file mode 100644 index 0000000000000..ad1d3df1ea10b --- /dev/null +++ b/app/models/server/raw/CustomUserStatus.ts @@ -0,0 +1,59 @@ +import { Cursor, FindOneOptions, InsertOneWriteOpResult, UpdateWriteOpResult, WithId, WithoutProjection } from 'mongodb'; + +import { BaseRaw, IndexSpecification } from './BaseRaw'; +import { ICustomUserStatus as T } from '../../../../definition/ICustomUserStatus'; + +export class CustomUserStatusRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { name: 1 } }, + ] + + // find one by name + async findOneByName(name: string, options: WithoutProjection>): Promise { + return this.findOne({ name }, options); + } + + // find + findByName(name: string, options: WithoutProjection>): Cursor { + const query = { + name, + }; + + return this.find(query, options); + } + + findByNameExceptId(name: string, except: string, options: WithoutProjection>): Cursor { + const query = { + _id: { $nin: [except] }, + name, + }; + + return this.find(query, options); + } + + // update + setName(_id: string, name: string): Promise { + const update = { + $set: { + name, + }, + }; + + return this.updateOne({ _id }, update); + } + + setStatusType(_id: string, statusType: string): Promise { + const update = { + $set: { + statusType, + }, + }; + + return this.updateOne({ _id }, update); + } + + // INSERT + create(data: T): Promise>> { + return this.insertOne(data); + } +} diff --git a/app/models/server/raw/EmailInbox.ts b/app/models/server/raw/EmailInbox.ts index 1d8d008242fa8..53b88792392f0 100644 --- a/app/models/server/raw/EmailInbox.ts +++ b/app/models/server/raw/EmailInbox.ts @@ -1,6 +1,8 @@ -import { BaseRaw } from './BaseRaw'; +import { BaseRaw, IndexSpecification } from './BaseRaw'; import { IEmailInbox } from '../../../../definition/IEmailInbox'; export class EmailInboxRaw extends BaseRaw { - // + protected indexes: IndexSpecification[] = [ + { key: { email: 1 }, unique: true }, + ] } diff --git a/app/models/server/raw/EmailMessageHistory.ts b/app/models/server/raw/EmailMessageHistory.ts index 9201d1b3a344c..89c54e079ec0f 100644 --- a/app/models/server/raw/EmailMessageHistory.ts +++ b/app/models/server/raw/EmailMessageHistory.ts @@ -1,10 +1,15 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { IndexSpecification, InsertOneWriteOpResult, WithId } from 'mongodb'; + import { BaseRaw } from './BaseRaw'; -import { IEmailMessageHistory } from '../../../../definition/IEmailMessageHistory'; +import { IEmailMessageHistory as T } from '../../../../definition/IEmailMessageHistory'; + +export class EmailMessageHistoryRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { createdAt: 1 }, expireAfterSeconds: 60 * 60 * 24 }, + ] -export class EmailMessageHistoryRaw extends BaseRaw { - insertOne({ _id, email }: IEmailMessageHistory) { - return this.col.insertOne({ + async create({ _id, email }: T): Promise>> { + return this.insertOne({ _id, email, createdAt: new Date(), diff --git a/app/models/server/raw/EmojiCustom.js b/app/models/server/raw/EmojiCustom.js deleted file mode 100644 index 80b81d41958b2..0000000000000 --- a/app/models/server/raw/EmojiCustom.js +++ /dev/null @@ -1,5 +0,0 @@ -import { BaseRaw } from './BaseRaw'; - -export class EmojiCustomRaw extends BaseRaw { - -} diff --git a/app/models/server/raw/EmojiCustom.ts b/app/models/server/raw/EmojiCustom.ts new file mode 100644 index 0000000000000..82f5f22fc97e2 --- /dev/null +++ b/app/models/server/raw/EmojiCustom.ts @@ -0,0 +1,79 @@ +import { Cursor, FindOneOptions, InsertOneWriteOpResult, UpdateWriteOpResult, WithId, WithoutProjection } from 'mongodb'; + +import { BaseRaw, IndexSpecification } from './BaseRaw'; +import { IEmojiCustom as T } from '../../../../definition/IEmojiCustom'; + +export class EmojiCustomRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { name: 1 } }, + { key: { aliases: 1 } }, + { key: { extension: 1 } }, + ] + + // find + findByNameOrAlias(emojiName: string, options: WithoutProjection>): Cursor { + let name = emojiName; + + if (typeof emojiName === 'string') { + name = emojiName.replace(/:/g, ''); + } + + const query = { + $or: [ + { name }, + { aliases: name }, + ], + }; + + return this.find(query, options); + } + + findByNameOrAliasExceptID(name: string, except: string, options: WithoutProjection>): Cursor { + const query = { + _id: { $nin: [except] }, + $or: [ + { name }, + { aliases: name }, + ], + }; + + return this.find(query, options); + } + + + // update + setName(_id: string, name: string): Promise { + const update = { + $set: { + name, + }, + }; + + return this.updateOne({ _id }, update); + } + + setAliases(_id: string, aliases: string): Promise { + const update = { + $set: { + aliases, + }, + }; + + return this.updateOne({ _id }, update); + } + + setExtension(_id: string, extension: string): Promise { + const update = { + $set: { + extension, + }, + }; + + return this.updateOne({ _id }, update); + } + + // INSERT + create(data: T): Promise>> { + return this.insertOne(data); + } +} diff --git a/app/models/server/raw/ExportOperations.ts b/app/models/server/raw/ExportOperations.ts new file mode 100644 index 0000000000000..d470722d28000 --- /dev/null +++ b/app/models/server/raw/ExportOperations.ts @@ -0,0 +1,68 @@ +import { Cursor, UpdateWriteOpResult } from 'mongodb'; + +import { BaseRaw, IndexSpecification } from './BaseRaw'; +import { IExportOperation } from '../../../../definition/IExportOperation'; + +type T = IExportOperation; + +export class ExportOperationsRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { userId: 1 } }, + { key: { status: 1 } }, + ] + + findOnePending(): Promise { + const query = { + status: { $nin: ['completed', 'skipped'] }, + }; + + return this.findOne(query); + } + + async create(data: T): Promise { + const result = await this.insertOne({ + ...data, + createdAt: new Date(), + }); + + return result.insertedId; + } + + findLastOperationByUser(userId: string, fullExport = false): Promise { + const query = { + userId, + fullExport, + }; + + return this.findOne(query, { sort: { createdAt: -1 } }); + } + + findAllPendingBeforeMyRequest(requestDay: Date): Cursor { + const query = { + status: { $nin: ['completed', 'skipped'] }, + createdAt: { $lt: requestDay }, + }; + + return this.find(query); + } + + updateOperation(data: T): Promise { + const update = { + $set: { + roomList: data.roomList, + status: data.status, + fileList: data.fileList, + generatedFile: data.generatedFile, + fileId: data.fileId, + userNameTable: data.userNameTable, + userData: data.userData, + generatedUserFile: data.generatedUserFile, + generatedAvatar: data.generatedAvatar, + exportPath: data.exportPath, + assetsPath: data.assetsPath, + }, + }; + + return this.updateOne({ _id: data._id }, update); + } +} diff --git a/app/models/server/raw/FederationKeys.ts b/app/models/server/raw/FederationKeys.ts new file mode 100644 index 0000000000000..7ac06051e4fa5 --- /dev/null +++ b/app/models/server/raw/FederationKeys.ts @@ -0,0 +1,65 @@ +import NodeRSA from 'node-rsa'; + +import { BaseRaw } from './BaseRaw'; + +type T = { + type: 'private' | 'public'; + key: string; +}; + +export class FederationKeysRaw extends BaseRaw { + async getKey(type: T['type']): Promise { + const keyResource = await this.findOne({ type }); + + if (!keyResource) { return null; } + + return keyResource.key; + } + + loadKey(keyData: NodeRSA.Key, type: T['type']): NodeRSA { + return new NodeRSA(keyData, `pkcs8-${ type }-pem`); + } + + async generateKeys(): Promise<{ privateKey: '' | NodeRSA | null; publicKey: '' | NodeRSA | null }> { + const key = new NodeRSA({ b: 512 }); + + key.generateKeyPair(); + + await this.deleteMany({}); + + await this.insertOne({ + type: 'private', + key: key.exportKey('pkcs8-private-pem').replace(/\n|\r/g, ''), + }); + + await this.insertOne({ + type: 'public', + key: key.exportKey('pkcs8-public-pem').replace(/\n|\r/g, ''), + }); + + return { + privateKey: await this.getPrivateKey(), + publicKey: await this.getPublicKey(), + }; + } + + async getPrivateKey(): Promise<'' | NodeRSA | null> { + const keyData = await this.getKey('private'); + + return keyData && this.loadKey(keyData, 'private'); + } + + getPrivateKeyString(): Promise { + return this.getKey('private'); + } + + async getPublicKey(): Promise<'' | NodeRSA | null> { + const keyData = await this.getKey('public'); + + return keyData && this.loadKey(keyData, 'public'); + } + + getPublicKeyString(): Promise { + return this.getKey('public'); + } +} diff --git a/app/models/server/raw/FederationServers.ts b/app/models/server/raw/FederationServers.ts new file mode 100644 index 0000000000000..c559b64137701 --- /dev/null +++ b/app/models/server/raw/FederationServers.ts @@ -0,0 +1,29 @@ +import { UpdateWriteOpResult } from 'mongodb'; + +import { Users } from './index'; +import { IFederationServer } from '../../../../definition/Federation'; +import { BaseRaw, IndexSpecification } from './BaseRaw'; + +export class FederationServersRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { domain: 1 } }, + ] + + saveDomain(domain: string): Promise { + return this.updateOne({ domain }, { + $setOnInsert: { + domain, + }, + }, { upsert: true }); + } + + async refreshServers(): Promise { + const domains = await Users.getDistinctFederationDomains(); + + for await (const domain of domains) { + await this.saveDomain(domain); + } + + await this.deleteMany({ domain: { $nin: domains } }); + } +} diff --git a/app/models/server/raw/IntegrationHistory.ts b/app/models/server/raw/IntegrationHistory.ts index 53f7167db7962..923c11c6257e7 100644 --- a/app/models/server/raw/IntegrationHistory.ts +++ b/app/models/server/raw/IntegrationHistory.ts @@ -1,4 +1,12 @@ import { BaseRaw } from './BaseRaw'; import { IIntegrationHistory } from '../../../../definition/IIntegrationHistory'; -export class IntegrationHistoryRaw extends BaseRaw {} +export class IntegrationHistoryRaw extends BaseRaw { + removeByIntegrationId(integrationId: string): ReturnType['deleteMany']> { + return this.deleteMany({ 'integration._id': integrationId }); + } + + findOneByIntegrationIdAndHistoryId(integrationId: string, historyId: string): Promise { + return this.findOne({ 'integration._id': integrationId, _id: historyId }); + } +} diff --git a/app/models/server/raw/Integrations.js b/app/models/server/raw/Integrations.js deleted file mode 100644 index ab8e01a5ebae0..0000000000000 --- a/app/models/server/raw/Integrations.js +++ /dev/null @@ -1,14 +0,0 @@ -import { BaseRaw } from './BaseRaw'; - -export class IntegrationsRaw extends BaseRaw { - findOneByIdAndCreatedByIfExists({ _id, createdBy }) { - const query = { - _id, - }; - if (createdBy) { - query['_createdBy._id'] = createdBy; - } - - return this.findOne(query); - } -} diff --git a/app/models/server/raw/Integrations.ts b/app/models/server/raw/Integrations.ts new file mode 100644 index 0000000000000..521507d3bfeca --- /dev/null +++ b/app/models/server/raw/Integrations.ts @@ -0,0 +1,36 @@ +import { BaseRaw, IndexSpecification } from './BaseRaw'; +import { IIntegration } from '../../../../definition/IIntegration'; + +export class IntegrationsRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { type: 1 } }, + ] + + findOneByUrl(url: string): Promise { + return this.findOne({ url }); + } + + updateRoomName(oldRoomName: string, newRoomName: string): ReturnType['updateMany']> { + const hashedOldRoomName = `#${ oldRoomName }`; + const hashedNewRoomName = `#${ newRoomName }`; + + return this.updateMany({ + channel: hashedOldRoomName, + }, { + $set: { + 'channel.$': hashedNewRoomName, + }, + }); + } + + findOneByIdAndCreatedByIfExists({ _id, createdBy }: { _id: IIntegration['_id']; createdBy: IIntegration['_createdBy'] }): Promise { + return this.findOne({ + _id, + ...createdBy && { '_createdBy._id': createdBy }, + }); + } + + disableByUserId(userId: string): ReturnType['updateMany']> { + return this.updateMany({ userId }, { $set: { enabled: false } }); + } +} diff --git a/app/models/server/raw/Invites.ts b/app/models/server/raw/Invites.ts new file mode 100644 index 0000000000000..84d21e4e3e818 --- /dev/null +++ b/app/models/server/raw/Invites.ts @@ -0,0 +1,27 @@ +import type { UpdateWriteOpResult } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; +import { IInvite } from '../../../../definition/IInvite'; + +type T = IInvite; + +export class InvitesRaw extends BaseRaw { + findOneByUserRoomMaxUsesAndExpiration(userId: string, rid: string, maxUses: number, daysToExpire: number): Promise { + return this.findOne({ + rid, + userId, + days: daysToExpire, + maxUses, + ...daysToExpire > 0 ? { expires: { $gt: new Date() } } : {}, + ...maxUses > 0 ? { uses: { $lt: maxUses } } : {}, + }); + } + + increaseUsageById(_id: string, uses = 1): Promise { + return this.updateOne({ _id }, { + $inc: { + uses, + }, + }); + } +} diff --git a/app/models/server/raw/LivechatAgentActivity.js b/app/models/server/raw/LivechatAgentActivity.ts similarity index 76% rename from app/models/server/raw/LivechatAgentActivity.js rename to app/models/server/raw/LivechatAgentActivity.ts index 9d48cf45a8328..7531dd30c3459 100644 --- a/app/models/server/raw/LivechatAgentActivity.js +++ b/app/models/server/raw/LivechatAgentActivity.ts @@ -1,9 +1,11 @@ import moment from 'moment'; +import { AggregationCursor } from 'mongodb'; import { BaseRaw } from './BaseRaw'; +import { ILivechatAgentActivity } from '../../../../definition/ILivechatAgentActivity'; -export class LivechatAgentActivityRaw extends BaseRaw { - findAllAverageAvailableServiceTime({ date, departmentId }) { +export class LivechatAgentActivityRaw extends BaseRaw { + findAllAverageAvailableServiceTime({ date, departmentId }: { date: Date; departmentId: string }): Promise { const match = { $match: { date } }; const lookup = { $lookup: { @@ -56,7 +58,7 @@ export class LivechatAgentActivityRaw extends BaseRaw { }, }, }; - const params = [match]; + const params = [match] as object[]; if (departmentId && departmentId !== 'undefined') { params.push(lookup); params.push(unwind); @@ -67,7 +69,19 @@ export class LivechatAgentActivityRaw extends BaseRaw { return this.col.aggregate(params).toArray(); } - findAvailableServiceTimeHistory({ start, end, fullReport, onlyCount = false, options = {} }) { + findAvailableServiceTimeHistory({ + start, + end, + fullReport, + onlyCount = false, + options = {}, + }: { + start: string; + end: string; + fullReport: boolean; + onlyCount: boolean; + options: any; + }): AggregationCursor { const match = { $match: { date: { @@ -101,13 +115,12 @@ export class LivechatAgentActivityRaw extends BaseRaw { _id: 0, username: '$_id.username', availableTimeInSeconds: 1, + ...fullReport && { serviceHistory: 1 }, }, }; - if (fullReport) { - project.$project.serviceHistory = 1; - } + const sort = { $sort: options.sort || { username: 1 } }; - const params = [match, lookup, unwind, group, project, sort]; + const params = [match, lookup, unwind, group, project, sort] as object[]; if (onlyCount) { params.push({ $count: 'total' }); return this.col.aggregate(params); diff --git a/app/models/server/raw/LivechatCustomField.js b/app/models/server/raw/LivechatCustomField.js deleted file mode 100644 index 2e3ee77e85a7b..0000000000000 --- a/app/models/server/raw/LivechatCustomField.js +++ /dev/null @@ -1,5 +0,0 @@ -import { BaseRaw } from './BaseRaw'; - -export class LivechatCustomFieldRaw extends BaseRaw { - -} diff --git a/app/models/server/raw/LivechatCustomField.ts b/app/models/server/raw/LivechatCustomField.ts new file mode 100644 index 0000000000000..6ca1ca5b0e238 --- /dev/null +++ b/app/models/server/raw/LivechatCustomField.ts @@ -0,0 +1,6 @@ +import { BaseRaw } from './BaseRaw'; +import { ILivechatCustomField } from '../../../../definition/ILivechatCustomField'; + +export class LivechatCustomFieldRaw extends BaseRaw { + +} diff --git a/app/models/server/raw/LivechatDepartment.js b/app/models/server/raw/LivechatDepartment.ts similarity index 51% rename from app/models/server/raw/LivechatDepartment.js rename to app/models/server/raw/LivechatDepartment.ts index 7915f57724c35..af4da4397da9f 100644 --- a/app/models/server/raw/LivechatDepartment.js +++ b/app/models/server/raw/LivechatDepartment.ts @@ -1,14 +1,16 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; +import { FindOneOptions, Cursor, FilterQuery, WriteOpResult } from 'mongodb'; import { BaseRaw } from './BaseRaw'; +import { ILivechatDepartmentRecord } from '../../../../definition/ILivechatDepartmentRecord'; -export class LivechatDepartmentRaw extends BaseRaw { - findInIds(departmentsIds, options) { +export class LivechatDepartmentRaw extends BaseRaw { + findInIds(departmentsIds: string[], options: FindOneOptions): Cursor { const query = { _id: { $in: departmentsIds } }; return this.find(query, options); } - findByNameRegexWithExceptionsAndConditions(searchTerm, exceptions = [], conditions = {}, options = {}) { + findByNameRegexWithExceptionsAndConditions(searchTerm: string, exceptions: string[] = [], conditions: FilterQuery = {}, options: FindOneOptions = {}): Cursor { if (!Array.isArray(exceptions)) { exceptions = [exceptions]; } @@ -26,17 +28,17 @@ export class LivechatDepartmentRaw extends BaseRaw { return this.find(query, options); } - findByBusinessHourId(businessHourId, options) { + findByBusinessHourId(businessHourId: string, options: FindOneOptions): Cursor { const query = { businessHourId }; return this.find(query, options); } - findEnabledByBusinessHourId(businessHourId, options) { + findEnabledByBusinessHourId(businessHourId: string, options: FindOneOptions): Cursor { const query = { businessHourId, enabled: true }; return this.find(query, options); } - addBusinessHourToDepartmentsByIds(ids = [], businessHourId) { + addBusinessHourToDepartmentsByIds(ids: string[] = [], businessHourId: string): Promise { const query = { _id: { $in: ids }, }; @@ -50,7 +52,7 @@ export class LivechatDepartmentRaw extends BaseRaw { return this.col.update(query, update, { multi: true }); } - removeBusinessHourFromDepartmentsByIdsAndBusinessHourId(ids = [], businessHourId) { + removeBusinessHourFromDepartmentsByIdsAndBusinessHourId(ids: string[] = [], businessHourId: string): Promise { const query = { _id: { $in: ids }, businessHourId, @@ -65,7 +67,7 @@ export class LivechatDepartmentRaw extends BaseRaw { return this.col.update(query, update, { multi: true }); } - removeBusinessHourFromDepartmentsByBusinessHourId(businessHourId) { + removeBusinessHourFromDepartmentsByBusinessHourId(businessHourId: string): Promise { const query = { businessHourId, }; diff --git a/app/models/server/raw/LivechatInquiry.js b/app/models/server/raw/LivechatInquiry.js deleted file mode 100644 index 5a3f4971786b5..0000000000000 --- a/app/models/server/raw/LivechatInquiry.js +++ /dev/null @@ -1,22 +0,0 @@ -import { BaseRaw } from './BaseRaw'; - -export class LivechatInquiryRaw extends BaseRaw { - findOneQueuedByRoomId(rid) { - const query = { - rid, - status: 'queued', - }; - return this.findOne(query); - } - - findOneByRoomId(rid, options) { - const query = { - rid, - }; - return this.findOne(query, options); - } - - getDistinctQueuedDepartments() { - return this.col.distinct('department', { status: 'queued' }); - } -} diff --git a/app/models/server/raw/LivechatInquiry.ts b/app/models/server/raw/LivechatInquiry.ts new file mode 100644 index 0000000000000..cae073044f89a --- /dev/null +++ b/app/models/server/raw/LivechatInquiry.ts @@ -0,0 +1,25 @@ +import { FindOneOptions, MongoDistinctPreferences } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; +import { ILivechatInquiryRecord, LivechatInquiryStatus } from '../../../../definition/IInquiry'; + +export class LivechatInquiryRaw extends BaseRaw { + findOneQueuedByRoomId(rid: string): Promise { + const query = { + rid, + status: LivechatInquiryStatus.QUEUED, + }; + return this.findOne(query) as unknown as (Promise<(ILivechatInquiryRecord & { status: LivechatInquiryStatus.QUEUED }) | null>); + } + + findOneByRoomId(rid: string, options: FindOneOptions): Promise { + const query = { + rid, + }; + return this.findOne(query, options); + } + + getDistinctQueuedDepartments(options: MongoDistinctPreferences): Promise { + return this.col.distinct('department', { status: LivechatInquiryStatus.QUEUED }, options); + } +} diff --git a/app/models/server/raw/LivechatRooms.js b/app/models/server/raw/LivechatRooms.js index 0c07e12add2be..c73a15e3ad5c2 100644 --- a/app/models/server/raw/LivechatRooms.js +++ b/app/models/server/raw/LivechatRooms.js @@ -479,6 +479,16 @@ export class LivechatRoomsRaw extends BaseRaw { 'metrics.chatDuration': { $exists: false, }, + $or: [{ + onHold: { + $exists: false, + }, + }, { + onHold: { + $exists: true, + $eq: false, + }, + }], servedBy: { $exists: true }, ts: { $gte: new Date(start), $lte: new Date(end) }, }; @@ -494,7 +504,6 @@ export class LivechatRoomsRaw extends BaseRaw { 'metrics.chatDuration': { $exists: true, }, - servedBy: { $exists: true }, ts: { $gte: new Date(start), $lte: new Date(end) }, }; if (departmentId && departmentId !== 'undefined') { @@ -507,6 +516,7 @@ export class LivechatRoomsRaw extends BaseRaw { const query = { t: 'l', servedBy: { $exists: false }, + open: true, ts: { $gte: new Date(start), $lte: new Date(end) }, }; if (departmentId && departmentId !== 'undefined') { @@ -521,6 +531,41 @@ export class LivechatRoomsRaw extends BaseRaw { t: 'l', 'servedBy.username': { $exists: true }, open: true, + $or: [{ + onHold: { + $exists: false, + }, + }, { + onHold: { + $exists: true, + $eq: false, + }, + }], + ts: { $gte: new Date(start), $lte: new Date(end) }, + }, + }; + const group = { + $group: { + _id: '$servedBy.username', + chats: { $sum: 1 }, + }, + }; + if (departmentId && departmentId !== 'undefined') { + match.$match.departmentId = departmentId; + } + return this.col.aggregate([match, group]).toArray(); + } + + countAllOnHoldChatsByAgentBetweenDate({ start, end, departmentId }) { + const match = { + $match: { + t: 'l', + 'servedBy.username': { $exists: true }, + open: true, + onHold: { + $exists: true, + $eq: true, + }, ts: { $gte: new Date(start), $lte: new Date(end) }, }, }; @@ -896,7 +941,7 @@ export class LivechatRoomsRaw extends BaseRaw { return this.col.aggregate(params); } - findRoomsWithCriteria({ agents, roomName, departmentId, open, served, createdAt, closedAt, tags, customFields, visitorId, roomIds, options = {} }) { + findRoomsWithCriteria({ agents, roomName, departmentId, open, served, createdAt, closedAt, tags, customFields, visitorId, roomIds, onhold, options = {} }) { const query = { t: 'l', }; @@ -911,6 +956,7 @@ export class LivechatRoomsRaw extends BaseRaw { } if (open !== undefined) { query.open = { $exists: open }; + query.onHold = { $ne: true }; } if (served !== undefined) { query.servedBy = { $exists: served }; @@ -947,9 +993,35 @@ export class LivechatRoomsRaw extends BaseRaw { query._id = { $in: roomIds }; } + if (onhold) { + query.onHold = { + $exists: true, + $eq: onhold, + }; + } + return this.find(query, { sort: options.sort || { name: 1 }, skip: options.offset, limit: options.count }); } + getOnHoldConversationsBetweenDate(from, to, departmentId) { + const query = { + onHold: { + $exists: true, + $eq: true, + }, + ts: { + $gte: new Date(from), // ISO Date, ts >= date.gte + $lt: new Date(to), // ISODate, ts < date.lt + }, + }; + + if (departmentId && departmentId !== 'undefined') { + query.departmentId = departmentId; + } + + return this.find(query).count(); + } + findAllServiceTimeByAgent({ start, end, onlyCount = false, options = {} }) { const match = { $match: { diff --git a/app/models/server/raw/LivechatTrigger.js b/app/models/server/raw/LivechatTrigger.js deleted file mode 100644 index af9bfbdcce749..0000000000000 --- a/app/models/server/raw/LivechatTrigger.js +++ /dev/null @@ -1,5 +0,0 @@ -import { BaseRaw } from './BaseRaw'; - -export class LivechatTriggerRaw extends BaseRaw { - -} diff --git a/app/models/server/raw/LivechatTrigger.ts b/app/models/server/raw/LivechatTrigger.ts new file mode 100644 index 0000000000000..71035b1db1112 --- /dev/null +++ b/app/models/server/raw/LivechatTrigger.ts @@ -0,0 +1,6 @@ +import { BaseRaw } from './BaseRaw'; +import { ILivechatTrigger } from '../../../../definition/ILivechatTrigger'; + +export class LivechatTriggerRaw extends BaseRaw { + +} diff --git a/app/models/server/raw/LivechatVisitors.js b/app/models/server/raw/LivechatVisitors.ts similarity index 51% rename from app/models/server/raw/LivechatVisitors.js rename to app/models/server/raw/LivechatVisitors.ts index dabd7124625cc..ff0f0ba5319fc 100644 --- a/app/models/server/raw/LivechatVisitors.js +++ b/app/models/server/raw/LivechatVisitors.ts @@ -1,9 +1,11 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; +import { AggregationCursor, Cursor, FilterQuery, FindOneOptions } from 'mongodb'; import { BaseRaw } from './BaseRaw'; +import { ILivechatVisitor } from '../../../../definition/ILivechatVisitor'; -export class LivechatVisitorsRaw extends BaseRaw { - getVisitorsBetweenDate({ start, end, department }) { +export class LivechatVisitorsRaw extends BaseRaw { + getVisitorsBetweenDate({ start, end, department }: { start: Date; end: Date; department: string }): Cursor { const query = { _updatedAt: { $gte: new Date(start), @@ -12,10 +14,12 @@ export class LivechatVisitorsRaw extends BaseRaw { ...department && department !== 'undefined' && { department }, }; - return this.find(query, { fields: { _id: 1 } }); + return this.find(query, { projection: { _id: 1 } }); } - findByNameRegexWithExceptionsAndConditions(searchTerm, exceptions = [], conditions = {}, options = {}) { + findByNameRegexWithExceptionsAndConditions

(searchTerm: string, exceptions: string[] = [], conditions: FilterQuery = {}, options: FindOneOptions

= {}): AggregationCursor

{ if (!Array.isArray(exceptions)) { exceptions = [exceptions]; } @@ -32,24 +36,22 @@ export class LivechatVisitorsRaw extends BaseRaw { }, }; - const { fields, sort, offset, count } = options; + const { projection, sort, skip, limit } = options; const project = { - $project: { + $project: { // TODO: move this logic to client + // eslint-disable-next-line @typescript-eslint/camelcase custom_name: { $concat: ['$username', ' - ', '$name'] }, - ...fields, + ...projection, }, }; const order = { $sort: sort || { name: 1 } }; - const params = [match, project, order]; - - if (offset) { - params.push({ $skip: offset }); - } - - if (count) { - params.push({ $limit: count }); - } + const params: Record[] = [ + match, + order, + skip && { $skip: skip }, + limit && { $limit: limit }, + project].filter(Boolean) as Record[]; return this.col.aggregate(params); } @@ -58,7 +60,7 @@ export class LivechatVisitorsRaw extends BaseRaw { * Find visitors by their email or phone or username or name * @return [{object}] List of Visitors from db */ - findVisitorsByEmailOrPhoneOrNameOrUsername(_emailOrPhoneOrNameOrUsername, options) { + findVisitorsByEmailOrPhoneOrNameOrUsername(_emailOrPhoneOrNameOrUsername: string, options: FindOneOptions): Cursor { const filter = new RegExp(_emailOrPhoneOrNameOrUsername, 'i'); const query = { $or: [{ diff --git a/app/models/server/raw/Messages.js b/app/models/server/raw/Messages.js index 06addaca1cdbc..f3704d6a53b79 100644 --- a/app/models/server/raw/Messages.js +++ b/app/models/server/raw/Messages.js @@ -184,4 +184,17 @@ export class MessagesRaw extends BaseRaw { } return this.col.aggregate(params).toArray(); } + + findLivechatClosedMessages(rid, options) { + return this.find( + { + rid, + $or: [ + { t: { $exists: false } }, + { t: 'livechat-close' }, + ], + }, + options, + ); + } } diff --git a/app/models/server/raw/NotificationQueue.ts b/app/models/server/raw/NotificationQueue.ts index 9aedb96809028..cf80da0b747d8 100644 --- a/app/models/server/raw/NotificationQueue.ts +++ b/app/models/server/raw/NotificationQueue.ts @@ -1,25 +1,27 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { - Collection, - ObjectId, -} from 'mongodb'; +import { UpdateWriteOpResult } from 'mongodb'; -import { BaseRaw } from './BaseRaw'; +import { BaseRaw, IndexSpecification } from './BaseRaw'; import { INotification } from '../../../../definition/INotification'; export class NotificationQueueRaw extends BaseRaw { - public readonly col!: Collection; + protected indexes: IndexSpecification[] = [ + { key: { uid: 1 } }, + { key: { ts: 1 }, expireAfterSeconds: 2 * 60 * 60 }, + { key: { schedule: 1 }, sparse: true }, + { key: { sending: 1 }, sparse: true }, + { key: { error: 1 }, sparse: true }, + ]; - unsetSendingById(_id: string) { - return this.col.updateOne({ _id }, { + unsetSendingById(_id: string): Promise { + return this.updateOne({ _id }, { $unset: { sending: 1, }, }); } - setErrorById(_id: string, error: any) { - return this.col.updateOne({ + setErrorById(_id: string, error: any): Promise { + return this.updateOne({ _id, }, { $set: { @@ -31,12 +33,8 @@ export class NotificationQueueRaw extends BaseRaw { }); } - removeById(_id: string) { - return this.col.deleteOne({ _id }); - } - - clearScheduleByUserId(uid: string) { - return this.col.updateMany({ + clearScheduleByUserId(uid: string): Promise { + return this.updateMany({ uid, schedule: { $exists: true }, }, { @@ -47,7 +45,7 @@ export class NotificationQueueRaw extends BaseRaw { } async clearQueueByUserId(uid: string): Promise { - const op = await this.col.deleteMany({ + const op = await this.deleteMany({ uid, }); @@ -83,11 +81,4 @@ export class NotificationQueueRaw extends BaseRaw { return result.value; } - - insertOne(data: Omit) { - return this.col.insertOne({ - _id: new ObjectId().toHexString(), - ...data, - }); - } } diff --git a/app/models/server/raw/Nps.ts b/app/models/server/raw/Nps.ts index 715628e7146e6..f77e0b61bde34 100644 --- a/app/models/server/raw/Nps.ts +++ b/app/models/server/raw/Nps.ts @@ -7,7 +7,7 @@ type T = INps; export class NpsRaw extends BaseRaw { constructor( public readonly col: Collection, - public readonly trash?: Collection, + trash?: Collection, ) { super(col, trash); diff --git a/app/models/server/raw/NpsVote.ts b/app/models/server/raw/NpsVote.ts index f6ebb6dcc34eb..e215e1a925306 100644 --- a/app/models/server/raw/NpsVote.ts +++ b/app/models/server/raw/NpsVote.ts @@ -7,7 +7,7 @@ type T = INpsVote; export class NpsVoteRaw extends BaseRaw { constructor( public readonly col: Collection, - public readonly trash?: Collection, + trash?: Collection, ) { super(col, trash); diff --git a/app/models/server/raw/OAuthApps.js b/app/models/server/raw/OAuthApps.js deleted file mode 100644 index 68c77a772cdda..0000000000000 --- a/app/models/server/raw/OAuthApps.js +++ /dev/null @@ -1,14 +0,0 @@ -import { BaseRaw } from './BaseRaw'; - -export class OAuthAppsRaw extends BaseRaw { - findOneAuthAppByIdOrClientId({ clientId, appId }) { - const query = {}; - if (clientId) { - query.clientId = clientId; - } - if (appId) { - query._id = appId; - } - return this.findOne(query); - } -} diff --git a/app/models/server/raw/OAuthApps.ts b/app/models/server/raw/OAuthApps.ts new file mode 100644 index 0000000000000..f70d88616b347 --- /dev/null +++ b/app/models/server/raw/OAuthApps.ts @@ -0,0 +1,11 @@ +import { IOAuthApps as T } from '../../../../definition/IOAuthApps'; +import { BaseRaw } from './BaseRaw'; + +export class OAuthAppsRaw extends BaseRaw { + findOneAuthAppByIdOrClientId({ clientId, appId }: {clientId: string; appId: string}): ReturnType['findOne']> { + return this.findOne({ + ...appId && { _id: appId }, + ...clientId && { clientId }, + }); + } +} diff --git a/app/models/server/raw/OEmbedCache.ts b/app/models/server/raw/OEmbedCache.ts new file mode 100644 index 0000000000000..586fb1d7040ca --- /dev/null +++ b/app/models/server/raw/OEmbedCache.ts @@ -0,0 +1,31 @@ +import { DeleteWriteOpResultObject } from 'mongodb'; + +import { BaseRaw, IndexSpecification } from './BaseRaw'; +import { IOEmbedCache } from '../../../../definition/IOEmbedCache'; + +type T = IOEmbedCache; + +export class OEmbedCacheRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { updatedAt: 1 } }, + ] + + async createWithIdAndData(_id: string, data: any): Promise { + const record = { + _id, + data, + updatedAt: new Date(), + }; + record._id = (await this.insertOne(record)).insertedId; + return record; + } + + removeAfterDate(date: Date): Promise { + const query = { + updatedAt: { + $lte: date, + }, + }; + return this.deleteMany(query); + } +} diff --git a/app/models/server/raw/Permissions.ts b/app/models/server/raw/Permissions.ts index d5321c82c80b0..1b0bacacc53bb 100644 --- a/app/models/server/raw/Permissions.ts +++ b/app/models/server/raw/Permissions.ts @@ -2,4 +2,39 @@ import { BaseRaw } from './BaseRaw'; import { IPermission } from '../../../../definition/IPermission'; export class PermissionsRaw extends BaseRaw { + async createOrUpdate(name: string, roles: string[]): Promise { + const exists = await this.findOne>({ + _id: name, + roles, + }, { fields: { _id: 1 } }); + + if (exists) { + return exists._id; + } + + return this.update({ _id: name }, { $set: { roles } }, { upsert: true }).then((result) => result.result._id); + } + + async create(id: string, roles: string[]): Promise { + const exists = await this.findOneById>(id, { fields: { _id: 1 } }); + + if (exists) { + return exists._id; + } + + return this.update({ _id: id }, { $set: { roles } }, { upsert: true }).then((result) => result.result._id); + } + + + async addRole(permission: string, role: string): Promise { + await this.update({ _id: permission, roles: { $ne: role } }, { $addToSet: { roles: role } }); + } + + async setRoles(permission: string, roles: string[]): Promise { + await this.update({ _id: permission }, { $set: { roles } }); + } + + async removeRole(permission: string, role: string): Promise { + await this.update({ _id: permission, roles: role }, { $pull: { roles: role } }); + } } diff --git a/app/models/server/raw/ReadReceipts.ts b/app/models/server/raw/ReadReceipts.ts new file mode 100644 index 0000000000000..12763332ca3e2 --- /dev/null +++ b/app/models/server/raw/ReadReceipts.ts @@ -0,0 +1,15 @@ +import { Cursor } from 'mongodb'; + +import { BaseRaw, IndexSpecification } from './BaseRaw'; +import { ReadReceipt } from '../../../../definition/ReadReceipt'; + +export class ReadReceiptsRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { roomId: 1, userId: 1, messageId: 1 }, unique: true }, + { key: { messageId: 1 } }, + ]; + + findByMessageId(messageId: string): Cursor { + return this.find({ messageId }); + } +} diff --git a/app/models/server/raw/Reports.ts b/app/models/server/raw/Reports.ts new file mode 100644 index 0000000000000..9b47fdb8fe9f2 --- /dev/null +++ b/app/models/server/raw/Reports.ts @@ -0,0 +1,15 @@ +import { BaseRaw } from './BaseRaw'; +import { IReport } from '../../../../definition/IReport'; +import { IMessage } from '../../../../definition/IMessage'; + +export class ReportsRaw extends BaseRaw { + createWithMessageDescriptionAndUserId(message: IMessage, description: string, userId: string): ReturnType['insertOne']> { + const record: Pick = { + message, + description, + ts: new Date(), + userId, + }; + return this.insertOne(record); + } +} diff --git a/app/models/server/raw/Roles.js b/app/models/server/raw/Roles.js deleted file mode 100644 index 7e06551fde567..0000000000000 --- a/app/models/server/raw/Roles.js +++ /dev/null @@ -1,31 +0,0 @@ -import { BaseRaw } from './BaseRaw'; - -export class RolesRaw extends BaseRaw { - constructor(col, trash, models) { - super(col, trash); - - this.models = models; - } - - async isUserInRoles(userId, roles, scope) { - if (!Array.isArray(roles)) { - roles = [roles]; - } - - for (let i = 0, total = roles.length; i < total; i++) { - const roleName = roles[i]; - - // eslint-disable-next-line no-await-in-loop - const role = await this.findOne({ name: roleName }, { scope: 1 }); - const roleScope = (role && role.scope) || 'Users'; - const model = this.models[roleScope]; - - // eslint-disable-next-line no-await-in-loop - const permitted = await (model && model.isUserInRole && model.isUserInRole(userId, roleName, scope)); - if (permitted) { - return true; - } - } - return false; - } -} diff --git a/app/models/server/raw/Roles.ts b/app/models/server/raw/Roles.ts new file mode 100644 index 0000000000000..3d776945677ec --- /dev/null +++ b/app/models/server/raw/Roles.ts @@ -0,0 +1,200 @@ +import type { Collection, Cursor, FilterQuery, FindOneOptions, InsertOneWriteOpResult, UpdateWriteOpResult, WithId, WithoutProjection } from 'mongodb'; + +import { IRole, IUser } from '../../../../definition/IUser'; +import { BaseRaw } from './BaseRaw'; +import { SubscriptionsRaw } from './Subscriptions'; +import { UsersRaw } from './Users'; + +type ScopedModelRoles = { + Subscriptions: SubscriptionsRaw; + Users: UsersRaw; +} + +export class RolesRaw extends BaseRaw { + constructor(public readonly col: Collection, + private readonly models: ScopedModelRoles, trash?: Collection) { + super(col, trash); + } + + + findByUpdatedDate(updatedAfterDate: Date, options?: FindOneOptions): Cursor { + const query = { + _updatedAt: { $gte: new Date(updatedAfterDate) }, + }; + + return options ? this.find(query, options) : this.find(query); + } + + + createOrUpdate(name: IRole['name'], scope: 'Users' | 'Subscriptions' = 'Users', description = '', protectedRole = true, mandatory2fa = false): Promise { + const queryData = { + name, + scope, + description, + protected: protectedRole, + mandatory2fa, + }; + + return this.updateOne({ _id: name }, { $set: queryData }, { upsert: true }); + } + + async addUserRoles(userId: IUser['_id'], roles: IRole['_id'][], scope?: string): Promise { + if (!Array.isArray(roles)) { + roles = [roles]; + process.env.NODE_ENV === 'development' && console.warn('[WARN] RolesRaw.addUserRoles: roles should be an array'); + } + + for await (const name of roles) { + const role = await this.findOne({ name }, { scope: 1 } as FindOneOptions); + + if (!role) { + process.env.NODE_ENV === 'development' && console.warn(`[WARN] RolesRaw.addUserRoles: role: ${ name } not found`); + continue; + } + switch (role.scope) { + case 'Subscriptions': + await this.models.Subscriptions.addRolesByUserId(userId, [name], scope); + break; + case 'Users': + default: + await this.models.Users.addRolesByUserId(userId, [name]); + } + } + return true; + } + + + async isUserInRoles(userId: IUser['_id'], roles: IRole['_id'][], scope?: string): Promise { + if (!Array.isArray(roles)) { // TODO: remove this check + roles = [roles]; + process.env.NODE_ENV === 'development' && console.warn('[WARN] RolesRaw.isUserInRoles: roles should be an array'); + } + + for await (const roleName of roles) { + const role = await this.findOne({ name: roleName }, { scope: 1 } as FindOneOptions); + + if (!role) { + continue; + } + + switch (role.scope) { + case 'Subscriptions': + if (await this.models.Subscriptions.isUserInRole(userId, roleName, scope)) { + return true; + } + break; + case 'Users': + default: + if (await this.models.Users.isUserInRole(userId, roleName)) { + return true; + } + } + } + return false; + } + + async removeUserRoles(userId: IUser['_id'], roles: IRole['_id'][], scope?: string): Promise { + if (!Array.isArray(roles)) { // TODO: remove this check + roles = [roles]; + process.env.NODE_ENV === 'development' && console.warn('[WARN] RolesRaw.removeUserRoles: roles should be an array'); + } + for await (const roleName of roles) { + const role = await this.findOne({ name: roleName }, { scope: 1 } as FindOneOptions); + + if (!role) { + continue; + } + + switch (role.scope) { + case 'Subscriptions': + scope && await this.models.Subscriptions.removeRolesByUserId(userId, [roleName], scope); + break; + case 'Users': + default: + await this.models.Users.removeRolesByUserId(userId, [roleName]); + } + } + return true; + } + + async findOneByIdOrName(_idOrName: IRole['_id'] | IRole['name'], options?: undefined): Promise; + + async findOneByIdOrName(_idOrName: IRole['_id'] | IRole['name'], options: WithoutProjection>): Promise; + + async findOneByIdOrName

(_idOrName: IRole['_id'] | IRole['name'], options: FindOneOptions

): Promise

; + + findOneByIdOrName

(_idOrName: IRole['_id'] | IRole['name'], options?: any): Promise { + const query: FilterQuery = { + $or: [{ + _id: _idOrName, + }, { + name: _idOrName, + }], + }; + + return this.findOne(query, options); + } + + updateById(_id: IRole['_id'], name: IRole['name'], scope: IRole['scope'], description: IRole['description'] = '', mandatory2fa: IRole['mandatory2fa'] = false): Promise { + const queryData = { + name, + scope, + description, + mandatory2fa, + }; + + return this.updateOne({ _id }, { $set: queryData }, { upsert: true }); + } + + + findUsersInRole(name: IRole['name'], scope?: string): Promise>; + + findUsersInRole(name: IRole['name'], scope: string | undefined, options: WithoutProjection>): Promise>; + + findUsersInRole

(name: IRole['name'], scope: string | undefined, options: FindOneOptions

): Promise>; + + async findUsersInRole

(name: IRole['name'], scope: string | undefined, options?: any | undefined): Promise | Cursor

> { + const role = await this.findOne({ name }, { scope: 1 } as FindOneOptions); + + if (!role) { + throw new Error('RolesRaw.findUsersInRole: role not found'); + } + + switch (role.scope) { + case 'Subscriptions': + return this.models.Subscriptions.findUsersInRoles([name], scope, options); + case 'Users': + default: + return this.models.Users.findUsersInRoles([name], options); + } + } + + + createWithRandomId(name: IRole['name'], scope: 'Users' | 'Subscriptions' = 'Users', description = '', protectedRole = true, mandatory2fa = false): Promise>> { + const role = { + name, + scope, + description, + protected: protectedRole, + mandatory2fa, + }; + + return this.insertOne(role); + } + + + async canAddUserToRole(uid: IUser['_id'], name: IRole['name'], scope?: string): Promise { + const role = await this.findOne({ name }, { fields: { scope: 1 } } as FindOneOptions); + if (!role) { + return false; + } + + switch (role.scope) { + case 'Subscriptions': + return this.models.Subscriptions.isUserInRoleScope(uid, scope); + case 'Users': + default: + return this.models.Users.isUserInRoleScope(uid); + } + } +} diff --git a/app/models/server/raw/Rooms.js b/app/models/server/raw/Rooms.js index 600d55c1fffa4..a4d83368a8b43 100644 --- a/app/models/server/raw/Rooms.js +++ b/app/models/server/raw/Rooms.js @@ -27,10 +27,8 @@ export class RoomsRaw extends BaseRaw { { $match: { t: 'l', - closedAt: { $exists: true }, - metrics: { $exists: true }, - 'metrics.chatDuration': { $exists: true }, ...department && { departmentId: department }, + closedAt: { $exists: true }, }, }, { $sort: { closedAt: -1 } }, @@ -183,6 +181,23 @@ export class RoomsRaw extends BaseRaw { return this.find(query, options); } + findRoomsByNameOrFnameStarting(name, options) { + const nameRegex = new RegExp(`^${ escapeRegExp(name).trim() }`, 'i'); + + const query = { + t: { + $in: ['c', 'p'], + }, + $or: [{ + name: nameRegex, + }, { + fname: nameRegex, + }], + }; + + return this.find(query, options); + } + findRoomsWithoutDiscussionsByRoomIds(name, roomIds, options) { const nameRegex = new RegExp(`^${ escapeRegExp(name).trim() }`, 'i'); @@ -352,18 +367,20 @@ export class RoomsRaw extends BaseRaw { const firstParams = [lookup, messagesProject, messagesUnwind, messagesGroup, lastWeekMessagesUnwind, lastWeekMessagesGroup, presentationProject]; const sort = { $sort: options.sort || { messages: -1 } }; const params = [...firstParams, sort]; + if (onlyCount) { params.push({ $count: 'total' }); - return this.col.aggregate(params); } + if (options.offset) { params.push({ $skip: options.offset }); } + if (options.count) { params.push({ $limit: options.count }); } - return this.col.aggregate(params).toArray(); + return this.col.aggregate(params); } findOneByName(name, options = {}) { @@ -399,4 +416,23 @@ export class RoomsRaw extends BaseRaw { findOneByNameOrFname(name, options = {}) { return this.col.findOne({ $or: [{ name }, { fname: name }] }, options); } + + allRoomSourcesCount() { + return this.col.aggregate([ + { + $match: { + source: { + $exists: true, + }, + t: 'l', + }, + }, + { + $group: { + _id: '$source', + count: { $sum: 1 }, + }, + }, + ]); + } } diff --git a/app/models/server/raw/ServerEvents.ts b/app/models/server/raw/ServerEvents.ts index f36b44983e193..1bb1342ed8850 100644 --- a/app/models/server/raw/ServerEvents.ts +++ b/app/models/server/raw/ServerEvents.ts @@ -1,38 +1,28 @@ -import { Collection, ObjectId } from 'mongodb'; - -import { BaseRaw } from './BaseRaw'; +import { BaseRaw, IndexSpecification } from './BaseRaw'; import { IServerEvent, IServerEventType } from '../../../../definition/IServerEvent'; -import { IUser } from '../../../../definition/IUser'; export class ServerEventsRaw extends BaseRaw { - public readonly col!: Collection; - - async insertOne(data: Omit): Promise { - if (data.u) { - data.u = { _id: data.u._id, username: data.u.username } as IUser; - } - return this.col.insertOne({ - _id: new ObjectId().toHexString(), - ...data, - }); - } + protected indexes: IndexSpecification[] = [ + { key: { t: 1, ip: 1, ts: -1 } }, + { key: { t: 1, 'u.username': 1, ts: -1 } }, + ] async findLastFailedAttemptByIp(ip: string): Promise { - return this.col.findOne({ + return this.findOne({ ip, t: IServerEventType.FAILED_LOGIN_ATTEMPT, }, { sort: { ts: -1 } }); } async findLastFailedAttemptByUsername(username: string): Promise { - return this.col.findOne({ + return this.findOne({ 'u.username': username, t: IServerEventType.FAILED_LOGIN_ATTEMPT, }, { sort: { ts: -1 } }); } async countFailedAttemptsByUsernameSince(username: string, since: Date): Promise { - return this.col.find({ + return this.find({ 'u.username': username, t: IServerEventType.FAILED_LOGIN_ATTEMPT, ts: { @@ -42,7 +32,7 @@ export class ServerEventsRaw extends BaseRaw { } countFailedAttemptsByIpSince(ip: string, since: Date): Promise { - return this.col.find({ + return this.find({ ip, t: IServerEventType.FAILED_LOGIN_ATTEMPT, ts: { @@ -52,14 +42,14 @@ export class ServerEventsRaw extends BaseRaw { } countFailedAttemptsByIp(ip: string): Promise { - return this.col.find({ + return this.find({ ip, t: IServerEventType.FAILED_LOGIN_ATTEMPT, }).count(); } countFailedAttemptsByUsername(username: string): Promise { - return this.col.find({ + return this.find({ 'u.username': username, t: IServerEventType.FAILED_LOGIN_ATTEMPT, }).count(); diff --git a/app/models/server/raw/Sessions.js b/app/models/server/raw/Sessions.js deleted file mode 100644 index 965604fcd0b54..0000000000000 --- a/app/models/server/raw/Sessions.js +++ /dev/null @@ -1,285 +0,0 @@ -import { BaseRaw } from './BaseRaw'; -import Sessions from '../models/Sessions'; - -const matchBasedOnDate = (start, end) => { - if (start.year === end.year && start.month === end.month) { - return { - year: start.year, - month: start.month, - day: { $gte: start.day, $lte: end.day }, - }; - } - - if (start.year === end.year) { - return { - year: start.year, - $and: [{ - $or: [{ - month: { $gt: start.month }, - }, { - month: start.month, - day: { $gte: start.day }, - }], - }, { - $or: [{ - month: { $lt: end.month }, - }, { - month: end.month, - day: { $lte: end.day }, - }], - }], - }; - } - - return { - $and: [{ - $or: [{ - year: { $gt: start.year }, - }, { - year: start.year, - month: { $gt: start.month }, - }, { - year: start.year, - month: start.month, - day: { $gte: start.day }, - }], - }, { - $or: [{ - year: { $lt: end.year }, - }, { - year: end.year, - month: { $lt: end.month }, - }, { - year: end.year, - month: end.month, - day: { $lte: end.day }, - }], - }], - }; -}; - -const getGroupSessionsByHour = (_id) => { - const isOpenSession = { $not: ['$session.closedAt'] }; - const isAfterLoginAt = { $gte: ['$range', { $hour: '$session.loginAt' }] }; - const isBeforeClosedAt = { $lte: ['$range', { $hour: '$session.closedAt' }] }; - - const listGroup = { - $group: { - _id, - usersList: { - $addToSet: { - $cond: [ - { - $or: [ - { $and: [isOpenSession, isAfterLoginAt] }, - { $and: [isAfterLoginAt, isBeforeClosedAt] }, - ], - }, - '$session.userId', - '$$REMOVE', - ], - }, - }, - }, - }; - - const countGroup = { - $addFields: { - users: { $size: '$usersList' }, - }, - }; - - return { listGroup, countGroup }; -}; - -const getSortByFullDate = () => ({ - year: -1, - month: -1, - day: -1, -}); - -const getProjectionByFullDate = () => ({ - day: '$_id.day', - month: '$_id.month', - year: '$_id.year', -}); - -export class SessionsRaw extends BaseRaw { - getActiveUsersBetweenDates({ start, end }) { - return this.col.aggregate([ - { - $match: { - ...matchBasedOnDate(start, end), - type: 'user_daily', - }, - }, - { - $group: { - _id: '$userId', - }, - }, - ]).toArray(); - } - - async findLastLoginByIp(ip) { - return (await this.col.find({ - ip, - }, { - sort: { loginAt: -1 }, - limit: 1, - }).toArray())[0]; - } - - getActiveUsersOfPeriodByDayBetweenDates({ start, end }) { - return this.col.aggregate([ - { - $match: { - ...matchBasedOnDate(start, end), - type: 'user_daily', - mostImportantRole: { $ne: 'anonymous' }, - }, - }, - { - $group: { - _id: { - day: '$day', - month: '$month', - year: '$year', - userId: '$userId', - }, - }, - }, - { - $group: { - _id: { - day: '$_id.day', - month: '$_id.month', - year: '$_id.year', - }, - usersList: { - $addToSet: '$_id.userId', - }, - users: { $sum: 1 }, - }, - }, - { - $project: { - _id: 0, - ...getProjectionByFullDate(), - usersList: 1, - users: 1, - }, - }, - { - $sort: { - ...getSortByFullDate(), - }, - }, - ]).toArray(); - } - - getBusiestTimeWithinHoursPeriod({ start, end, groupSize }) { - const match = { - $match: { - type: 'computed-session', - loginAt: { $gte: start, $lte: end }, - }, - }; - const rangeProject = { - $project: { - range: { - $range: [0, 24, groupSize], - }, - session: '$$ROOT', - }, - }; - const unwind = { - $unwind: '$range', - }; - const groups = getGroupSessionsByHour('$range'); - const presentationProject = { - $project: { - _id: 0, - hour: '$_id', - users: 1, - }, - }; - const sort = { - $sort: { - hour: -1, - }, - }; - return this.col.aggregate([match, rangeProject, unwind, groups.listGroup, groups.countGroup, presentationProject, sort]).toArray(); - } - - getTotalOfSessionsByDayBetweenDates({ start, end }) { - return this.col.aggregate([ - { - $match: { - ...matchBasedOnDate(start, end), - type: 'user_daily', - mostImportantRole: { $ne: 'anonymous' }, - }, - }, - { - $group: { - _id: { year: '$year', month: '$month', day: '$day' }, - users: { $sum: 1 }, - }, - }, - { - $project: { - _id: 0, - ...getProjectionByFullDate(), - users: 1, - }, - }, - { - $sort: { - ...getSortByFullDate(), - }, - }, - ]).toArray(); - } - - getTotalOfSessionByHourAndDayBetweenDates({ start, end }) { - const match = { - $match: { - type: 'computed-session', - loginAt: { $gte: start, $lte: end }, - }, - }; - const rangeProject = { - $project: { - range: { - $range: [ - { $hour: '$loginAt' }, - { $sum: [{ $ifNull: [{ $hour: '$closedAt' }, 23] }, 1] }], - }, - session: '$$ROOT', - }, - - }; - const unwind = { - $unwind: '$range', - }; - const groups = getGroupSessionsByHour({ range: '$range', day: '$session.day', month: '$session.month', year: '$session.year' }); - const presentationProject = { - $project: { - _id: 0, - hour: '$_id.range', - ...getProjectionByFullDate(), - users: 1, - }, - }; - const sort = { - $sort: { - ...getSortByFullDate(), - hour: -1, - }, - }; - return this.col.aggregate([match, rangeProject, unwind, groups.listGroup, groups.countGroup, presentationProject, sort]).toArray(); - } -} - -export default new SessionsRaw(Sessions.model.rawCollection()); diff --git a/app/models/server/models/Sessions.tests.js b/app/models/server/raw/Sessions.tests.js similarity index 93% rename from app/models/server/models/Sessions.tests.js rename to app/models/server/raw/Sessions.tests.js index ba0b94c608925..8b284009c5a4b 100644 --- a/app/models/server/models/Sessions.tests.js +++ b/app/models/server/raw/Sessions.tests.js @@ -1,11 +1,6 @@ -/* eslint-env mocha */ - -import assert from 'assert'; - +import { expect } from 'chai'; import { MongoMemoryServer } from 'mongodb-memory-server'; -import './Sessions.mocks.js'; - const { MongoClient } = require('mongodb'); const { aggregates } = require('./Sessions'); @@ -282,14 +277,14 @@ describe('Sessions Aggregates', () => { it('should have sessions_dates data saved', () => { const collection = db.collection('sessions_dates'); return collection.find().toArray() - .then((docs) => assert.strictEqual(docs.length, DATA.sessions_dates.length)); + .then((docs) => expect(docs.length).to.be.equal(DATA.sessions_dates.length)); }); it('should match sessions between 2018-12-11 and 2019-1-10', () => { const collection = db.collection('sessions_dates'); const $match = aggregates.getMatchOfLastMonthOrWeek({ year: 2019, month: 1, day: 10 }); - assert.deepStrictEqual($match, { + expect($match).to.be.deep.equal({ $and: [{ $or: [ { year: { $gt: 2018 } }, @@ -309,8 +304,8 @@ describe('Sessions Aggregates', () => { $match, }]).toArray() .then((docs) => { - assert.strictEqual(docs.length, 31); - assert.deepStrictEqual(docs, [ + expect(docs.length).to.be.equal(31); + expect(docs).to.be.deep.equal([ { _id: '2018-12-11', year: 2018, month: 12, day: 11 }, { _id: '2018-12-12', year: 2018, month: 12, day: 12 }, { _id: '2018-12-13', year: 2018, month: 12, day: 13 }, @@ -350,7 +345,7 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions_dates'); const $match = aggregates.getMatchOfLastMonthOrWeek({ year: 2019, month: 2, day: 10 }); - assert.deepStrictEqual($match, { + expect($match).to.be.deep.equal({ year: 2019, $and: [{ $or: [ @@ -369,8 +364,8 @@ describe('Sessions Aggregates', () => { $match, }]).toArray() .then((docs) => { - assert.strictEqual(docs.length, 31); - assert.deepStrictEqual(docs, [ + expect(docs.length).to.be.deep.equal(31); + expect(docs).to.be.deep.equal([ { _id: '2019-1-11', year: 2019, month: 1, day: 11 }, { _id: '2019-1-12', year: 2019, month: 1, day: 12 }, { _id: '2019-1-13', year: 2019, month: 1, day: 13 }, @@ -410,7 +405,7 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions_dates'); const $match = aggregates.getMatchOfLastMonthOrWeek({ year: 2019, month: 5, day: 31 }); - assert.deepStrictEqual($match, { + expect($match).to.be.deep.equal({ year: 2019, month: 5, day: { $gte: 1, $lte: 31 }, @@ -420,8 +415,8 @@ describe('Sessions Aggregates', () => { $match, }]).toArray() .then((docs) => { - assert.strictEqual(docs.length, 31); - assert.deepStrictEqual(docs, [ + expect(docs.length).to.be.equal(31); + expect(docs).to.be.deep.equal([ { _id: '2019-5-1', year: 2019, month: 5, day: 1 }, { _id: '2019-5-2', year: 2019, month: 5, day: 2 }, { _id: '2019-5-3', year: 2019, month: 5, day: 3 }, @@ -461,7 +456,7 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions_dates'); const $match = aggregates.getMatchOfLastMonthOrWeek({ year: 2019, month: 4, day: 30 }); - assert.deepStrictEqual($match, { + expect($match).to.be.deep.equal({ year: 2019, month: 4, day: { $gte: 1, $lte: 30 }, @@ -471,8 +466,8 @@ describe('Sessions Aggregates', () => { $match, }]).toArray() .then((docs) => { - assert.strictEqual(docs.length, 30); - assert.deepStrictEqual(docs, [ + expect(docs.length).to.be.equal(30); + expect(docs).to.be.deep.equal([ { _id: '2019-4-1', year: 2019, month: 4, day: 1 }, { _id: '2019-4-2', year: 2019, month: 4, day: 2 }, { _id: '2019-4-3', year: 2019, month: 4, day: 3 }, @@ -511,7 +506,7 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions_dates'); const $match = aggregates.getMatchOfLastMonthOrWeek({ year: 2019, month: 2, day: 28 }); - assert.deepStrictEqual($match, { + expect($match).to.be.deep.equal({ year: 2019, month: 2, day: { $gte: 1, $lte: 28 }, @@ -521,8 +516,8 @@ describe('Sessions Aggregates', () => { $match, }]).toArray() .then((docs) => { - assert.strictEqual(docs.length, 28); - assert.deepStrictEqual(docs, [ + expect(docs.length).to.be.equal(28); + expect(docs).to.be.deep.equal([ { _id: '2019-2-1', year: 2019, month: 2, day: 1 }, { _id: '2019-2-2', year: 2019, month: 2, day: 2 }, { _id: '2019-2-3', year: 2019, month: 2, day: 3 }, @@ -559,7 +554,7 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions_dates'); const $match = aggregates.getMatchOfLastMonthOrWeek({ year: 2019, month: 2, day: 27 }); - assert.deepStrictEqual($match, { + expect($match).to.be.deep.equal({ year: 2019, $and: [{ $or: [ @@ -578,8 +573,8 @@ describe('Sessions Aggregates', () => { $match, }]).toArray() .then((docs) => { - assert.strictEqual(docs.length, 31); - assert.deepStrictEqual(docs, [ + expect(docs.length).to.be.equal(31); + expect(docs).to.be.deep.equal([ { _id: '2019-1-28', year: 2019, month: 1, day: 28 }, { _id: '2019-1-29', year: 2019, month: 1, day: 29 }, { _id: '2019-1-30', year: 2019, month: 1, day: 30 }, @@ -618,7 +613,7 @@ describe('Sessions Aggregates', () => { it('should have sessions data saved', () => { const collection = db.collection('sessions'); return collection.find().toArray() - .then((docs) => assert.strictEqual(docs.length, DATA.sessions.length)); + .then((docs) => expect(docs.length).to.be.equal(DATA.sessions.length)); }); it('should generate daily sessions', () => { @@ -631,8 +626,8 @@ describe('Sessions Aggregates', () => { await collection.insertMany(docs); - assert.strictEqual(docs.length, 3); - assert.deepStrictEqual(docs, [{ + expect(docs.length).to.be.equal(3); + expect(docs).to.be.deep.equal([{ _id: 'xPZXw9xqM3kKshsse-2019-5-2', time: 5814, sessions: 3, @@ -728,8 +723,8 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions'); return aggregates.getUniqueUsersOfLastMonthOrWeek(collection, { year: 2019, month: 5, day: 31 }) .then((docs) => { - assert.strictEqual(docs.length, 1); - assert.deepStrictEqual(docs, [{ + expect(docs.length).to.be.equal(1); + expect(docs).to.be.deep.equal([{ count: 2, roles: [{ count: 1, @@ -752,8 +747,8 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions'); return aggregates.getUniqueUsersOfYesterday(collection, { year: 2019, month: 5, day: 1 }) .then((docs) => { - assert.strictEqual(docs.length, 1); - assert.deepStrictEqual(docs, [{ + expect(docs.length).to.be.equal(1); + expect(docs).to.be.deep.equal([{ count: 1, roles: [{ count: 1, @@ -771,8 +766,8 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions'); return aggregates.getUniqueUsersOfYesterday(collection, { year: 2019, month: 5, day: 2 }) .then((docs) => { - assert.strictEqual(docs.length, 1); - assert.deepStrictEqual(docs, [{ + expect(docs.length).to.be.equal(1); + expect(docs).to.be.deep.equal([{ count: 1, roles: [{ count: 1, @@ -790,8 +785,8 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions'); return aggregates.getUniqueDevicesOfLastMonthOrWeek(collection, { year: 2019, month: 5, day: 31 }) .then((docs) => { - assert.strictEqual(docs.length, 2); - assert.deepStrictEqual(docs, [{ + expect(docs.length).to.be.equal(2); + expect(docs).to.be.deep.equal([{ count: 3, time: 9695, type: 'browser', @@ -811,8 +806,8 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions'); return aggregates.getUniqueDevicesOfYesterday(collection, { year: 2019, month: 5, day: 2 }) .then((docs) => { - assert.strictEqual(docs.length, 2); - assert.deepStrictEqual(docs, [{ + expect(docs.length).to.be.equal(2); + expect(docs).to.be.deep.equal([{ count: 2, time: 5528, type: 'browser', @@ -832,8 +827,8 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions'); return aggregates.getUniqueOSOfLastMonthOrWeek(collection, { year: 2019, month: 5, day: 31 }) .then((docs) => { - assert.strictEqual(docs.length, 2); - assert.deepStrictEqual(docs, [{ + expect(docs.length).to.be.equal(2); + expect(docs).to.be.deep.equal([{ count: 3, time: 9695, name: 'Mac OS', @@ -851,8 +846,8 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions'); return aggregates.getUniqueOSOfYesterday(collection, { year: 2019, month: 5, day: 2 }) .then((docs) => { - assert.strictEqual(docs.length, 2); - assert.deepStrictEqual(docs, [{ + expect(docs.length).to.be.equal(2); + expect(docs).to.be.deep.equal([{ count: 2, time: 5528, name: 'Mac OS', @@ -870,7 +865,7 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions_dates'); const $match = aggregates.getMatchOfLastMonthOrWeek({ year: 2019, month: 1, day: 4, type: 'week' }); - assert.deepStrictEqual($match, { + expect($match).to.be.deep.equal({ $and: [{ $or: [ { year: { $gt: 2018 } }, @@ -890,8 +885,8 @@ describe('Sessions Aggregates', () => { $match, }]).toArray() .then((docs) => { - assert.strictEqual(docs.length, 7); - assert.deepStrictEqual(docs, [ + expect(docs.length).to.be.equal(7); + expect(docs).to.be.deep.equal([ { _id: '2018-12-29', year: 2018, month: 12, day: 29 }, { _id: '2018-12-30', year: 2018, month: 12, day: 30 }, { _id: '2018-12-31', year: 2018, month: 12, day: 31 }, @@ -907,7 +902,7 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions_dates'); const $match = aggregates.getMatchOfLastMonthOrWeek({ year: 2019, month: 2, day: 4, type: 'week' }); - assert.deepStrictEqual($match, { + expect($match).to.be.deep.equal({ year: 2019, $and: [{ $or: [ @@ -926,8 +921,8 @@ describe('Sessions Aggregates', () => { $match, }]).toArray() .then((docs) => { - assert.strictEqual(docs.length, 7); - assert.deepStrictEqual(docs, [ + expect(docs.length).to.be.equal(7); + expect(docs).to.be.deep.equal([ { _id: '2019-1-29', year: 2019, month: 1, day: 29 }, { _id: '2019-1-30', year: 2019, month: 1, day: 30 }, { _id: '2019-1-31', year: 2019, month: 1, day: 31 }, @@ -943,7 +938,7 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions_dates'); const $match = aggregates.getMatchOfLastMonthOrWeek({ year: 2019, month: 5, day: 7, type: 'week' }); - assert.deepStrictEqual($match, { + expect($match).to.be.deep.equal({ year: 2019, month: 5, day: { $gte: 1, $lte: 7 }, @@ -953,8 +948,8 @@ describe('Sessions Aggregates', () => { $match, }]).toArray() .then((docs) => { - assert.strictEqual(docs.length, 7); - assert.deepStrictEqual(docs, [ + expect(docs.length).to.be.equal(7); + expect(docs).to.be.deep.equal([ { _id: '2019-5-1', year: 2019, month: 5, day: 1 }, { _id: '2019-5-2', year: 2019, month: 5, day: 2 }, { _id: '2019-5-3', year: 2019, month: 5, day: 3 }, @@ -970,7 +965,7 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions_dates'); const $match = aggregates.getMatchOfLastMonthOrWeek({ year: 2019, month: 5, day: 14, type: 'week' }); - assert.deepStrictEqual($match, { + expect($match).to.be.deep.equal({ year: 2019, month: 5, day: { $gte: 8, $lte: 14 }, @@ -980,8 +975,8 @@ describe('Sessions Aggregates', () => { $match, }]).toArray() .then((docs) => { - assert.strictEqual(docs.length, 7); - assert.deepStrictEqual(docs, [ + expect(docs.length).to.be.equal(7); + expect(docs).to.be.deep.equal([ { _id: '2019-5-8', year: 2019, month: 5, day: 8 }, { _id: '2019-5-9', year: 2019, month: 5, day: 9 }, { _id: '2019-5-10', year: 2019, month: 5, day: 10 }, @@ -997,7 +992,7 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions'); return aggregates.getUniqueUsersOfLastMonthOrWeek(collection, { year: 2019, month: 5, day: 31, type: 'week' }) .then((docs) => { - assert.strictEqual(docs.length, 0); + expect(docs.length).to.be.equal(0); }); }); @@ -1005,8 +1000,8 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions'); return aggregates.getUniqueUsersOfLastMonthOrWeek(collection, { year: 2019, month: 5, day: 7, type: 'week' }) .then((docs) => { - assert.strictEqual(docs.length, 1); - assert.deepStrictEqual(docs, [{ + expect(docs.length).to.be.equal(1); + expect(docs).to.be.deep.equal([{ count: 2, roles: [{ count: 1, @@ -1029,8 +1024,8 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions'); return aggregates.getUniqueDevicesOfLastMonthOrWeek(collection, { year: 2019, month: 5, day: 7, type: 'week' }) .then((docs) => { - assert.strictEqual(docs.length, 2); - assert.deepStrictEqual(docs, [{ + expect(docs.length).to.be.equal(2); + expect(docs).to.be.deep.equal([{ count: 3, time: 9695, type: 'browser', @@ -1050,8 +1045,8 @@ describe('Sessions Aggregates', () => { const collection = db.collection('sessions'); return aggregates.getUniqueOSOfLastMonthOrWeek(collection, { year: 2019, month: 5, day: 7 }) .then((docs) => { - assert.strictEqual(docs.length, 2); - assert.deepStrictEqual(docs, [{ + expect(docs.length).to.be.equal(2); + expect(docs).to.be.deep.equal([{ count: 3, time: 9695, name: 'Mac OS', diff --git a/app/models/server/raw/Sessions.ts b/app/models/server/raw/Sessions.ts new file mode 100644 index 0000000000000..64d0c5cca7ff0 --- /dev/null +++ b/app/models/server/raw/Sessions.ts @@ -0,0 +1,1061 @@ +import { AggregationCursor, BulkWriteOperation, BulkWriteOpResultObject, Collection, IndexSpecification, UpdateWriteOpResult, FilterQuery } from 'mongodb'; + +import type { ISession } from '../../../../definition/ISession'; +import { BaseRaw, ModelOptionalId } from './BaseRaw'; +import type { IUser } from '../../../../definition/IUser'; + +type DestructuredDate = {year: number; month: number; day: number}; +type DestructuredDateWithType = {year: number; month: number; day: number; type?: 'month' | 'week'}; +type DestructuredRange = {start: DestructuredDate; end: DestructuredDate}; +type DateRange = {start: Date; end: Date}; +type FullReturn = { year: number; month: number; day: number; data: ISession[] }; + +const matchBasedOnDate = (start: DestructuredDate, end: DestructuredDate): FilterQuery => { + if (start.year === end.year && start.month === end.month) { + return { + year: start.year, + month: start.month, + day: { $gte: start.day, $lte: end.day }, + }; + } + + if (start.year === end.year) { + return { + year: start.year, + $and: [{ + $or: [{ + month: { $gt: start.month }, + }, { + month: start.month, + day: { $gte: start.day }, + }], + }, { + $or: [{ + month: { $lt: end.month }, + }, { + month: end.month, + day: { $lte: end.day }, + }], + }], + }; + } + + return { + $and: [{ + $or: [{ + year: { $gt: start.year }, + }, { + year: start.year, + month: { $gt: start.month }, + }, { + year: start.year, + month: start.month, + day: { $gte: start.day }, + }], + }, { + $or: [{ + year: { $lt: end.year }, + }, { + year: end.year, + month: { $lt: end.month }, + }, { + year: end.year, + month: end.month, + day: { $lte: end.day }, + }], + }], + }; +}; + +const getGroupSessionsByHour = (_id: { range: string; day: string; month: string; year: string } | string): {listGroup: object; countGroup: object} => { + const isOpenSession = { $not: ['$session.closedAt'] }; + const isAfterLoginAt = { $gte: ['$range', { $hour: '$session.loginAt' }] }; + const isBeforeClosedAt = { $lte: ['$range', { $hour: '$session.closedAt' }] }; + + const listGroup = { + $group: { + _id, + usersList: { + $addToSet: { + $cond: [ + { + $or: [ + { $and: [isOpenSession, isAfterLoginAt] }, + { $and: [isAfterLoginAt, isBeforeClosedAt] }, + ], + }, + '$session.userId', + '$$REMOVE', + ], + }, + }, + }, + }; + + const countGroup = { + $addFields: { + users: { $size: '$usersList' }, + }, + }; + + return { listGroup, countGroup }; +}; + +const getSortByFullDate = (): { year: number; month: number; day: number } => ({ + year: -1, + month: -1, + day: -1, +}); + +const getProjectionByFullDate = (): { day: string; month: string; year: string } => ({ + day: '$_id.day', + month: '$_id.month', + year: '$_id.year', +}); + +export const aggregates = { + dailySessionsOfYesterday(collection: Collection, { year, month, day }: DestructuredDate): AggregationCursor & { + time: number; + sessions: number; + devices: ISession['device'][]; + _computedAt: string; + }> { + return collection.aggregate & { + time: number; + sessions: number; + devices: ISession['device'][]; + _computedAt: string; + }>([{ + $match: { + userId: { $exists: true }, + lastActivityAt: { $exists: true }, + device: { $exists: true }, + type: 'session', + $or: [{ + year: { $lt: year }, + }, { + year, + month: { $lt: month }, + }, { + year, + month, + day: { $lte: day }, + }], + }, + }, { + $project: { + userId: 1, + device: 1, + day: 1, + month: 1, + year: 1, + mostImportantRole: 1, + time: { $trunc: { $divide: [{ $subtract: ['$lastActivityAt', '$loginAt'] }, 1000] } }, + }, + }, { + $match: { + time: { $gt: 0 }, + }, + }, { + $group: { + _id: { + userId: '$userId', + device: '$device', + day: '$day', + month: '$month', + year: '$year', + }, + mostImportantRole: { $first: '$mostImportantRole' }, + time: { $sum: '$time' }, + sessions: { $sum: 1 }, + }, + }, { + $sort: { + time: -1, + }, + }, { + $group: { + _id: { + userId: '$_id.userId', + day: '$_id.day', + month: '$_id.month', + year: '$_id.year', + }, + mostImportantRole: { $first: '$mostImportantRole' }, + time: { $sum: '$time' }, + sessions: { $sum: '$sessions' }, + devices: { + $push: { + sessions: '$sessions', + time: '$time', + device: '$_id.device', + }, + }, + }, + }, { + $sort: { + _id: 1, + }, + }, { + $project: { + _id: 0, + type: { $literal: 'user_daily' }, + _computedAt: { $literal: new Date() }, + day: '$_id.day', + month: '$_id.month', + year: '$_id.year', + userId: '$_id.userId', + mostImportantRole: 1, + time: 1, + sessions: 1, + devices: 1, + }, + }], { allowDiskUse: true }); + }, + + async getUniqueUsersOfYesterday(collection: Collection, { year, month, day }: DestructuredDate): Promise { + return collection.aggregate([{ + $match: { + year, + month, + day, + type: 'user_daily', + }, + }, { + $group: { + _id: { + day: '$day', + month: '$month', + year: '$year', + mostImportantRole: '$mostImportantRole', + }, + count: { + $sum: 1, + }, + sessions: { + $sum: '$sessions', + }, + time: { + $sum: '$time', + }, + }, + }, { + $group: { + _id: { + day: '$day', + month: '$month', + year: '$year', + }, + roles: { + $push: { + role: '$_id.mostImportantRole', + count: '$count', + sessions: '$sessions', + time: '$time', + }, + }, + count: { + $sum: '$count', + }, + sessions: { + $sum: '$sessions', + }, + time: { + $sum: '$time', + }, + }, + }, { + $project: { + _id: 0, + count: 1, + sessions: 1, + time: 1, + roles: 1, + }, + }]).toArray(); + }, + + async getUniqueUsersOfLastMonthOrWeek(collection: Collection, { year, month, day, type = 'month' }: DestructuredDateWithType): Promise { + return collection.aggregate([{ + $match: { + type: 'user_daily', + ...aggregates.getMatchOfLastMonthOrWeek({ year, month, day, type }), + }, + }, { + $group: { + _id: { + userId: '$userId', + }, + mostImportantRole: { $first: '$mostImportantRole' }, + sessions: { + $sum: '$sessions', + }, + time: { + $sum: '$time', + }, + }, + }, { + $group: { + _id: { + mostImportantRole: '$mostImportantRole', + }, + count: { + $sum: 1, + }, + sessions: { + $sum: '$sessions', + }, + time: { + $sum: '$time', + }, + }, + }, { + $sort: { + time: -1, + }, + }, { + $group: { + _id: 1, + roles: { + $push: { + role: '$_id.mostImportantRole', + count: '$count', + sessions: '$sessions', + time: '$time', + }, + }, + count: { + $sum: '$count', + }, + sessions: { + $sum: '$sessions', + }, + time: { + $sum: '$time', + }, + }, + }, { + $project: { + _id: 0, + count: 1, + roles: 1, + sessions: 1, + time: 1, + }, + }], { allowDiskUse: true }).toArray(); + }, + + getMatchOfLastMonthOrWeek({ year, month, day, type = 'month' }: DestructuredDateWithType): FilterQuery { + let startOfPeriod; + + if (type === 'month') { + const pastMonthLastDay = new Date(year, month - 1, 0).getDate(); + const currMonthLastDay = new Date(year, month, 0).getDate(); + + startOfPeriod = new Date(year, month - 1, day); + startOfPeriod.setMonth(startOfPeriod.getMonth() - 1, (currMonthLastDay === day ? pastMonthLastDay : Math.min(pastMonthLastDay, day)) + 1); + } else { + startOfPeriod = new Date(year, month - 1, day - 6); + } + + const startOfPeriodObject = { + year: startOfPeriod.getFullYear(), + month: startOfPeriod.getMonth() + 1, + day: startOfPeriod.getDate(), + }; + + if (year === startOfPeriodObject.year && month === startOfPeriodObject.month) { + return { + year, + month, + day: { $gte: startOfPeriodObject.day, $lte: day }, + }; + } + + if (year === startOfPeriodObject.year) { + return { + year, + $and: [{ + $or: [{ + month: { $gt: startOfPeriodObject.month }, + }, { + month: startOfPeriodObject.month, + day: { $gte: startOfPeriodObject.day }, + }], + }, { + $or: [{ + month: { $lt: month }, + }, { + month, + day: { $lte: day }, + }], + }], + }; + } + + return { + $and: [{ + $or: [{ + year: { $gt: startOfPeriodObject.year }, + }, { + year: startOfPeriodObject.year, + month: { $gt: startOfPeriodObject.month }, + }, { + year: startOfPeriodObject.year, + month: startOfPeriodObject.month, + day: { $gte: startOfPeriodObject.day }, + }], + }, { + $or: [{ + year: { $lt: year }, + }, { + year, + month: { $lt: month }, + }, { + year, + month, + day: { $lte: day }, + }], + }], + }; + }, + + async getUniqueDevicesOfLastMonthOrWeek(collection: Collection, { year, month, day, type = 'month' }: DestructuredDateWithType): Promise { + return collection.aggregate([{ + $match: { + type: 'user_daily', + ...aggregates.getMatchOfLastMonthOrWeek({ year, month, day, type }), + }, + }, { + $unwind: '$devices', + }, { + $group: { + _id: { + type: '$devices.device.type', + name: '$devices.device.name', + version: '$devices.device.version', + }, + count: { + $sum: '$devices.sessions', + }, + time: { + $sum: '$devices.time', + }, + }, + }, { + $sort: { + time: -1, + }, + }, { + $project: { + _id: 0, + type: '$_id.type', + name: '$_id.name', + version: '$_id.version', + count: 1, + time: 1, + }, + }], { allowDiskUse: true }).toArray(); + }, + + getUniqueDevicesOfYesterday(collection: Collection, { year, month, day }: DestructuredDate): Promise { + return collection.aggregate([{ + $match: { + year, + month, + day, + type: 'user_daily', + }, + }, { + $unwind: '$devices', + }, { + $group: { + _id: { + type: '$devices.device.type', + name: '$devices.device.name', + version: '$devices.device.version', + }, + count: { + $sum: '$devices.sessions', + }, + time: { + $sum: '$devices.time', + }, + }, + }, { + $sort: { + time: -1, + }, + }, { + $project: { + _id: 0, + type: '$_id.type', + name: '$_id.name', + version: '$_id.version', + count: 1, + time: 1, + }, + }]).toArray(); + }, + + getUniqueOSOfLastMonthOrWeek(collection: Collection, { year, month, day, type = 'month' }: DestructuredDateWithType): Promise { + return collection.aggregate([{ + $match: { + type: 'user_daily', + 'devices.device.os.name': { + $exists: true, + }, + ...aggregates.getMatchOfLastMonthOrWeek({ year, month, day, type }), + }, + }, { + $unwind: '$devices', + }, { + $group: { + _id: { + name: '$devices.device.os.name', + version: '$devices.device.os.version', + }, + count: { + $sum: '$devices.sessions', + }, + time: { + $sum: '$devices.time', + }, + }, + }, { + $sort: { + time: -1, + }, + }, { + $project: { + _id: 0, + name: '$_id.name', + version: '$_id.version', + count: 1, + time: 1, + }, + }], { allowDiskUse: true }).toArray(); + }, + + getUniqueOSOfYesterday(collection: Collection, { year, month, day }: DestructuredDate): Promise { + return collection.aggregate([{ + $match: { + year, + month, + day, + type: 'user_daily', + 'devices.device.os.name': { + $exists: true, + }, + }, + }, { + $unwind: '$devices', + }, { + $group: { + _id: { + name: '$devices.device.os.name', + version: '$devices.device.os.version', + }, + count: { + $sum: '$devices.sessions', + }, + time: { + $sum: '$devices.time', + }, + }, + }, { + $sort: { + time: -1, + }, + }, { + $project: { + _id: 0, + name: '$_id.name', + version: '$_id.version', + count: 1, + time: 1, + }, + }]).toArray(); + }, +}; + +export class SessionsRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { instanceId: 1, sessionId: 1, year: 1, month: 1, day: 1 } }, + { key: { instanceId: 1, sessionId: 1, userId: 1 } }, + { key: { instanceId: 1, sessionId: 1 } }, + { key: { sessionId: 1 } }, + { key: { userId: 1 } }, + { key: { year: 1, month: 1, day: 1, type: 1 } }, + { key: { type: 1 } }, + { key: { ip: 1, loginAt: 1 } }, + { key: { _computedAt: 1 }, expireAfterSeconds: 60 * 60 * 24 * 45 }, + ] + + private secondaryCollection: Collection; + + constructor( + public readonly col: Collection, + public readonly colSecondary: Collection, + trash?: Collection, + ) { + super(col, trash); + + this.secondaryCollection = colSecondary; + } + + async getActiveUsersBetweenDates({ start, end }: DestructuredRange): Promise { + return this.col.aggregate([ + { + $match: { + ...matchBasedOnDate(start, end), + type: 'user_daily', + }, + }, + { + $group: { + _id: '$userId', + }, + }, + ]).toArray(); + } + + async findLastLoginByIp(ip: string): Promise { + return this.findOne({ + ip, + }, { + sort: { loginAt: -1 }, + limit: 1, + }); + } + + async getActiveUsersOfPeriodByDayBetweenDates({ start, end }: DestructuredRange): Promise<{ + day: number; + month: number; + year: number; + usersList: IUser['_id'][]; + users: number; + }[]> { + return this.col.aggregate<{ + day: number; + month: number; + year: number; + usersList: IUser['_id'][]; + users: number; + }>([ + { + $match: { + ...matchBasedOnDate(start, end), + type: 'user_daily', + mostImportantRole: { $ne: 'anonymous' }, + }, + }, + { + $group: { + _id: { + day: '$day', + month: '$month', + year: '$year', + userId: '$userId', + }, + }, + }, + { + $group: { + _id: { + day: '$_id.day', + month: '$_id.month', + year: '$_id.year', + }, + usersList: { + $addToSet: '$_id.userId', + }, + users: { $sum: 1 }, + }, + }, + { + $project: { + _id: 0, + ...getProjectionByFullDate(), + usersList: 1, + users: 1, + }, + }, + { + $sort: { + ...getSortByFullDate(), + }, + }, + ]).toArray(); + } + + async getBusiestTimeWithinHoursPeriod({ start, end, groupSize }: DateRange & { groupSize: number }): Promise<{ + hour: number; + users: number; + }[]> { + const match = { + $match: { + type: 'computed-session', + loginAt: { $gte: start, $lte: end }, + }, + }; + const rangeProject = { + $project: { + range: { + $range: [0, 24, groupSize], + }, + session: '$$ROOT', + }, + }; + const unwind = { + $unwind: '$range', + }; + const groups = getGroupSessionsByHour('$range'); + const presentationProject = { + $project: { + _id: 0, + hour: '$_id', + users: 1, + }, + }; + const sort = { + $sort: { + hour: -1, + }, + }; + return this.col.aggregate<{ + hour: number; + users: number; + }>([match, rangeProject, unwind, groups.listGroup, groups.countGroup, presentationProject, sort]).toArray(); + } + + async getTotalOfSessionsByDayBetweenDates({ start, end }: DestructuredRange): Promise<{ + day: number; + month: number; + year: number; + users: number; + }[]> { + return this.col.aggregate<{ + day: number; + month: number; + year: number; + users: number; + }>([ + { + $match: { + ...matchBasedOnDate(start, end), + type: 'user_daily', + mostImportantRole: { $ne: 'anonymous' }, + }, + }, + { + $group: { + _id: { year: '$year', month: '$month', day: '$day' }, + users: { $sum: 1 }, + }, + }, + { + $project: { + _id: 0, + ...getProjectionByFullDate(), + users: 1, + }, + }, + { + $sort: { + ...getSortByFullDate(), + }, + }, + ]).toArray(); + } + + async getTotalOfSessionByHourAndDayBetweenDates({ start, end }: DateRange): Promise<{ + hour: number; + day: number; + month: number; + year: number; + users: number; + }[]> { + const match = { + $match: { + type: 'computed-session', + loginAt: { $gte: start, $lte: end }, + }, + }; + const rangeProject = { + $project: { + range: { + $range: [ + { $hour: '$loginAt' }, + { $sum: [{ $ifNull: [{ $hour: '$closedAt' }, 23] }, 1] }], + }, + session: '$$ROOT', + }, + + }; + const unwind = { + $unwind: '$range', + }; + const groups = getGroupSessionsByHour({ range: '$range', day: '$session.day', month: '$session.month', year: '$session.year' }); + const presentationProject = { + $project: { + _id: 0, + hour: '$_id.range', + ...getProjectionByFullDate(), + users: 1, + }, + }; + const sort = { + $sort: { + ...getSortByFullDate(), + hour: -1, + }, + }; + + return this.col.aggregate<{ + hour: number; + day: number; + month: number; + year: number; + users: number; + }>([match, rangeProject, unwind, groups.listGroup, groups.countGroup, presentationProject, sort]).toArray(); + } + + async getUniqueUsersOfYesterday(): Promise { + const date = new Date(); + date.setDate(date.getDate() - 1); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { + year, + month, + day, + data: await aggregates.getUniqueUsersOfYesterday(this.secondaryCollection, { year, month, day }), + }; + } + + async getUniqueUsersOfLastMonth(): Promise { + const date = new Date(); + date.setDate(date.getDate() - 1); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { + year, + month, + day, + data: await aggregates.getUniqueUsersOfLastMonthOrWeek(this.secondaryCollection, { year, month, day }), + }; + } + + async getUniqueUsersOfLastWeek(): Promise { + const date = new Date(); + date.setDate(date.getDate() - 1); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { + year, + month, + day, + data: await aggregates.getUniqueUsersOfLastMonthOrWeek(this.secondaryCollection, { year, month, day, type: 'week' }), + }; + } + + async getUniqueDevicesOfYesterday(): Promise { + const date = new Date(); + date.setDate(date.getDate() - 1); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { + year, + month, + day, + data: await aggregates.getUniqueDevicesOfYesterday(this.secondaryCollection, { year, month, day }), + }; + } + + async getUniqueDevicesOfLastMonth(): Promise { + const date = new Date(); + date.setDate(date.getDate() - 1); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { + year, + month, + day, + data: await aggregates.getUniqueDevicesOfLastMonthOrWeek(this.secondaryCollection, { year, month, day }), + }; + } + + async getUniqueDevicesOfLastWeek(): Promise { + const date = new Date(); + date.setDate(date.getDate() - 1); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { + year, + month, + day, + data: await aggregates.getUniqueDevicesOfLastMonthOrWeek(this.secondaryCollection, { year, month, day, type: 'week' }), + }; + } + + async getUniqueOSOfYesterday(): Promise { + const date = new Date(); + date.setDate(date.getDate() - 1); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { + year, + month, + day, + data: await aggregates.getUniqueOSOfYesterday(this.secondaryCollection, { year, month, day }), + }; + } + + async getUniqueOSOfLastMonth(): Promise { + const date = new Date(); + date.setDate(date.getDate() - 1); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { + year, + month, + day, + data: await aggregates.getUniqueOSOfLastMonthOrWeek(this.secondaryCollection, { year, month, day }), + }; + } + + async getUniqueOSOfLastWeek(): Promise { + const date = new Date(); + date.setDate(date.getDate() - 1); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return { + year, + month, + day, + data: await aggregates.getUniqueOSOfLastMonthOrWeek(this.secondaryCollection, { year, month, day, type: 'week' }), + }; + } + + async createOrUpdate(data: ISession): Promise { + const { year, month, day, sessionId, instanceId } = data; + + if (!year || !month || !day || !sessionId || !instanceId) { + return; + } + + const now = new Date(); + + return this.updateOne({ instanceId, sessionId, year, month, day }, { + $set: data, + $setOnInsert: { + createdAt: now, + }, + }, { upsert: true }); + } + + async closeByInstanceIdAndSessionId(instanceId: string, sessionId: string): Promise { + const query = { + instanceId, + sessionId, + closedAt: { $exists: false }, + }; + + const closeTime = new Date(); + const update = { + $set: { + closedAt: closeTime, + lastActivityAt: closeTime, + }, + }; + + return this.updateOne(query, update); + } + + async updateActiveSessionsByDateAndInstanceIdAndIds({ year, month, day }: Partial = {}, instanceId: string, sessions: string[], data = {}): Promise { + const query = { + instanceId, + year, + month, + day, + sessionId: { $in: sessions }, + closedAt: { $exists: false }, + }; + + const update = { + $set: data, + }; + + return this.updateMany(query, update); + } + + async logoutByInstanceIdAndSessionIdAndUserId(instanceId: string, sessionId: string, userId: string): Promise { + const query = { + instanceId, + sessionId, + userId, + logoutAt: { $exists: 0 }, + }; + + const logoutAt = new Date(); + const update = { + $set: { + logoutAt, + }, + }; + + return this.updateMany(query, update); + } + + async createBatch(sessions: ModelOptionalId[]): Promise { + if (!sessions || sessions.length === 0) { + return; + } + + const ops: BulkWriteOperation[] = []; + sessions.forEach((doc) => { + const { year, month, day, sessionId, instanceId } = doc; + delete doc._id; + + ops.push({ + updateOne: { + filter: { year, month, day, sessionId, instanceId }, + update: { + $set: doc, + }, + upsert: true, + }, + }); + }); + + return this.col.bulkWrite(ops, { ordered: false }); + } +} diff --git a/app/models/server/raw/Settings.ts b/app/models/server/raw/Settings.ts index dd475ed9a1312..7e84d539b05eb 100644 --- a/app/models/server/raw/Settings.ts +++ b/app/models/server/raw/Settings.ts @@ -1,18 +1,29 @@ -import { Cursor, WriteOpResult } from 'mongodb'; +import { Cursor, FilterQuery, UpdateQuery, WriteOpResult } from 'mongodb'; import { BaseRaw } from './BaseRaw'; -import { ISetting } from '../../../../definition/ISetting'; +import { ISetting, ISettingColor, ISettingSelectOption } from '../../../../definition/ISetting'; -type T = ISetting; -export class SettingsRaw extends BaseRaw { +export class SettingsRaw extends BaseRaw { async getValueById(_id: string): Promise { const setting = await this.findOne>({ _id }, { projection: { value: 1 } }); return setting?.value; } - findOneNotHiddenById(_id: string): Promise { + findNotHidden({ updatedAfter }: { updatedAfter?: Date } = {}): Cursor { + const query: FilterQuery = { + hidden: { $ne: true }, + }; + + if (updatedAfter) { + query._updatedAt = { $gt: updatedAfter }; + } + + return this.find(query); + } + + findOneNotHiddenById(_id: string): Promise { const query = { _id, hidden: { $ne: true }, @@ -21,7 +32,7 @@ export class SettingsRaw extends BaseRaw { return this.findOne(query); } - findByIds(_id: string[] | string = []): Cursor { + findByIds(_id: string[] | string = []): Cursor { if (typeof _id === 'string') { _id = [_id]; } @@ -35,7 +46,50 @@ export class SettingsRaw extends BaseRaw { return this.find(query); } - updateValueById(_id: string, value: any): Promise { + updateValueById(_id: string, value: T): Promise { + const query = { + blocked: { $ne: true }, + value: { $ne: value }, + _id, + }; + + const update = { + $set: { + value, + }, + }; + + return this.update(query, update); + } + + updateOptionsById(_id: ISetting['_id'], options: UpdateQuery['$set']): Promise { + const query = { + blocked: { $ne: true }, + _id, + }; + + const update = { $set: options }; + + return this.update(query, update); + } + + updateValueNotHiddenById(_id: ISetting['_id'], value: T): Promise { + const query = { + _id, + hidden: { $ne: true }, + blocked: { $ne: true }, + }; + + const update = { + $set: { + value, + }, + }; + + return this.update(query, update); + } + + updateValueAndEditorById(_id: ISetting['_id'], value: T, editor: ISettingColor['editor']): Promise { const query = { blocked: { $ne: true }, value: { $ne: value }, @@ -45,9 +99,58 @@ export class SettingsRaw extends BaseRaw { const update = { $set: { value, + editor, }, }; return this.update(query, update); } + + findNotHiddenPublic(ids: ISetting['_id'][] = []): Cursor< T extends ISettingColor ? Pick : Pick> { + const filter: FilterQuery = { + hidden: { $ne: true }, + public: true, + }; + + if (ids.length > 0) { + filter._id = { $in: ids }; + } + + return this.find(filter, { projection: { _id: 1, value: 1, editor: 1, enterprise: 1, invalidValue: 1, modules: 1, requiredOnWizard: 1 } }); + } + + findSetupWizardSettings(): Cursor { + return this.find({ wizard: { $exists: true } }); + } + + addOptionValueById(_id: ISetting['_id'], option: ISettingSelectOption): Promise { + const query = { + blocked: { $ne: true }, + _id, + }; + + const { key, i18nLabel } = option; + const update = { + $addToSet: { + values: { + key, + i18nLabel, + }, + }, + }; + + return this.update(query, update); + } + + findNotHiddenPublicUpdatedAfter(updatedAt: Date): Cursor { + const filter = { + hidden: { $ne: true }, + public: true, + _updatedAt: { + $gt: updatedAt, + }, + }; + + return this.find(filter, { projection: { _id: 1, value: 1, editor: 1, enterprise: 1, invalidValue: 1, modules: 1, requiredOnWizard: 1 } }); + } } diff --git a/app/models/server/raw/SmarshHistory.ts b/app/models/server/raw/SmarshHistory.ts new file mode 100644 index 0000000000000..70c2e3df482d7 --- /dev/null +++ b/app/models/server/raw/SmarshHistory.ts @@ -0,0 +1,8 @@ +import { BaseRaw } from './BaseRaw'; +import { ISmarshHistory } from '../../../../definition/ISmarshHistory'; + +type T = ISmarshHistory; + +export class SmarshHistoryRaw extends BaseRaw { + +} diff --git a/app/models/server/raw/Statistics.js b/app/models/server/raw/Statistics.js deleted file mode 100644 index 15b3cf39404a0..0000000000000 --- a/app/models/server/raw/Statistics.js +++ /dev/null @@ -1,14 +0,0 @@ -import { BaseRaw } from './BaseRaw'; - -export class StatisticsRaw extends BaseRaw { - async findLast() { - const options = { - sort: { - createdAt: -1, - }, - limit: 1, - }; - const records = await this.find({}, options).toArray(); - return records && records[0]; - } -} diff --git a/app/models/server/raw/Statistics.ts b/app/models/server/raw/Statistics.ts new file mode 100644 index 0000000000000..b3b915a9ebcea --- /dev/null +++ b/app/models/server/raw/Statistics.ts @@ -0,0 +1,21 @@ +import { BaseRaw, IndexSpecification } from './BaseRaw'; +import { IStatistic } from '../../../../definition/IStatistic'; + +type T = IStatistic; + +export class StatisticsRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { createdAt: -1 } }, + ] + + async findLast(): Promise { + const options = { + sort: { + createdAt: -1, + }, + limit: 1, + }; + const records = await this.find({}, options).toArray(); + return records && records[0]; + } +} diff --git a/app/models/server/raw/Subscriptions.ts b/app/models/server/raw/Subscriptions.ts index 544af563af91a..2877c147312e1 100644 --- a/app/models/server/raw/Subscriptions.ts +++ b/app/models/server/raw/Subscriptions.ts @@ -1,10 +1,20 @@ -import { FindOneOptions, Cursor, UpdateQuery, FilterQuery } from 'mongodb'; +import { FindOneOptions, Cursor, UpdateQuery, FilterQuery, UpdateWriteOpResult, Collection, WithoutProjection } from 'mongodb'; +import { compact } from 'lodash'; import { BaseRaw } from './BaseRaw'; import { ISubscription } from '../../../../definition/ISubscription'; +import { IRole, IUser } from '../../../../definition/IUser'; +import { IRoom } from '../../../../definition/IRoom'; +import { UsersRaw } from './Users'; type T = ISubscription; export class SubscriptionsRaw extends BaseRaw { + constructor(public readonly col: Collection, + private readonly models: { Users: UsersRaw }, + trash?: Collection) { + super(col, trash); + } + findOneByRoomIdAndUserId(rid: string, uid: string, options: FindOneOptions = {}): Promise { const query = { rid, @@ -36,7 +46,18 @@ export class SubscriptionsRaw extends BaseRaw { return this.find(query, options); } - countByRoomIdAndUserId(rid: string, uid: string): Promise { + findByLivechatRoomIdAndNotUserId(roomId: string, userId: string, options: FindOneOptions = {}): Cursor { + const query = { + rid: roomId, + 'servedBy._id': { + $ne: userId, + }, + }; + + return this.find(query, options); + } + + countByRoomIdAndUserId(rid: string, uid: string | undefined): Promise { const query = { rid, 'u._id': uid, @@ -47,7 +68,7 @@ export class SubscriptionsRaw extends BaseRaw { return cursor.count(); } - async isUserInRole(uid: string, roleName: string, rid: string): Promise { + async isUserInRole(uid: IUser['_id'], roleName: IRole['name'], rid?: IRoom['_id']): Promise { if (rid == null) { return null; } @@ -80,4 +101,77 @@ export class SubscriptionsRaw extends BaseRaw { return this.update(query, update, options); } + + removeRolesByUserId(uid: IUser['_id'], roles: IRole['name'][], rid: IRoom['_id']): Promise { + const query = { + 'u._id': uid, + rid, + }; + + const update = { + $pullAll: { + roles, + }, + }; + + return this.updateOne(query, update); + } + + + findUsersInRoles(name: IRole['name'][], rid: string | undefined): Promise>; + + findUsersInRoles(name: IRole['name'][], rid: string | undefined, options: WithoutProjection>): Promise>; + + findUsersInRoles

(name: IRole['name'][], rid: string | undefined, options: FindOneOptions

): Promise>; + + async findUsersInRoles

(roles: IRole['name'][], rid: IRoom['_id'] | undefined, options?: FindOneOptions

): Promise> { + const query = { + roles: { $in: roles }, + ...rid && { rid }, + }; + + const subscriptions = await this.find(query).toArray(); + + const users = compact(subscriptions.map((subscription) => subscription.u?._id).filter(Boolean)); + + return !options ? this.models.Users.find({ _id: { $in: users } }) : this.models.Users.find({ _id: { $in: users } } as FilterQuery, options); + } + + + addRolesByUserId(uid: IUser['_id'], roles: IRole['name'][], rid?: IRoom['_id']): Promise { + if (!Array.isArray(roles)) { + roles = [roles]; + process.env.NODE_ENV === 'development' && console.warn('[WARN] Subscriptions.addRolesByUserId: roles should be an array'); + } + + const query = { + 'u._id': uid, + rid, + }; + + const update = { + $addToSet: { + roles: { $each: roles }, + }, + }; + + return this.updateOne(query, update); + } + + async isUserInRoleScope(uid: IUser['_id'], rid?: IRoom['_id']): Promise { + const query = { + 'u._id': uid, + rid, + }; + + if (!rid) { + return false; + } + const options = { + fields: { _id: 1 }, + }; + + const found = await this.findOne(query, options); + return !!found; + } } diff --git a/app/models/server/raw/Team.ts b/app/models/server/raw/Team.ts index 8c8d51b724fc5..f8d1874797cb0 100644 --- a/app/models/server/raw/Team.ts +++ b/app/models/server/raw/Team.ts @@ -6,7 +6,7 @@ import { ITeam, TEAM_TYPE } from '../../../../definition/ITeam'; export class TeamRaw extends BaseRaw { constructor( public readonly col: Collection, - public readonly trash?: Collection, + trash?: Collection, ) { super(col, trash); diff --git a/app/models/server/raw/TeamMember.ts b/app/models/server/raw/TeamMember.ts index e411980a8627b..c65fcea6e533e 100644 --- a/app/models/server/raw/TeamMember.ts +++ b/app/models/server/raw/TeamMember.ts @@ -8,7 +8,7 @@ type T = ITeamMember; export class TeamMemberRaw extends BaseRaw { constructor( public readonly col: Collection, - public readonly trash?: Collection, + trash?: Collection, ) { super(col, trash); diff --git a/app/models/server/raw/Uploads.ts b/app/models/server/raw/Uploads.ts new file mode 100644 index 0000000000000..ad2fd67247c91 --- /dev/null +++ b/app/models/server/raw/Uploads.ts @@ -0,0 +1,116 @@ +// TODO: Lib imports should not exists inside the raw models +import { escapeRegExp } from '@rocket.chat/string-helpers'; +import { CollectionInsertOneOptions, Cursor, DeleteWriteOpResultObject, FilterQuery, InsertOneWriteOpResult, UpdateOneOptions, UpdateQuery, UpdateWriteOpResult, WithId, WriteOpResult } from 'mongodb'; + +import { BaseRaw, IndexSpecification, InsertionModel } from './BaseRaw'; +import { IUpload as T } from '../../../../definition/IUpload'; + +const fillTypeGroup = (fileData: Partial): void => { + if (!fileData.type) { + return; + } + + fileData.typeGroup = fileData.type.split('/').shift(); +}; + +export class UploadsRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { rid: 1 } }, + { key: { uploadedAt: 1 } }, + { key: { typeGroup: 1 } }, + ] + + findNotHiddenFilesOfRoom(roomId: string, searchText: string, fileType: string, limit: number): Cursor { + const fileQuery = { + rid: roomId, + complete: true, + uploading: false, + _hidden: { + $ne: true, + }, + + ...searchText && { name: { $regex: new RegExp(escapeRegExp(searchText), 'i') } }, + ...fileType && fileType !== 'all' && { typeGroup: fileType }, + }; + + const fileOptions = { + limit, + sort: { + uploadedAt: -1, + }, + projection: { + _id: 1, + userId: 1, + rid: 1, + name: 1, + description: 1, + type: 1, + url: 1, + uploadedAt: 1, + typeGroup: 1, + }, + }; + + return this.find(fileQuery, fileOptions); + } + + insert(fileData: InsertionModel, options?: CollectionInsertOneOptions): Promise>> { + fillTypeGroup(fileData); + return super.insertOne(fileData, options); + } + + update(filter: FilterQuery, update: UpdateQuery | Partial, options?: UpdateOneOptions & { multi?: boolean }): Promise { + if ('$set' in update && update.$set) { + fillTypeGroup(update.$set); + } else if ('type' in update && update.type) { + fillTypeGroup(update); + } + + return super.update(filter, update, options); + } + + async insertFileInit(userId: string, store: string, file: {name: string}, extra: object): Promise>> { + const fileData = { + userId, + store, + complete: false, + uploading: true, + progress: 0, + extension: file.name.split('.').pop(), + uploadedAt: new Date(), + ...file, + ...extra, + }; + + fillTypeGroup(fileData); + return this.insert(fileData); + } + + async updateFileComplete(fileId: string, userId: string, file: object): Promise { + if (!fileId) { + return; + } + + const filter = { + _id: fileId, + userId, + }; + + const update = { + $set: { + complete: true, + uploading: false, + progress: 1, + }, + }; + + update.$set = Object.assign(file, update.$set); + + fillTypeGroup(update.$set); + return this.updateOne(filter, update); + } + + async deleteFile(fileId: string): Promise { + return this.deleteOne({ _id: fileId }); + } +} diff --git a/app/models/server/raw/UserDataFiles.ts b/app/models/server/raw/UserDataFiles.ts new file mode 100644 index 0000000000000..684135c9d57a3 --- /dev/null +++ b/app/models/server/raw/UserDataFiles.ts @@ -0,0 +1,29 @@ +import { FindOneOptions, InsertOneWriteOpResult, WithId, WithoutProjection } from 'mongodb'; + +import { BaseRaw, IndexSpecification } from './BaseRaw'; +import { IUserDataFile as T } from '../../../../definition/IUserDataFile'; + +export class UserDataFilesRaw extends BaseRaw { + protected indexes: IndexSpecification[] = [ + { key: { userId: 1 } }, + ] + + findLastFileByUser(userId: string, options: WithoutProjection> = {}): Promise { + const query = { + userId, + }; + + options.sort = { _updatedAt: -1 }; + return this.findOne(query, options); + } + + // INSERT + create(data: T): Promise>> { + const userDataFile = { + createdAt: new Date(), + ...data, + }; + + return this.insertOne(userDataFile); + } +} diff --git a/app/models/server/raw/Users.js b/app/models/server/raw/Users.js index a021a91f25e62..dc2cab7b6232b 100644 --- a/app/models/server/raw/Users.js +++ b/app/models/server/raw/Users.js @@ -11,6 +11,24 @@ export class UsersRaw extends BaseRaw { }; } + addRolesByUserId(uid, roles) { + if (!Array.isArray(roles)) { + roles = [roles]; + process.env.NODE_ENV === 'development' && console.warn('[WARN] Users.addRolesByUserId: roles should be an array'); + } + + const query = { + _id: uid, + }; + + const update = { + $addToSet: { + roles: { $each: roles }, + }, + }; + return this.updateOne(query, update); + } + findUsersInRoles(roles, scope, options) { roles = [].concat(roles); @@ -259,14 +277,16 @@ export class UsersRaw extends BaseRaw { return result.value; } - setLivechatStatus(userId, status) { // TODO: Create class Agent + setLivechatStatusIf(userId, status, conditions = {}, extraFields = {}) { // TODO: Create class Agent const query = { _id: userId, + ...conditions, }; const update = { $set: { statusLivechat: status, + ...extraFields, }, }; @@ -704,4 +724,48 @@ export class UsersRaw extends BaseRaw { $pullAll: { __rooms: rids }, }, { multi: true }); } + + removeRolesByUserId(uid, roles) { + const query = { + _id: uid, + }; + + const update = { + $pullAll: { + roles, + }, + }; + + return this.updateOne(query, update); + } + + async isUserInRoleScope(uid) { + const query = { + _id: uid, + }; + + const options = { + fields: { _id: 1 }, + }; + + const found = await this.findOne(query, options); + return !!found; + } + + addBannerById(_id, banner) { + const query = { + _id, + [`banners.${ banner.id }.read`]: { + $ne: true, + }, + }; + + const update = { + $set: { + [`banners.${ banner.id }`]: banner, + }, + }; + + return this.updateOne(query, update); + } } diff --git a/app/models/server/raw/UsersSessions.ts b/app/models/server/raw/UsersSessions.ts index b89feaa9deb3f..3560f1e175d1a 100644 --- a/app/models/server/raw/UsersSessions.ts +++ b/app/models/server/raw/UsersSessions.ts @@ -1,4 +1,16 @@ import { BaseRaw } from './BaseRaw'; import { IUserSession } from '../../../../definition/IUserSession'; -export class UsersSessionsRaw extends BaseRaw {} +export class UsersSessionsRaw extends BaseRaw { + clearConnectionsFromInstanceId(instanceId: string[]): ReturnType['updateMany']> { + return this.col.updateMany({}, { + $pull: { + connections: { + instanceId: { + $nin: instanceId, + }, + }, + }, + }); + } +} diff --git a/app/models/server/raw/WebdavAccounts.js b/app/models/server/raw/WebdavAccounts.js deleted file mode 100644 index bcd87761c2674..0000000000000 --- a/app/models/server/raw/WebdavAccounts.js +++ /dev/null @@ -1,8 +0,0 @@ -import { BaseRaw } from './BaseRaw'; - -export class WebdavAccountsRaw extends BaseRaw { - findWithUserId(user_id, options) { - const query = { user_id }; - return this.find(query, options); - } -} diff --git a/app/models/server/raw/WebdavAccounts.ts b/app/models/server/raw/WebdavAccounts.ts new file mode 100644 index 0000000000000..1a7fea7114e6f --- /dev/null +++ b/app/models/server/raw/WebdavAccounts.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/camelcase */ +/** + * Webdav Accounts model + */ +import type { Collection, FindOneOptions, Cursor, DeleteWriteOpResultObject } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; +import { IWebdavAccount } from '../../../../definition/IWebdavAccount'; + +type T = IWebdavAccount; + +export class WebdavAccountsRaw extends BaseRaw { + constructor( + public readonly col: Collection, + trash?: Collection, + ) { + super(col, trash); + + this.col.createIndex({ user_id: 1 }); + } + + findOneByIdAndUserId(_id: string, user_id: string, options: FindOneOptions): Promise { + return this.findOne({ _id, user_id }, options); + } + + findOneByUserIdServerUrlAndUsername({ + user_id, + server_url, + username, + }: { + user_id: string; + server_url: string; + username: string; + }, options: FindOneOptions): Promise { + return this.findOne({ user_id, server_url, username }, options); + } + + findWithUserId(user_id: string, options: FindOneOptions): Cursor { + const query = { user_id }; + return this.find(query, options); + } + + removeByUserAndId(_id: string, user_id: string): Promise { + return this.deleteOne({ _id, user_id }); + } +} diff --git a/app/models/server/raw/_Users.d.ts b/app/models/server/raw/_Users.d.ts new file mode 100644 index 0000000000000..891392ee3f7e4 --- /dev/null +++ b/app/models/server/raw/_Users.d.ts @@ -0,0 +1,14 @@ +import { UpdateWriteOpResult } from 'mongodb'; + +import { IRole, IUser } from '../../../../definition/IUser'; +import { BaseRaw } from './BaseRaw'; + +export interface IUserRaw extends BaseRaw { + isUserInRole(uid: IUser['_id'], name: IRole['name']): Promise; + removeRolesByUserId(uid: IUser['_id'], roles: IRole['name'][]): Promise; + findUsersInRoles(roles: IRole['name'][]): Promise; + addRolesByUserId(uid: IUser['_id'], roles: IRole['name'][]): Promise; + isUserInRoleScope(uid: IUser['_id']): Promise; + new(...args: any): IUser; +} +export const UsersRaw: IUserRaw; diff --git a/app/models/server/raw/index.ts b/app/models/server/raw/index.ts index 605218c636bea..34789c62ce2fe 100644 --- a/app/models/server/raw/index.ts +++ b/app/models/server/raw/index.ts @@ -1,83 +1,81 @@ -import PermissionsModel from '../models/Permissions'; +import { MongoInternals } from 'meteor/mongo'; + +import { AvatarsRaw } from './Avatars'; +import { AnalyticsRaw } from './Analytics'; +import { api } from '../../../../server/sdk/api'; +import { BaseDbWatch, trash } from '../models/_BaseDb'; +import { CredentialTokensRaw } from './CredentialTokens'; +import { CustomSoundsRaw } from './CustomSounds'; +import { CustomUserStatusRaw } from './CustomUserStatus'; +import { EmailInboxRaw } from './EmailInbox'; +import { EmailMessageHistoryRaw } from './EmailMessageHistory'; +import { EmojiCustomRaw } from './EmojiCustom'; +import { ExportOperationsRaw } from './ExportOperations'; +import { FederationKeysRaw } from './FederationKeys'; +import { FederationServersRaw } from './FederationServers'; +import { ImportDataRaw } from './ImportData'; +import { initWatchers } from '../../../../server/modules/watchers/watchers.module'; +import { InstanceStatusRaw } from './InstanceStatus'; +import { IntegrationHistoryRaw } from './IntegrationHistory'; +import { IntegrationsRaw } from './Integrations'; +import { InvitesRaw } from './Invites'; +import { LivechatAgentActivityRaw } from './LivechatAgentActivity'; +import { LivechatBusinessHoursRaw } from './LivechatBusinessHours'; +import { LivechatCustomFieldRaw } from './LivechatCustomField'; +import { LivechatDepartmentAgentsRaw } from './LivechatDepartmentAgents'; +import { LivechatDepartmentRaw } from './LivechatDepartment'; +import { LivechatExternalMessageRaw } from './LivechatExternalMessages'; +import { LivechatInquiryRaw } from './LivechatInquiry'; +import { LivechatRoomsRaw } from './LivechatRooms'; +import { LivechatTriggerRaw } from './LivechatTrigger'; +import { LivechatVisitorsRaw } from './LivechatVisitors'; +import { LoginServiceConfigurationRaw } from './LoginServiceConfiguration'; +import { MessagesRaw } from './Messages'; +import { NotificationQueueRaw } from './NotificationQueue'; +import { OAuthAppsRaw } from './OAuthApps'; +import { OEmbedCacheRaw } from './OEmbedCache'; +import { OmnichannelQueueRaw } from './OmnichannelQueue'; import { PermissionsRaw } from './Permissions'; -import RolesModel from '../models/Roles'; +import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; +import { ReadReceiptsRaw } from './ReadReceipts'; +import { ReportsRaw } from './Reports'; import { RolesRaw } from './Roles'; -import SubscriptionsModel from '../models/Subscriptions'; -import { SubscriptionsRaw } from './Subscriptions'; -import SettingsModel from '../models/Settings'; +import { RoomsRaw } from './Rooms'; +import { ServerEventsRaw } from './ServerEvents'; +import { SessionsRaw } from './Sessions'; import { SettingsRaw } from './Settings'; -import UsersModel from '../models/Users'; +import { SmarshHistoryRaw } from './SmarshHistory'; +import { StatisticsRaw } from './Statistics'; +import { SubscriptionsRaw } from './Subscriptions'; import { UsersRaw } from './Users'; -import SessionsModel from '../models/Sessions'; -import { SessionsRaw } from './Sessions'; -import RoomsModel from '../models/Rooms'; -import { RoomsRaw } from './Rooms'; +import { UsersSessionsRaw } from './UsersSessions'; +import { UserDataFilesRaw } from './UserDataFiles'; +import { UploadsRaw } from './Uploads'; +import { WebdavAccountsRaw } from './WebdavAccounts'; +import ImportDataModel from '../models/ImportData'; +import LivechatAgentActivityModel from '../models/LivechatAgentActivity'; +import LivechatBusinessHoursModel from '../models/LivechatBusinessHours'; import LivechatCustomFieldModel from '../models/LivechatCustomField'; -import { LivechatCustomFieldRaw } from './LivechatCustomField'; -import LivechatTriggerModel from '../models/LivechatTrigger'; -import { LivechatTriggerRaw } from './LivechatTrigger'; -import LivechatDepartmentModel from '../models/LivechatDepartment'; -import { LivechatDepartmentRaw } from './LivechatDepartment'; import LivechatDepartmentAgentsModel from '../models/LivechatDepartmentAgents'; -import { LivechatDepartmentAgentsRaw } from './LivechatDepartmentAgents'; -import LivechatRoomsModel from '../models/LivechatRooms'; -import { LivechatRoomsRaw } from './LivechatRooms'; -import MessagesModel from '../models/Messages'; -import { MessagesRaw } from './Messages'; +import LivechatDepartmentModel from '../models/LivechatDepartment'; import LivechatExternalMessagesModel from '../models/LivechatExternalMessages'; -import { LivechatExternalMessageRaw } from './LivechatExternalMessages'; -import LivechatVisitorsModel from '../models/LivechatVisitors'; -import { LivechatVisitorsRaw } from './LivechatVisitors'; import LivechatInquiryModel from '../models/LivechatInquiry'; -import { LivechatInquiryRaw } from './LivechatInquiry'; -import IntegrationsModel from '../models/Integrations'; -import { IntegrationsRaw } from './Integrations'; -import EmojiCustomModel from '../models/EmojiCustom'; -import { EmojiCustomRaw } from './EmojiCustom'; -import WebdavAccountsModel from '../models/WebdavAccounts'; -import { WebdavAccountsRaw } from './WebdavAccounts'; -import OAuthAppsModel from '../models/OAuthApps'; -import { OAuthAppsRaw } from './OAuthApps'; -import CustomSoundsModel from '../models/CustomSounds'; -import { CustomSoundsRaw } from './CustomSounds'; -import CustomUserStatusModel from '../models/CustomUserStatus'; -import { CustomUserStatusRaw } from './CustomUserStatus'; -import LivechatAgentActivityModel from '../models/LivechatAgentActivity'; -import { LivechatAgentActivityRaw } from './LivechatAgentActivity'; -import StatisticsModel from '../models/Statistics'; -import { StatisticsRaw } from './Statistics'; -import NotificationQueueModel from '../models/NotificationQueue'; -import { NotificationQueueRaw } from './NotificationQueue'; -import LivechatBusinessHoursModel from '../models/LivechatBusinessHours'; -import { LivechatBusinessHoursRaw } from './LivechatBusinessHours'; -import ServerEventModel from '../models/ServerEvents'; -import { UsersSessionsRaw } from './UsersSessions'; -import UsersSessionsModel from '../models/UsersSessions'; -import { ServerEventsRaw } from './ServerEvents'; -import { trash } from '../models/_BaseDb'; +import LivechatRoomsModel from '../models/LivechatRooms'; +import LivechatTriggerModel from '../models/LivechatTrigger'; +import LivechatVisitorsModel from '../models/LivechatVisitors'; import LoginServiceConfigurationModel from '../models/LoginServiceConfiguration'; -import { LoginServiceConfigurationRaw } from './LoginServiceConfiguration'; -import { InstanceStatusRaw } from './InstanceStatus'; -import InstanceStatusModel from '../models/InstanceStatus'; -import { IntegrationHistoryRaw } from './IntegrationHistory'; -import IntegrationHistoryModel from '../models/IntegrationHistory'; +import MessagesModel from '../models/Messages'; import OmnichannelQueueModel from '../models/OmnichannelQueue'; -import { OmnichannelQueueRaw } from './OmnichannelQueue'; -import EmailInboxModel from '../models/EmailInbox'; -import { EmailInboxRaw } from './EmailInbox'; -import EmailMessageHistoryModel from '../models/EmailMessageHistory'; -import { EmailMessageHistoryRaw } from './EmailMessageHistory'; -import { api } from '../../../../server/sdk/api'; -import { initWatchers } from '../../../../server/modules/watchers/watchers.module'; -import ImportDataModel from '../models/ImportData'; -import { ImportDataRaw } from './ImportData'; +import RoomsModel from '../models/Rooms'; +import SettingsModel from '../models/Settings'; +import SubscriptionsModel from '../models/Subscriptions'; +import UsersModel from '../models/Users'; const trashCollection = trash.rawCollection(); -export const Permissions = new PermissionsRaw(PermissionsModel.model.rawCollection(), trashCollection); -export const Subscriptions = new SubscriptionsRaw(SubscriptionsModel.model.rawCollection(), trashCollection); -export const Settings = new SettingsRaw(SettingsModel.model.rawCollection(), trashCollection); export const Users = new UsersRaw(UsersModel.model.rawCollection(), trashCollection); +export const Subscriptions = new SubscriptionsRaw(SubscriptionsModel.model.rawCollection(), { Users }, trashCollection); +export const Settings = new SettingsRaw(SettingsModel.model.rawCollection(), trashCollection); export const Rooms = new RoomsRaw(RoomsModel.model.rawCollection(), trashCollection); export const LivechatCustomField = new LivechatCustomFieldRaw(LivechatCustomFieldModel.model.rawCollection(), trashCollection); export const LivechatTrigger = new LivechatTriggerRaw(LivechatTriggerModel.model.rawCollection(), trashCollection); @@ -88,44 +86,56 @@ export const Messages = new MessagesRaw(MessagesModel.model.rawCollection(), tra export const LivechatExternalMessage = new LivechatExternalMessageRaw(LivechatExternalMessagesModel.model.rawCollection(), trashCollection); export const LivechatVisitors = new LivechatVisitorsRaw(LivechatVisitorsModel.model.rawCollection(), trashCollection); export const LivechatInquiry = new LivechatInquiryRaw(LivechatInquiryModel.model.rawCollection(), trashCollection); -export const Integrations = new IntegrationsRaw(IntegrationsModel.model.rawCollection(), trashCollection); -export const EmojiCustom = new EmojiCustomRaw(EmojiCustomModel.model.rawCollection(), trashCollection); -export const WebdavAccounts = new WebdavAccountsRaw(WebdavAccountsModel.model.rawCollection(), trashCollection); -export const OAuthApps = new OAuthAppsRaw(OAuthAppsModel.model.rawCollection(), trashCollection); -export const CustomSounds = new CustomSoundsRaw(CustomSoundsModel.model.rawCollection(), trashCollection); -export const CustomUserStatus = new CustomUserStatusRaw(CustomUserStatusModel.model.rawCollection(), trashCollection); export const LivechatAgentActivity = new LivechatAgentActivityRaw(LivechatAgentActivityModel.model.rawCollection(), trashCollection); -export const Statistics = new StatisticsRaw(StatisticsModel.model.rawCollection(), trashCollection); -export const NotificationQueue = new NotificationQueueRaw(NotificationQueueModel.model.rawCollection(), trashCollection); export const LivechatBusinessHours = new LivechatBusinessHoursRaw(LivechatBusinessHoursModel.model.rawCollection(), trashCollection); -export const ServerEvents = new ServerEventsRaw(ServerEventModel.model.rawCollection(), trashCollection); -export const Roles = new RolesRaw(RolesModel.model.rawCollection(), trashCollection, { Users, Subscriptions }); -export const UsersSessions = new UsersSessionsRaw(UsersSessionsModel.model.rawCollection(), trashCollection); +// export const Roles = new RolesRaw(RolesModel.model.rawCollection(), { Users, Subscriptions }, trashCollection); export const LoginServiceConfiguration = new LoginServiceConfigurationRaw(LoginServiceConfigurationModel.model.rawCollection(), trashCollection); -export const InstanceStatus = new InstanceStatusRaw(InstanceStatusModel.model.rawCollection(), trashCollection); -export const IntegrationHistory = new IntegrationHistoryRaw(IntegrationHistoryModel.model.rawCollection(), trashCollection); -export const Sessions = new SessionsRaw(SessionsModel.model.rawCollection(), trashCollection); export const OmnichannelQueue = new OmnichannelQueueRaw(OmnichannelQueueModel.model.rawCollection(), trashCollection); -export const EmailInbox = new EmailInboxRaw(EmailInboxModel.model.rawCollection(), trashCollection); -export const EmailMessageHistory = new EmailMessageHistoryRaw(EmailMessageHistoryModel.model.rawCollection(), trashCollection); export const ImportData = new ImportDataRaw(ImportDataModel.model.rawCollection(), trashCollection); +const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; +const prefix = 'rocketchat_'; + +export const Avatars = new AvatarsRaw(db.collection(`${ prefix }avatars`), trashCollection); +export const Analytics = new AnalyticsRaw(db.collection(`${ prefix }analytics`, { readPreference: readSecondaryPreferred(db) }), trashCollection); +export const CustomSounds = new CustomSoundsRaw(db.collection(`${ prefix }custom_sounds`), trashCollection); +export const CustomUserStatus = new CustomUserStatusRaw(db.collection(`${ prefix }custom_user_status`), trashCollection); +export const CredentialTokens = new CredentialTokensRaw(db.collection(`${ prefix }credential_tokens`), trashCollection); +export const EmailInbox = new EmailInboxRaw(db.collection(`${ prefix }email_inbox`), trashCollection); +export const EmailMessageHistory = new EmailMessageHistoryRaw(db.collection(`${ prefix }email_message_history`), trashCollection); +export const EmojiCustom = new EmojiCustomRaw(db.collection(`${ prefix }custom_emoji`), trashCollection); +export const ExportOperations = new ExportOperationsRaw(db.collection(`${ prefix }export_operations`), trashCollection); +export const FederationKeys = new FederationKeysRaw(db.collection(`${ prefix }federation_keys`), trashCollection); +export const FederationServers = new FederationServersRaw(db.collection(`${ prefix }federation_servers`), trashCollection); +export const InstanceStatus = new InstanceStatusRaw(db.collection('instances'), trashCollection, { preventSetUpdatedAt: true }); +export const Integrations = new IntegrationsRaw(db.collection(`${ prefix }integrations`), trashCollection); +export const IntegrationHistory = new IntegrationHistoryRaw(db.collection(`${ prefix }integration_history`), trashCollection); +export const Invites = new InvitesRaw(db.collection(`${ prefix }invites`), trashCollection); +export const NotificationQueue = new NotificationQueueRaw(db.collection(`${ prefix }notification_queue`), trashCollection); +export const OAuthApps = new OAuthAppsRaw(db.collection(`${ prefix }oauth_apps`), trashCollection); +export const OEmbedCache = new OEmbedCacheRaw(db.collection(`${ prefix }oembed_cache`), trashCollection); +export const Permissions = new PermissionsRaw(db.collection(`${ prefix }permissions`), trashCollection); +export const ReadReceipts = new ReadReceiptsRaw(db.collection(`${ prefix }read_receipts`), trashCollection); +export const Reports = new ReportsRaw(db.collection(`${ prefix }reports`), trashCollection); +export const ServerEvents = new ServerEventsRaw(db.collection(`${ prefix }server_events`), trashCollection); +export const Sessions = new SessionsRaw(db.collection(`${ prefix }sessions`), db.collection(`${ prefix }sessions`, { readPreference: readSecondaryPreferred(db) }), trashCollection); +export const Roles = new RolesRaw(db.collection(`${ prefix }roles`), { Users, Subscriptions }, trashCollection); +export const SmarshHistory = new SmarshHistoryRaw(db.collection(`${ prefix }smarsh_history`), trashCollection); +export const Statistics = new StatisticsRaw(db.collection(`${ prefix }statistics`), trashCollection); +export const UsersSessions = new UsersSessionsRaw(db.collection('usersSessions'), trashCollection, { preventSetUpdatedAt: true }); +export const UserDataFiles = new UserDataFilesRaw(db.collection(`${ prefix }user_data_files`), trashCollection); +export const Uploads = new UploadsRaw(db.collection(`${ prefix }uploads`), trashCollection); +export const WebdavAccounts = new WebdavAccountsRaw(db.collection(`${ prefix }webdav_accounts`), trashCollection); + const map = { [Messages.col.collectionName]: MessagesModel, [Users.col.collectionName]: UsersModel, [Subscriptions.col.collectionName]: SubscriptionsModel, [Settings.col.collectionName]: SettingsModel, - [Roles.col.collectionName]: RolesModel, - [Permissions.col.collectionName]: PermissionsModel, [LivechatInquiry.col.collectionName]: LivechatInquiryModel, [LivechatDepartmentAgents.col.collectionName]: LivechatDepartmentAgentsModel, - [UsersSessions.col.collectionName]: UsersSessionsModel, [Rooms.col.collectionName]: RoomsModel, [LoginServiceConfiguration.col.collectionName]: LoginServiceConfigurationModel, - [InstanceStatus.col.collectionName]: InstanceStatusModel, - [IntegrationHistory.col.collectionName]: IntegrationHistoryModel, - [Integrations.col.collectionName]: IntegrationsModel, - [EmailInbox.col.collectionName]: EmailInboxModel, }; if (!process.env.DISABLE_DB_WATCH) { @@ -148,7 +158,7 @@ if (!process.env.DISABLE_DB_WATCH) { }; initWatchers(models, api.broadcastLocal.bind(api), (model, fn) => { - const meteorModel = map[model.col.collectionName]; + const meteorModel = map[model.col.collectionName] || new BaseDbWatch(model.col.collectionName); if (!meteorModel) { return; } diff --git a/app/notifications/client/lib/Notifications.js b/app/notifications/client/lib/Notifications.js index 15cd4374fe96b..bf601bb7878d3 100644 --- a/app/notifications/client/lib/Notifications.js +++ b/app/notifications/client/lib/Notifications.js @@ -78,9 +78,9 @@ class Notifications { return this.streamRoom.on(`${ room }/${ eventName }`, callback); } - async onUser(eventName, callback) { - await this.streamUser.on(`${ Meteor.userId() }/${ eventName }`, callback); - return () => this.unUser(eventName, callback); + async onUser(eventName, callback, visitorId = null) { + await this.streamUser.on(`${ Meteor.userId() || visitorId }/${ eventName }`, callback); + return () => this.unUser(eventName, callback, visitorId); } unAll(callback) { @@ -95,8 +95,8 @@ class Notifications { return this.streamRoom.removeListener(`${ room }/${ eventName }`, callback); } - unUser(eventName, callback) { - return this.streamUser.removeListener(`${ Meteor.userId() }/${ eventName }`, callback); + unUser(eventName, callback, visitorId = null) { + return this.streamUser.removeListener(`${ Meteor.userId() || visitorId }/${ eventName }`, callback); } } diff --git a/app/notifications/server/lib/Notifications.ts b/app/notifications/server/lib/Notifications.ts index 6653cd98c8299..214d642d5f417 100644 --- a/app/notifications/server/lib/Notifications.ts +++ b/app/notifications/server/lib/Notifications.ts @@ -1,5 +1,4 @@ import { Meteor } from 'meteor/meteor'; -import { Promise } from 'meteor/promise'; import { DDPCommon } from 'meteor/ddp-common'; import { NotificationsModule } from '../../../../server/modules/notifications/notifications.module'; diff --git a/app/notifications/server/lib/Presence.ts b/app/notifications/server/lib/Presence.ts index 549c06af110e6..19ccba63461a0 100644 --- a/app/notifications/server/lib/Presence.ts +++ b/app/notifications/server/lib/Presence.ts @@ -1,7 +1,7 @@ import { Emitter } from '@rocket.chat/emitter'; +import type { IPublication, IStreamerConstructor, Connection, IStreamer } from 'meteor/rocketchat:streamer'; -import { IUser } from '../../../../definition/IUser'; -import { IPublication, IStreamerConstructor, Connection, IStreamer } from '../../../../server/modules/streamer/streamer.module'; +import type { IUser } from '../../../../definition/IUser'; export type UserPresenseStreamProps = { added: IUser['_id'][]; diff --git a/app/oauth2-server-config/server/admin/methods/addOAuthApp.js b/app/oauth2-server-config/server/admin/methods/addOAuthApp.js index cb4d73e19f27c..688409e5ddf4c 100644 --- a/app/oauth2-server-config/server/admin/methods/addOAuthApp.js +++ b/app/oauth2-server-config/server/admin/methods/addOAuthApp.js @@ -3,11 +3,12 @@ import { Random } from 'meteor/random'; import _ from 'underscore'; import { hasPermission } from '../../../../authorization'; -import { Users, OAuthApps } from '../../../../models'; +import { Users } from '../../../../models/server'; +import { OAuthApps } from '../../../../models/server/raw'; import { parseUriList } from '../functions/parseUriList'; Meteor.methods({ - addOAuthApp(application) { + async addOAuthApp(application) { if (!hasPermission(this.userId, 'manage-oauth-apps')) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'addOAuthApp' }); } @@ -31,7 +32,7 @@ Meteor.methods({ application.clientSecret = Random.secret(); application._createdAt = new Date(); application._createdBy = Users.findOne(this.userId, { fields: { username: 1 } }); - application._id = OAuthApps.insert(application); + application._id = (await OAuthApps.insertOne(application)).insertedId; return application; }, }); diff --git a/app/oauth2-server-config/server/admin/methods/deleteOAuthApp.js b/app/oauth2-server-config/server/admin/methods/deleteOAuthApp.js index 6c0b1e665de64..d1df82d95704a 100644 --- a/app/oauth2-server-config/server/admin/methods/deleteOAuthApp.js +++ b/app/oauth2-server-config/server/admin/methods/deleteOAuthApp.js @@ -1,18 +1,18 @@ import { Meteor } from 'meteor/meteor'; import { hasPermission } from '../../../../authorization'; -import { OAuthApps } from '../../../../models'; +import { OAuthApps } from '../../../../models/server/raw'; Meteor.methods({ - deleteOAuthApp(applicationId) { + async deleteOAuthApp(applicationId) { if (!hasPermission(this.userId, 'manage-oauth-apps')) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'deleteOAuthApp' }); } - const application = OAuthApps.findOne(applicationId); + const application = await OAuthApps.findOneById(applicationId); if (application == null) { throw new Meteor.Error('error-application-not-found', 'Application not found', { method: 'deleteOAuthApp' }); } - OAuthApps.remove({ _id: applicationId }); + await OAuthApps.deleteOne({ _id: applicationId }); return true; }, }); diff --git a/app/oauth2-server-config/server/admin/methods/updateOAuthApp.js b/app/oauth2-server-config/server/admin/methods/updateOAuthApp.js index 007f5be2e95c4..3a7f88dda09e7 100644 --- a/app/oauth2-server-config/server/admin/methods/updateOAuthApp.js +++ b/app/oauth2-server-config/server/admin/methods/updateOAuthApp.js @@ -2,11 +2,12 @@ import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; import { hasPermission } from '../../../../authorization'; -import { OAuthApps, Users } from '../../../../models'; +import { OAuthApps } from '../../../../models/server/raw'; +import { Users } from '../../../../models/server'; import { parseUriList } from '../functions/parseUriList'; Meteor.methods({ - updateOAuthApp(applicationId, application) { + async updateOAuthApp(applicationId, application) { if (!hasPermission(this.userId, 'manage-oauth-apps')) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'updateOAuthApp' }); } @@ -19,7 +20,7 @@ Meteor.methods({ if (!_.isBoolean(application.active)) { throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', { method: 'updateOAuthApp' }); } - const currentApplication = OAuthApps.findOne(applicationId); + const currentApplication = await OAuthApps.findOneById(applicationId); if (currentApplication == null) { throw new Meteor.Error('error-application-not-found', 'Application not found', { method: 'updateOAuthApp' }); } @@ -30,7 +31,7 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-redirectUri', 'Invalid redirectUri', { method: 'updateOAuthApp' }); } - OAuthApps.update(applicationId, { + await OAuthApps.updateOne({ _id: applicationId }, { $set: { name: application.name, active: application.active, @@ -43,6 +44,6 @@ Meteor.methods({ }), }, }); - return OAuthApps.findOne(applicationId); + return OAuthApps.findOneById(applicationId); }, }); diff --git a/app/oauth2-server-config/server/oauth/default-services.js b/app/oauth2-server-config/server/oauth/default-services.js deleted file mode 100644 index d39489c9ec850..0000000000000 --- a/app/oauth2-server-config/server/oauth/default-services.js +++ /dev/null @@ -1,17 +0,0 @@ -import { OAuthApps } from '../../../models'; - -if (!OAuthApps.findOne('zapier')) { - OAuthApps.insert({ - _id: 'zapier', - name: 'Zapier', - active: true, - clientId: 'zapier', - clientSecret: 'RTK6TlndaCIolhQhZ7_KHIGOKj41RnlaOq_o-7JKwLr', - redirectUri: 'https://zapier.com/dashboard/auth/oauth/return/RocketChatDevAPI/', - _createdAt: new Date(), - _createdBy: { - _id: 'system', - username: 'system', - }, - }); -} diff --git a/app/oauth2-server-config/server/oauth/default-services.ts b/app/oauth2-server-config/server/oauth/default-services.ts new file mode 100644 index 0000000000000..05fd8f5c5d350 --- /dev/null +++ b/app/oauth2-server-config/server/oauth/default-services.ts @@ -0,0 +1,20 @@ +import { OAuthApps } from '../../../models/server/raw'; + +async function run(): Promise { + if (!await OAuthApps.findOneById('zapier')) { + await OAuthApps.insertOne({ + _id: 'zapier', + name: 'Zapier', + active: true, + clientId: 'zapier', + clientSecret: 'RTK6TlndaCIolhQhZ7_KHIGOKj41RnlaOq_o-7JKwLr', + redirectUri: 'https://zapier.com/dashboard/auth/oauth/return/RocketChatDevAPI/', + _createdAt: new Date(), + _createdBy: { + _id: 'system', + username: 'system', + }, + }); + } +} +run(); diff --git a/app/oauth2-server-config/server/oauth/oauth2-server.js b/app/oauth2-server-config/server/oauth/oauth2-server.js index 438aaaa2e0e64..c801074db4d50 100644 --- a/app/oauth2-server-config/server/oauth/oauth2-server.js +++ b/app/oauth2-server-config/server/oauth/oauth2-server.js @@ -1,15 +1,18 @@ import { Meteor } from 'meteor/meteor'; +import { Mongo } from 'meteor/mongo'; import { WebApp } from 'meteor/webapp'; import { OAuth2Server } from 'meteor/rocketchat:oauth2-server'; -import { OAuthApps, Users } from '../../../models'; +import { Users } from '../../../models/server'; +import { OAuthApps } from '../../../models/server/raw'; import { API } from '../../../api/server'; const oauth2server = new OAuth2Server({ accessTokensCollectionName: 'rocketchat_oauth_access_tokens', refreshTokensCollectionName: 'rocketchat_oauth_refresh_tokens', authCodesCollectionName: 'rocketchat_oauth_auth_codes', - clientsCollection: OAuthApps.model, + // TODO: Remove workaround. Used to pass meteor collection reference to a package + clientsCollection: new Mongo.Collection(OAuthApps.col.collectionName), debug: true, }); diff --git a/app/oembed/server/server.js b/app/oembed/server/server.js index 05f95cfbc0dc4..a3b32443fd1db 100644 --- a/app/oembed/server/server.js +++ b/app/oembed/server/server.js @@ -10,7 +10,8 @@ import ipRangeCheck from 'ip-range-check'; import he from 'he'; import jschardet from 'jschardet'; -import { OEmbedCache, Messages } from '../../models'; +import { Messages } from '../../models/server'; +import { OEmbedCache } from '../../models/server/raw'; import { callbacks } from '../../callbacks'; import { settings } from '../../settings'; import { isURL } from '../../utils/lib/isURL'; @@ -214,8 +215,8 @@ OEmbed.getUrlMeta = function(url, withFragment) { }); }; -OEmbed.getUrlMetaWithCache = function(url, withFragment) { - const cache = OEmbedCache.findOneById(url); +OEmbed.getUrlMetaWithCache = async function(url, withFragment) { + const cache = await OEmbedCache.findOneById(url); if (cache != null) { return cache.data; @@ -223,7 +224,7 @@ OEmbed.getUrlMetaWithCache = function(url, withFragment) { const data = OEmbed.getUrlMeta(url, withFragment); if (data != null) { try { - OEmbedCache.createWithIdAndData(url, data); + await OEmbedCache.createWithIdAndData(url, data); } catch (_error) { SystemLogger.error('OEmbed duplicated record', url); } @@ -262,21 +263,21 @@ const getRelevantMetaTags = function(metaObj) { const insertMaxWidthInOembedHtml = (oembedHtml) => oembedHtml?.replace('iframe', 'iframe style=\"max-width: 100%;width:400px;height:225px\"'); -OEmbed.rocketUrlParser = function(message) { +OEmbed.rocketUrlParser = async function(message) { if (Array.isArray(message.urls)) { - let attachments = []; + const attachments = []; let changed = false; - message.urls.forEach(function(item) { + for await (const item of message.urls) { if (item.ignoreParse === true) { return; } if (!isURL(item.url)) { return; } - const data = OEmbed.getUrlMetaWithCache(item.url); + const data = await OEmbed.getUrlMetaWithCache(item.url); if (data != null) { if (data.attachments) { - attachments = _.union(attachments, data.attachments); + attachments.push(...data.attachments); return; } if (data.meta != null) { @@ -291,7 +292,7 @@ OEmbed.rocketUrlParser = function(message) { item.parsedUrl = data.parsedUrl; changed = true; } - }); + } if (attachments.length) { Messages.setMessageAttachments(message._id, attachments); } @@ -304,7 +305,7 @@ OEmbed.rocketUrlParser = function(message) { settings.watch('API_Embed', function(value) { if (value) { - return callbacks.add('afterSaveMessage', OEmbed.rocketUrlParser, callbacks.priority.LOW, 'API_Embed'); + return callbacks.add('afterSaveMessage', (message) => Promise.await(OEmbed.rocketUrlParser(message)), callbacks.priority.LOW, 'API_Embed'); } return callbacks.remove('afterSaveMessage', 'API_Embed'); }); diff --git a/app/reactions/server/setReaction.js b/app/reactions/server/setReaction.js index 53de3fe70c1fd..e5f2a885b3083 100644 --- a/app/reactions/server/setReaction.js +++ b/app/reactions/server/setReaction.js @@ -2,11 +2,12 @@ import { Meteor } from 'meteor/meteor'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import _ from 'underscore'; -import { Messages, EmojiCustom, Rooms } from '../../models'; -import { callbacks } from '../../callbacks'; -import { emoji } from '../../emoji'; -import { isTheLastMessage, msgStream } from '../../lib'; -import { hasPermission } from '../../authorization/server/functions/hasPermission'; +import { Messages, Rooms } from '../../models/server'; +import { EmojiCustom } from '../../models/server/raw'; +import { callbacks } from '../../callbacks/server'; +import { emoji } from '../../emoji/server'; +import { isTheLastMessage, msgStream } from '../../lib/server'; +import { canAccessRoom, hasPermission } from '../../authorization/server'; import { api } from '../../../server/sdk/api'; const removeUserReaction = (message, reaction, username) => { @@ -20,7 +21,7 @@ const removeUserReaction = (message, reaction, username) => { async function setReaction(room, user, message, reaction, shouldReact) { reaction = `:${ reaction.replace(/:/g, '') }:`; - if (!emoji.list[reaction] && EmojiCustom.findByNameOrAlias(reaction).count() === 0) { + if (!emoji.list[reaction] && await EmojiCustom.findByNameOrAlias(reaction).count() === 0) { throw new Meteor.Error('error-not-allowed', 'Invalid emoji provided.', { method: 'setReaction' }); } @@ -91,17 +92,19 @@ export const executeSetReaction = async function(reaction, messageId, shouldReac } const message = Messages.findOneById(messageId); - if (!message) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); } - const room = Meteor.call('canAccessRoom', message.rid, Meteor.userId()); - + const room = Rooms.findOneById(message.rid); if (!room) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); } + if (!canAccessRoom(room, user)) { + throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'setReaction' }); + } + return setReaction(room, user, message, reaction, shouldReact); }; diff --git a/app/search/server/service/validationService.js b/app/search/server/service/validationService.js index 633d88b7697eb..120207bd0e97d 100644 --- a/app/search/server/service/validationService.js +++ b/app/search/server/service/validationService.js @@ -1,42 +1,46 @@ import { Meteor } from 'meteor/meteor'; +import mem from 'mem'; import SearchLogger from '../logger/logger'; -import { Users } from '../../../models'; +import { canAccessRoom } from '../../../authorization/server'; +import { Users, Rooms } from '../../../models/server'; class ValidationService { validateSearchResult(result) { - const subscriptionCache = {}; + const getSubscription = mem((rid, uid) => { + if (!rid) { + return; + } - const getSubscription = (rid, uid) => { - if (!subscriptionCache.hasOwnProperty(rid)) { - subscriptionCache[rid] = Meteor.call('canAccessRoom', rid, uid); + const room = Rooms.findOneById(rid); + if (!room) { + return; } - return subscriptionCache[rid]; - }; + if (!canAccessRoom(room, { _id: uid })) { + return; + } - const userCache = {}; + return room; + }); - const getUsername = (uid) => { - if (!userCache.hasOwnProperty(uid)) { - try { - userCache[uid] = Users.findById(uid).fetch()[0].username; - } catch (e) { - userCache[uid] = undefined; - } + const getUser = mem((uid) => { + if (!uid) { + return; } - return userCache[uid]; - }; + return Users.findOneById(uid, { fields: { username: 1 } }); + }); const uid = Meteor.userId(); // get subscription for message if (result.message) { result.message.docs.forEach((msg) => { + const user = getUser(msg.user); const subscription = getSubscription(msg.rid, uid); if (subscription) { msg.r = { name: subscription.name, t: subscription.t }; - msg.username = getUsername(msg.user); + msg.username = user?.username; msg.valid = true; SearchLogger.debug(`user ${ uid } can access ${ msg.rid } ( ${ subscription.t === 'd' ? subscription.username : subscription.name } )`); } else { @@ -44,7 +48,7 @@ class ValidationService { } }); - result.message.docs.filter((msg) => msg.valid); + result.message.docs = result.message.docs.filter((msg) => msg.valid); } if (result.room) { @@ -60,7 +64,7 @@ class ValidationService { } }); - result.room.docs.filter((room) => room.valid); + result.room.docs = result.room.docs.filter((room) => room.valid); } return result; diff --git a/app/settings/server/SettingsRegistry.ts b/app/settings/server/SettingsRegistry.ts index 40f8c08ba48e0..fa1f4ff3b9d85 100644 --- a/app/settings/server/SettingsRegistry.ts +++ b/app/settings/server/SettingsRegistry.ts @@ -75,6 +75,7 @@ const compareSettingsIgnoringKeys = (keys: Array) => .every((key) => isEqual(a[key as keyof ISetting], b[key as keyof ISetting])); const compareSettings = compareSettingsIgnoringKeys(['value', 'ts', 'createdAt', 'valueSource', 'packageValue', 'processEnvValue', '_updatedAt']); + export class SettingsRegistry { private model: typeof SettingsModel; @@ -108,7 +109,15 @@ export class SettingsRegistry { this._sorter[sorterKey] = this._sorter[sorterKey] ?? -1; } - const settingFromCode = getSettingDefaults({ _id, type: 'string', section, value, sorter: sorter ?? (sorterKey?.length && this._sorter[sorterKey]++), group, ...options }, blockedSettings, hiddenSettings, wizardRequiredSettings); + const settingFromCode = getSettingDefaults({ + _id, + type: 'string', + value, + sorter: sorter ?? (sorterKey?.length && this._sorter[sorterKey]++), + group, + section, + ...options, + }, blockedSettings, hiddenSettings, wizardRequiredSettings); if (isSettingEnterprise(settingFromCode) && !('invalidValue' in settingFromCode)) { SystemLogger.error(`Enterprise setting ${ _id } is missing the invalidValue option`); @@ -117,6 +126,7 @@ export class SettingsRegistry { const settingStored = this.store.getSetting(_id); const settingOverwritten = overwriteSetting(settingFromCode); + try { validateSetting(settingFromCode._id, settingFromCode.type, settingFromCode.value); } catch (e) { @@ -129,7 +139,16 @@ export class SettingsRegistry { if (settingStored && !compareSettings(settingStored, settingOverwritten)) { const { value: _value, ...settingOverwrittenProps } = settingOverwritten; - this.model.upsert({ _id }, { $set: { ...settingOverwrittenProps } }); + + const overwrittenKeys = Object.keys(settingOverwritten); + const removedKeys = Object.keys(settingStored).filter((key) => !['_updatedAt'].includes(key) && !overwrittenKeys.includes(key)); + + this.model.upsert({ _id }, { + $set: { ...settingOverwrittenProps }, + ...removedKeys.length && { + $unset: removedKeys.reduce((unset, key) => ({ ...unset, [key]: 1 }), {}), + }, + }); return; } diff --git a/app/settings/server/functions/getSettingDefaults.tests.ts b/app/settings/server/functions/getSettingDefaults.tests.ts index 1ed12b81a672e..63581c43ce69d 100644 --- a/app/settings/server/functions/getSettingDefaults.tests.ts +++ b/app/settings/server/functions/getSettingDefaults.tests.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ -/* eslint-env mocha */ import { expect } from 'chai'; import { getSettingDefaults } from './getSettingDefaults'; @@ -85,4 +83,14 @@ describe('getSettingDefaults', () => { expect(setting).to.have.property('blocked').to.be.equal(true); }); + + it('should not return undefined options', () => { + const setting = getSettingDefaults({ _id: 'test', value: true, type: 'string', section: undefined, group: undefined }, new Set(['test'])); + + expect(setting).to.be.an('object'); + expect(setting).to.have.property('_id'); + + expect(setting).to.not.have.property('section'); + expect(setting).to.not.have.property('group'); + }); }); diff --git a/app/settings/server/functions/getSettingDefaults.ts b/app/settings/server/functions/getSettingDefaults.ts index 3530840fd56ea..e17c97790b1e3 100644 --- a/app/settings/server/functions/getSettingDefaults.ts +++ b/app/settings/server/functions/getSettingDefaults.ts @@ -1,7 +1,15 @@ import { ISetting, ISettingColor, isSettingColor } from '../../../../definition/ISetting'; -export const getSettingDefaults = (setting: Partial & Pick, blockedSettings: Set = new Set(), hiddenSettings: Set = new Set(), wizardRequiredSettings: Set = new Set()): ISetting => { - const { _id, value, sorter, ...options } = setting; +export const getSettingDefaults = ( + setting: Partial & Pick, + blockedSettings: Set = new Set(), + hiddenSettings: Set = new Set(), + wizardRequiredSettings: Set = new Set(), +): ISetting => { + const { _id, value, sorter, ...props } = setting; + + const options = Object.fromEntries(Object.entries(props).filter(([, value]) => value !== undefined)); + return { _id, value, diff --git a/app/settings/server/functions/overrideGenerator.tests.ts b/app/settings/server/functions/overrideGenerator.tests.ts index a1881527ed84d..776cf8ce2cf95 100644 --- a/app/settings/server/functions/overrideGenerator.tests.ts +++ b/app/settings/server/functions/overrideGenerator.tests.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ -/* eslint-env mocha */ import { expect } from 'chai'; import { getSettingDefaults } from './getSettingDefaults'; diff --git a/app/settings/server/functions/settings.tests.ts b/app/settings/server/functions/settings.tests.ts index 4028486beb2a6..55692c7dc6d55 100644 --- a/app/settings/server/functions/settings.tests.ts +++ b/app/settings/server/functions/settings.tests.ts @@ -1,14 +1,10 @@ /* eslint-disable @typescript-eslint/camelcase */ -/* eslint-env mocha */ -import chai, { expect } from 'chai'; -import spies from 'chai-spies'; +import { expect, spy } from 'chai'; import { Settings } from './settings.mocks'; import { SettingsRegistry } from '../SettingsRegistry'; import { CachedSettings } from '../CachedSettings'; -chai.use(spies); - describe('Settings', () => { beforeEach(() => { Settings.insertCalls = 0; @@ -306,8 +302,8 @@ describe('Settings', () => { settings.initilized(); const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); - const spy = chai.spy(); - const spy2 = chai.spy(); + const spiedCallback1 = spy(); + const spiedCallback2 = spy(); settingsRegistry.addGroup('group', function() { this.section('section', function() { @@ -317,27 +313,27 @@ describe('Settings', () => { }); }); - settings.watch('setting_callback', spy, { debounce: 10 }); - settings.watchByRegex(/setting_callback/, spy2, { debounce: 10 }); + settings.watch('setting_callback', spiedCallback1, { debounce: 10 }); + settings.watchByRegex(/setting_callback/, spiedCallback2, { debounce: 10 }); setTimeout(() => { - expect(spy).to.have.been.called.exactly(1); - expect(spy2).to.have.been.called.exactly(1); - expect(spy).to.have.been.called.always.with('value1'); - expect(spy2).to.have.been.called.always.with('setting_callback', 'value1'); + expect(spiedCallback1).to.have.been.called.exactly(1); + expect(spiedCallback2).to.have.been.called.exactly(1); + expect(spiedCallback1).to.have.been.called.always.with('value1'); + expect(spiedCallback2).to.have.been.called.always.with('setting_callback', 'value1'); done(); }, settings.getConfig({ debounce: 10 }).debounce); }); it('should call `settings.watch` callback on setting changed registering before initialized', (done) => { - const spy = chai.spy(); - const spy2 = chai.spy(); + const spiedCallback1 = spy(); + const spiedCallback2 = spy(); const settings = new CachedSettings(); Settings.settings = settings; const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); - settings.watch('setting_callback', spy, { debounce: 1 }); - settings.watchByRegex(/setting_callback/ig, spy2, { debounce: 1 }); + settings.watch('setting_callback', spiedCallback1, { debounce: 1 }); + settings.watchByRegex(/setting_callback/ig, spiedCallback2, { debounce: 1 }); settings.initilized(); settingsRegistry.addGroup('group', function() { @@ -350,10 +346,10 @@ describe('Settings', () => { setTimeout(() => { Settings.updateValueById('setting_callback', 'value3'); setTimeout(() => { - expect(spy).to.have.been.called.exactly(2); - expect(spy2).to.have.been.called.exactly(2); - expect(spy).to.have.been.called.with('value2'); - expect(spy).to.have.been.called.with('value3'); + expect(spiedCallback1).to.have.been.called.exactly(2); + expect(spiedCallback2).to.have.been.called.exactly(2); + expect(spiedCallback1).to.have.been.called.with('value2'); + expect(spiedCallback1).to.have.been.called.with('value3'); done(); }, settings.getConfig({ debounce: 10 }).debounce); }, settings.getConfig({ debounce: 10 }).debounce); diff --git a/app/settings/server/functions/validateSettings.tests.ts b/app/settings/server/functions/validateSettings.tests.ts index 8891afcf6947b..ba7d28f793f89 100644 --- a/app/settings/server/functions/validateSettings.tests.ts +++ b/app/settings/server/functions/validateSettings.tests.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/camelcase */ -/* eslint-env mocha */ import { expect } from 'chai'; import { validateSetting } from './validateSetting'; diff --git a/app/settings/server/raw.tests.js b/app/settings/server/raw.tests.js index 7a9d6bccd6cf9..ecd1aeada491f 100644 --- a/app/settings/server/raw.tests.js +++ b/app/settings/server/raw.tests.js @@ -1,22 +1,18 @@ -/* eslint-env mocha */ -import chai, { expect } from 'chai'; -import spies from 'chai-spies'; +import { expect, spy } from 'chai'; import rewire from 'rewire'; -chai.use(spies); - describe('Raw Settings', () => { let rawModule; const cache = new Map(); before('rewire deps', () => { - const spy = chai.spy(async (id) => { + const spied = spy(async (id) => { if (id === '1') { return 'some-setting-value'; } return null; }); rawModule = rewire('./raw'); - rawModule.__set__('setFromDB', spy); + rawModule.__set__('setFromDB', spied); rawModule.__set__('cache', cache); }); diff --git a/app/smarsh-connector/server/functions/generateEml.js b/app/smarsh-connector/server/functions/generateEml.js index 8633fd1ca0ca8..f828ce310387c 100644 --- a/app/smarsh-connector/server/functions/generateEml.js +++ b/app/smarsh-connector/server/functions/generateEml.js @@ -4,7 +4,8 @@ import _ from 'underscore'; import moment from 'moment'; import { settings } from '../../../settings'; -import { Rooms, Messages, Users, SmarshHistory } from '../../../models'; +import { Rooms, Messages, Users } from '../../../models/server'; +import { SmarshHistory } from '../../../models/server/raw'; import { MessageTypes } from '../../../ui-utils'; import { smarsh } from '../lib/rocketchat'; import 'moment-timezone'; @@ -31,8 +32,8 @@ smarsh.generateEml = () => { const smarshMissingEmail = settings.get('Smarsh_MissingEmail_Email'); const timeZone = settings.get('Smarsh_Timezone'); - Rooms.find().forEach((room) => { - const smarshHistory = SmarshHistory.findOne({ _id: room._id }); + Rooms.find().forEach(async (room) => { + const smarshHistory = await SmarshHistory.findOne({ _id: room._id }); const query = { rid: room._id }; if (smarshHistory) { diff --git a/app/smarsh-connector/server/functions/sendEmail.js b/app/smarsh-connector/server/functions/sendEmail.js index 9b69b05b3ac1e..67fcfde02e675 100644 --- a/app/smarsh-connector/server/functions/sendEmail.js +++ b/app/smarsh-connector/server/functions/sendEmail.js @@ -4,19 +4,18 @@ // subject: 'Rocket.Chat, 17 Users, 24 Messages, 1 File, 799504 Minutes, in #random', // files: ['i3nc9l3mn'] // } -import _ from 'underscore'; import { UploadFS } from 'meteor/jalik:ufs'; import * as Mailer from '../../../mailer'; -import { Uploads } from '../../../models'; +import { Uploads } from '../../../models/server/raw'; import { settings } from '../../../settings'; import { smarsh } from '../lib/rocketchat'; -smarsh.sendEmail = (data) => { +smarsh.sendEmail = async (data) => { const attachments = []; - _.each(data.files, (fileId) => { - const file = Uploads.findOneById(fileId); + for await (const fileId of data.files) { + const file = await Uploads.findOneById(fileId); if (file.store === 'rocketchat_uploads' || file.store === 'fileSystem') { const rs = UploadFS.getStore(file.store).getReadStream(fileId, file); attachments.push({ @@ -24,8 +23,7 @@ smarsh.sendEmail = (data) => { streamSource: rs, }); } - }); - + } Mailer.sendNoWrap({ to: settings.get('Smarsh_Email'), diff --git a/app/statistics/server/lib/SAUMonitor.js b/app/statistics/server/lib/SAUMonitor.js index 36b7036fb5a04..9ffa5479a9e24 100644 --- a/app/statistics/server/lib/SAUMonitor.js +++ b/app/statistics/server/lib/SAUMonitor.js @@ -4,9 +4,9 @@ import { SyncedCron } from 'meteor/littledata:synced-cron'; import UAParser from 'ua-parser-js'; import { UAParserMobile, UAParserDesktop } from './UAParserCustom'; -import { Sessions } from '../../../models/server'; +import { Sessions } from '../../../models/server/raw'; +import { aggregates } from '../../../models/server/raw/Sessions'; import { Logger } from '../../../logger'; -import { aggregates } from '../../../models/server/models/Sessions'; import { getMostImportantRole } from './getMostImportantRole'; const getDateObj = (dateTime = new Date()) => ({ @@ -32,7 +32,7 @@ export class SAUMonitorClass { this._jobName = 'aggregate-sessions'; } - start(instanceId) { + async start(instanceId) { if (this.isRunning()) { return; } @@ -44,7 +44,7 @@ export class SAUMonitorClass { return; } - this._startMonitoring(() => { + await this._startMonitoring(() => { this._started = true; logger.debug(`[start] - InstanceId: ${ this._instanceId }`); }); @@ -70,12 +70,12 @@ export class SAUMonitorClass { return this._started === true; } - _startMonitoring(callback) { + async _startMonitoring(callback) { try { this._handleAccountEvents(); this._handleOnConnection(); this._startSessionControl(); - this._initActiveServerSessions(); + await this._initActiveServerSessions(); this._startAggregation(); if (callback) { callback(); @@ -94,8 +94,8 @@ export class SAUMonitorClass { return; } - this._timer = Meteor.setInterval(() => { - this._updateActiveSessions(); + this._timer = Meteor.setInterval(async () => { + await this._updateActiveSessions(); }, this._monitorTime); } @@ -110,8 +110,8 @@ export class SAUMonitorClass { } // this._handleSession(connection, getDateObj()); - connection.onClose(() => { - Sessions.closeByInstanceIdAndSessionId(this._instanceId, connection.id); + connection.onClose(async () => { + await Sessions.closeByInstanceIdAndSessionId(this._instanceId, connection.id); }); }); } @@ -121,7 +121,7 @@ export class SAUMonitorClass { return; } - Accounts.onLogin((info) => { + Accounts.onLogin(async (info) => { if (!this.isRunning()) { return; } @@ -132,11 +132,11 @@ export class SAUMonitorClass { const loginAt = new Date(); const params = { userId, roles, mostImportantRole, loginAt, ...getDateObj() }; - this._handleSession(info.connection, params); + await this._handleSession(info.connection, params); this._updateConnectionInfo(info.connection.id, { loginAt }); }); - Accounts.onLogout((info) => { + Accounts.onLogout(async (info) => { if (!this.isRunning()) { return; } @@ -144,17 +144,17 @@ export class SAUMonitorClass { const sessionId = info.connection.id; if (info.user) { const userId = info.user._id; - Sessions.logoutByInstanceIdAndSessionIdAndUserId(this._instanceId, sessionId, userId); + await Sessions.logoutByInstanceIdAndSessionIdAndUserId(this._instanceId, sessionId, userId); } }); } - _handleSession(connection, params) { + async _handleSession(connection, params) { const data = this._getConnectionInfo(connection, params); - Sessions.createOrUpdate(data); + await Sessions.createOrUpdate(data); } - _updateActiveSessions() { + async _updateActiveSessions() { if (!this.isRunning()) { return; } @@ -167,8 +167,8 @@ export class SAUMonitorClass { const beforeDateTime = new Date(this._today.year, this._today.month - 1, this._today.day, 23, 59, 59, 999); const nextDateTime = new Date(currentDay.year, currentDay.month - 1, currentDay.day); - const createSessions = (objects, ids) => { - Sessions.createBatch(objects); + const createSessions = async (objects, ids) => { + await Sessions.createBatch(objects); Meteor.defer(() => { Sessions.updateActiveSessionsByDateAndInstanceIdAndIds({ year, month, day }, this._instanceId, ids, { lastActivityAt: beforeDateTime }); @@ -180,8 +180,8 @@ export class SAUMonitorClass { } // Otherwise, just update the lastActivityAt field - this._applyAllServerSessionsIds((sessions) => { - Sessions.updateActiveSessionsByDateAndInstanceIdAndIds({ year, month, day }, this._instanceId, sessions, { lastActivityAt: currentDateTime }); + await this._applyAllServerSessionsIds(async (sessions) => { + await Sessions.updateActiveSessionsByDateAndInstanceIdAndIds({ year, month, day }, this._instanceId, sessions, { lastActivityAt: currentDateTime }); }); } @@ -266,32 +266,38 @@ export class SAUMonitorClass { }; } - _initActiveServerSessions() { - this._applyAllServerSessions((connectionHandle) => { - this._handleSession(connectionHandle, getDateObj()); + async _initActiveServerSessions() { + await this._applyAllServerSessions(async (connectionHandle) => { + await this._handleSession(connectionHandle, getDateObj()); }); } - _applyAllServerSessions(callback) { + async _applyAllServerSessions(callback) { if (!callback || typeof callback !== 'function') { return; } const sessions = Object.values(Meteor.server.sessions).filter((session) => session.userId); - sessions.forEach((session) => { - callback(session.connectionHandle); - }); + for await (const session of sessions) { + await callback(session.connectionHandle); + } } - _applyAllServerSessionsIds(callback) { + async recursive(callback, sessionIds) { + await callback(sessionIds.splice(0, 500)); + + if (sessionIds.length) { + await this.recursive(callback, sessionIds); + } + } + + async _applyAllServerSessionsIds(callback) { if (!callback || typeof callback !== 'function') { return; } const sessionIds = Object.values(Meteor.server.sessions).filter((session) => session.userId).map((s) => s.id); - while (sessionIds.length) { - callback(sessionIds.splice(0, 500)); - } + await this.recursive(callback, sessionIds); } _updateConnectionInfo(sessionId, data = {}) { @@ -315,8 +321,8 @@ export class SAUMonitorClass { return Promise.all(arr.splice(0, limit).map((item) => { ids.push(item.id); return this._getConnectionInfo(item.connectionHandle, params); - })).then((data) => { - callback(data, ids); + })).then(async (data) => { + await callback(data, ids); return batch(arr, limit); }).catch((e) => { logger.debug(`Error: ${ e.message }`); @@ -333,13 +339,13 @@ export class SAUMonitorClass { SyncedCron.add({ name: this._jobName, schedule: (parser) => parser.text('at 2:00 am'), - job: () => { - this.aggregate(); + job: async () => { + await this.aggregate(); }, }); } - aggregate() { + async aggregate() { if (!this.isRunning()) { return; } @@ -357,16 +363,16 @@ export class SAUMonitorClass { day: { $lte: yesterday.day }, }; - aggregates.dailySessionsOfYesterday(Sessions.model.rawCollection(), yesterday).forEach(Meteor.bindEnvironment((record) => { + await aggregates.dailySessionsOfYesterday(Sessions.col, yesterday).forEach(async (record) => { record._id = `${ record.userId }-${ record.year }-${ record.month }-${ record.day }`; - Sessions.upsert({ _id: record._id }, record); - })); + await Sessions.updateOne({ _id: record._id }, { $set: record }, { upsert: true }); + }); - Sessions.update(match, { + await Sessions.updateMany(match, { $set: { type: 'computed-session', _computedAt: new Date(), }, - }, { multi: true }); + }); } } diff --git a/app/statistics/server/lib/UAParserCustom.tests.js b/app/statistics/server/lib/UAParserCustom.tests.js index 0d7b2da16f3d9..18338bc051046 100644 --- a/app/statistics/server/lib/UAParserCustom.tests.js +++ b/app/statistics/server/lib/UAParserCustom.tests.js @@ -1,5 +1,3 @@ -/* eslint-env mocha */ - import { expect } from 'chai'; import { UAParserMobile, UAParserDesktop } from './UAParserCustom'; diff --git a/app/statistics/server/lib/getMostImportantRole.tests.js b/app/statistics/server/lib/getMostImportantRole.tests.js index 464f14b0e5744..5d3c7a6efa843 100644 --- a/app/statistics/server/lib/getMostImportantRole.tests.js +++ b/app/statistics/server/lib/getMostImportantRole.tests.js @@ -1,5 +1,3 @@ -/* eslint-env mocha */ - import { expect } from 'chai'; import { getMostImportantRole } from './getMostImportantRole'; diff --git a/app/statistics/server/lib/getServicesStatistics.ts b/app/statistics/server/lib/getServicesStatistics.ts index faf9a07928c36..0eb444940b3ad 100644 --- a/app/statistics/server/lib/getServicesStatistics.ts +++ b/app/statistics/server/lib/getServicesStatistics.ts @@ -11,7 +11,7 @@ function getCustomOAuthServices(): Record(`Accounts_OAuth_Custom-${ name }-merge_roles`), users: Users.countActiveUsersByService(name), }]; })); diff --git a/app/statistics/server/lib/statistics.js b/app/statistics/server/lib/statistics.js index b4ffb9f7dd25f..3fa09484ed4d3 100644 --- a/app/statistics/server/lib/statistics.js +++ b/app/statistics/server/lib/statistics.js @@ -3,24 +3,21 @@ import os from 'os'; import _ from 'underscore'; import { Meteor } from 'meteor/meteor'; import { InstanceStatus } from 'meteor/konecty:multiple-instances-status'; +import { MongoInternals } from 'meteor/mongo'; import { - Sessions, Settings, Users, Rooms, Subscriptions, - Uploads, Messages, LivechatVisitors, - Integrations, - Statistics, } from '../../../models/server'; import { settings } from '../../../settings/server'; import { Info, getMongoInfo } from '../../../utils/server'; import { getControl } from '../../../../server/lib/migrations'; import { getStatistics as federationGetStatistics } from '../../../federation/server/functions/dashboard'; -import { NotificationQueue, Users as UsersRaw } from '../../../models/server/raw'; +import { NotificationQueue, Users as UsersRaw, Rooms as RoomsRaw, Statistics, Sessions, Integrations, Uploads } from '../../../models/server/raw'; import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; import { getAppsStatistics } from './getAppsStatistics'; import { getServicesStatistics } from './getServicesStatistics'; @@ -55,9 +52,11 @@ const getUserLanguages = (totalUsers) => { return languages; }; +const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; + export const statistics = { get: function _getStatistics() { - const readPreference = readSecondaryPreferred(Uploads.model.rawDatabase()); + const readPreference = readSecondaryPreferred(db); const statistics = {}; @@ -117,6 +116,17 @@ export const statistics = { // livechat enabled statistics.livechatEnabled = settings.get('Livechat_enabled'); + // Count and types of omnichannel rooms + statistics.omnichannelSources = Promise.await(RoomsRaw.allRoomSourcesCount().toArray()).map(({ + _id: { id, alias, type }, + count, + }) => ({ + id, + alias, + type, + count, + })); + // Message statistics statistics.totalChannelMessages = _.reduce(Rooms.findByType('c', { fields: { msgs: 1 } }).fetch(), function _countChannelMessages(num, room) { return num + room.msgs; }, 0); statistics.totalPrivateGroupMessages = _.reduce(Rooms.findByType('p', { fields: { msgs: 1 } }).fetch(), function _countPrivateGroupMessages(num, room) { return num + room.msgs; }, 0); @@ -162,8 +172,8 @@ export const statistics = { statistics.enterpriseReady = true; - statistics.uploadsTotal = Uploads.find().count(); - const [result] = Promise.await(Uploads.model.rawCollection().aggregate([{ + statistics.uploadsTotal = Promise.await(Uploads.find().count()); + const [result] = Promise.await(Uploads.col.aggregate([{ $group: { _id: 'total', total: { $sum: '$size' } }, }], { readPreference }).toArray()); statistics.uploadsTotalSize = result ? result.total : 0; @@ -176,20 +186,20 @@ export const statistics = { statistics.mongoVersion = mongoVersion; statistics.mongoStorageEngine = mongoStorageEngine; - statistics.uniqueUsersOfYesterday = Sessions.getUniqueUsersOfYesterday(); - statistics.uniqueUsersOfLastWeek = Sessions.getUniqueUsersOfLastWeek(); - statistics.uniqueUsersOfLastMonth = Sessions.getUniqueUsersOfLastMonth(); - statistics.uniqueDevicesOfYesterday = Sessions.getUniqueDevicesOfYesterday(); - statistics.uniqueDevicesOfLastWeek = Sessions.getUniqueDevicesOfLastWeek(); - statistics.uniqueDevicesOfLastMonth = Sessions.getUniqueDevicesOfLastMonth(); - statistics.uniqueOSOfYesterday = Sessions.getUniqueOSOfYesterday(); - statistics.uniqueOSOfLastWeek = Sessions.getUniqueOSOfLastWeek(); - statistics.uniqueOSOfLastMonth = Sessions.getUniqueOSOfLastMonth(); + statistics.uniqueUsersOfYesterday = Promise.await(Sessions.getUniqueUsersOfYesterday()); + statistics.uniqueUsersOfLastWeek = Promise.await(Sessions.getUniqueUsersOfLastWeek()); + statistics.uniqueUsersOfLastMonth = Promise.await(Sessions.getUniqueUsersOfLastMonth()); + statistics.uniqueDevicesOfYesterday = Promise.await(Sessions.getUniqueDevicesOfYesterday()); + statistics.uniqueDevicesOfLastWeek = Promise.await(Sessions.getUniqueDevicesOfLastWeek()); + statistics.uniqueDevicesOfLastMonth = Promise.await(Sessions.getUniqueDevicesOfLastMonth()); + statistics.uniqueOSOfYesterday = Promise.await(Sessions.getUniqueOSOfYesterday()); + statistics.uniqueOSOfLastWeek = Promise.await(Sessions.getUniqueOSOfLastWeek()); + statistics.uniqueOSOfLastMonth = Promise.await(Sessions.getUniqueOSOfLastMonth()); statistics.apps = getAppsStatistics(); statistics.services = getServicesStatistics(); - const integrations = Promise.await(Integrations.model.rawCollection().find({}, { + const integrations = Promise.await(Integrations.find({}, { projection: { _id: 0, type: 1, @@ -215,10 +225,10 @@ export const statistics = { return statistics; }, - save() { + async save() { const rcStatistics = statistics.get(); rcStatistics.createdAt = new Date(); - Statistics.insert(rcStatistics); + await Statistics.insertOne(rcStatistics); return rcStatistics; }, }; diff --git a/app/theme/client/imports/components/popover.css b/app/theme/client/imports/components/popover.css index 807bdd7c88d21..5ce63aee851e5 100644 --- a/app/theme/client/imports/components/popover.css +++ b/app/theme/client/imports/components/popover.css @@ -53,6 +53,10 @@ border-radius: var(--popover-radius); background-color: var(--popover-background); box-shadow: 0 0 2px 0 rgba(47, 52, 61, 0.08), 0 0 12px 0 rgba(47, 52, 61, 0.12); + + &--templateless { + padding: var(--popover-padding) 0; + } } &__column { @@ -96,7 +100,7 @@ width: 100%; - padding: 4px 0; + padding: 4px 12px; cursor: pointer; @@ -109,6 +113,10 @@ font-size: var(--popover-item-text-size); align-items: center; + &:hover { + background-color: #f7f8fa; + } + &--alert { color: var(--rc-color-error); @@ -179,9 +187,9 @@ } &__divider { - width: 100%; + width: 88%; height: var(--popover-divider-height); - margin: 1rem 0; + margin: 1rem auto; background: var(--popover-divider-color); diff --git a/app/theme/client/imports/general/base_old.css b/app/theme/client/imports/general/base_old.css index 4378dc27b44a4..339396f3e718d 100644 --- a/app/theme/client/imports/general/base_old.css +++ b/app/theme/client/imports/general/base_old.css @@ -1894,7 +1894,7 @@ font-family: inherit; font-size: 0.875rem; - font-weight: 600; + font-weight: 700; line-height: inherit; } diff --git a/app/threads/server/methods/followMessage.js b/app/threads/server/methods/followMessage.js index 4ab2e59e29458..642c32e3b6337 100644 --- a/app/threads/server/methods/followMessage.js +++ b/app/threads/server/methods/followMessage.js @@ -4,6 +4,7 @@ import { check } from 'meteor/check'; import { Messages } from '../../../models/server'; import { RateLimiter } from '../../../lib/server'; import { settings } from '../../../settings/server'; +import { canAccessRoom } from '../../../authorization/server'; import { follow } from '../functions'; Meteor.methods({ @@ -24,8 +25,7 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-message', 'Invalid message', { method: 'followMessage' }); } - const room = Meteor.call('canAccessRoom', message.rid, uid); - if (!room) { + if (!canAccessRoom({ _id: message.rid }, { _id: uid })) { throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'followMessage' }); } diff --git a/app/threads/server/methods/unfollowMessage.js b/app/threads/server/methods/unfollowMessage.js index 743d9bd5e7195..a5ed0fa50c6a6 100644 --- a/app/threads/server/methods/unfollowMessage.js +++ b/app/threads/server/methods/unfollowMessage.js @@ -4,6 +4,7 @@ import { check } from 'meteor/check'; import { Messages } from '../../../models/server'; import { RateLimiter } from '../../../lib/server'; import { settings } from '../../../settings/server'; +import { canAccessRoom } from '../../../authorization/server'; import { unfollow } from '../functions'; Meteor.methods({ @@ -21,12 +22,11 @@ Meteor.methods({ const message = Messages.findOneById(mid, { fields: { rid: 1, tmid: 1 } }); if (!message) { - throw new Meteor.Error('error-invalid-message', 'Invalid message', { method: 'followMessage' }); + throw new Meteor.Error('error-invalid-message', 'Invalid message', { method: 'unfollowMessage' }); } - const room = Meteor.call('canAccessRoom', message.rid, uid); - if (!room) { - throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'followMessage' }); + if (!canAccessRoom({ _id: message.rid }, { _id: uid })) { + throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'unfollowMessage' }); } return unfollow({ rid: message.rid, tmid: message.tmid || message._id, uid }); diff --git a/app/ui-master/client/main.html b/app/ui-master/client/main.html index f226be71093a8..587453a419366 100644 --- a/app/ui-master/client/main.html +++ b/app/ui-master/client/main.html @@ -35,7 +35,6 @@ {{/if}} {{/unless}} {{ CustomScriptLoggedIn }} - {{> photoswipe}} {{/unless}} {{else}} {{> loading}} diff --git a/app/ui-message/client/messageBox/messageBox.js b/app/ui-message/client/messageBox/messageBox.js index 8d67e3466bdc3..d8cf0424deff1 100644 --- a/app/ui-message/client/messageBox/messageBox.js +++ b/app/ui-message/client/messageBox/messageBox.js @@ -477,6 +477,7 @@ Template.messageBox.events({ data: { rid: this.rid, tmid: this.tmid, + prid: this.subscription.prid, messageBox: instance.firstNode, }, activeElement: event.currentTarget, @@ -494,6 +495,7 @@ Template.messageBox.events({ rid: this.rid, tmid: this.tmid, messageBox: instance.firstNode, + prid: this.subscription.prid, event, }); }); diff --git a/app/ui-sidenav/client/roomList.js b/app/ui-sidenav/client/roomList.js index de128304d349a..cab1983ad98d1 100644 --- a/app/ui-sidenav/client/roomList.js +++ b/app/ui-sidenav/client/roomList.js @@ -172,6 +172,7 @@ const mergeSubRoom = (subscription) => { livechatData: 1, departmentId: 1, source: 1, + queuedAt: 1, }, }; @@ -212,6 +213,7 @@ const mergeSubRoom = (subscription) => { departmentId, ts, source, + queuedAt, } = room; subscription.lm = subscription.lr ? new Date(Math.max(subscription.lr, lastRoomUpdate)) : lastRoomUpdate; @@ -249,6 +251,7 @@ const mergeSubRoom = (subscription) => { departmentId, ts, source, + queuedAt, }); }; @@ -291,6 +294,7 @@ const mergeRoomSub = (room) => { departmentId, ts, source, + queuedAt, } = room; Subscriptions.update({ @@ -328,6 +332,7 @@ const mergeRoomSub = (room) => { jitsiTimeout, ts, source, + queuedAt, ...getLowerCaseNames(room, sub.name, sub.fname), }, }); diff --git a/app/ui-utils/client/lib/AccountBox.d.ts b/app/ui-utils/client/lib/AccountBox.d.ts new file mode 100644 index 0000000000000..71217df759c63 --- /dev/null +++ b/app/ui-utils/client/lib/AccountBox.d.ts @@ -0,0 +1,13 @@ +import { IUser } from '../../../../definition/IUser'; +import { TranslationKey } from '../../../../client/contexts/TranslationContext'; + +export declare const AccountBox: { + setStatus: (status: IUser['status'], statusText?: IUser['statusText']) => void; + getItems: () => Array<{ + condition: () => boolean; + name: TranslationKey; + icon: string; + sideNav: string; + href: string; + }>; +}; diff --git a/app/ui-utils/client/lib/SideNav.js b/app/ui-utils/client/lib/SideNav.js index fe0b5bab0c102..c6deaa23087ae 100644 --- a/app/ui-utils/client/lib/SideNav.js +++ b/app/ui-utils/client/lib/SideNav.js @@ -84,23 +84,6 @@ export const SideNav = new class { return AccountBox.toggle(); } - focusInput() { - const sideNavDivs = Array.from(this.sideNav[0].children).filter((el) => el.tagName === 'DIV' && !el.classList.contains('hidden')); - let highestZidx = 0; - let highestZidxElem; - sideNavDivs.forEach((el) => { - const zIndex = Number(window.getComputedStyle(el).zIndex); - if (zIndex > highestZidx) { - highestZidx = zIndex; - highestZidxElem = el; - } - }); - setTimeout(() => { - const ref = highestZidxElem && highestZidxElem.querySelector('input'); - return ref && ref.focus(); - }, 200); - } - validate() { const invalid = []; this.sideNav.find('input.required').each(function() { @@ -125,7 +108,6 @@ export const SideNav = new class { return; } this.toggleFlex(1, callback); - return this.focusInput(); } init() { diff --git a/app/ui-utils/client/lib/popover.html b/app/ui-utils/client/lib/popover.html index 588d9365be686..4376562cdb30a 100644 --- a/app/ui-utils/client/lib/popover.html +++ b/app/ui-utils/client/lib/popover.html @@ -1,6 +1,6 @@ diff --git a/app/ui/client/views/app/photoswipeContent.ts b/app/ui/client/views/app/photoswipeContent.ts new file mode 100644 index 0000000000000..7e6ee33b9176b --- /dev/null +++ b/app/ui/client/views/app/photoswipeContent.ts @@ -0,0 +1,158 @@ +import { Meteor } from 'meteor/meteor'; +import { Blaze } from 'meteor/blaze'; +import { Template } from 'meteor/templating'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import type PhotoSwipe from 'photoswipe'; +import type PhotoSwipeUiDefault from 'photoswipe/dist/photoswipe-ui-default'; + +const parseLength = (x: unknown): number | undefined => { + const length = typeof x === 'string' ? parseInt(x, 10) : undefined; + return Number.isFinite(length) ? length : undefined; +}; + +const getImageSize = (src: string): Promise<[w: number, h: number]> => new Promise((resolve, reject) => { + const img = new Image(); + + img.addEventListener('load', () => { + resolve([img.naturalWidth, img.naturalHeight]); + }); + + img.addEventListener('error', (error) => { + reject(error.error); + }); + + img.src = src; +}); + +type Slide = PhotoSwipeUiDefault.Item & { description?: string }; + +const fromElementToSlide = async (element: Element): Promise => { + if (!(element instanceof HTMLElement)) { + return null; + } + + const title = element.dataset.title || element.title; + const { description } = element.dataset; + + if (element instanceof HTMLAnchorElement) { + const src = element.dataset.src || element.href; + let w = parseLength(element.dataset.width); + let h = parseLength(element.dataset.height); + + if (w === undefined || h === undefined) { + [w, h] = await getImageSize(src); + } + + return { src, w, h, title, description }; + } + + if (element instanceof HTMLImageElement) { + let msrc: string | undefined; + let { src } = element; + let w: number | undefined = element.naturalWidth; + let h: number | undefined = element.naturalHeight; + + if (element.dataset.src) { + msrc = element.src; + src = element.dataset.src; + w = parseLength(element.dataset.width); + h = parseLength(element.dataset.height); + + if (w === undefined || h === undefined) { + [w, h] = await getImageSize(src); + } + } + + return { msrc, src, w, h, title, description }; + } + + return null; +}; + +let currentGallery: PhotoSwipe | null = null; + +const initGallery = async (items: Slide[], options: PhotoSwipeUiDefault.Options): Promise => { + const [ + { default: PhotoSwipe }, + { default: PhotoSwipeUiDefault }, // eslint-disable-line @typescript-eslint/camelcase + ] = await Promise.all([ + import('photoswipe'), + import('photoswipe/dist/photoswipe-ui-default'), + // @ts-ignore + import('photoswipe/dist/photoswipe.css'), + // @ts-ignore + import('./photoswipeContent.html'), + ]); + + Blaze.render(Template.photoswipeContent, document.body); + + if (!currentGallery) { + const container = document.getElementById('pswp'); + + if (!container) { + throw new Error('Photoswipe container element not found'); + } + + currentGallery = new PhotoSwipe(container, PhotoSwipeUiDefault, items, options); + + currentGallery.listen('destroy', () => { + currentGallery = null; + }); + + currentGallery.init(); + } +}; + +const defaultGalleryOptions: PhotoSwipeUiDefault.Options = { + bgOpacity: 0.7, + showHideOpacity: true, + counterEl: false, + shareEl: false, + clickToCloseNonZoomable: false, + index: 0, + addCaptionHTMLFn(item: Slide, captionEl: HTMLElement): boolean { + captionEl.children[0].innerHTML = ` + ${ escapeHTML(item.title ?? '') }
+ ${ escapeHTML(item.description ?? '') } + `; + return true; + }, +}; + +const createEventListenerFor = (className: string) => (event: JQuery.ClickEvent): void => { + event.preventDefault(); + event.stopPropagation(); + + const { currentTarget } = event; + + Array.from(document.querySelectorAll(className)) + .sort((a, b) => { + if (a === currentTarget) { + return -1; + } + + if (b === currentTarget) { + return 1; + } + + return 0; + }) + .map((element) => fromElementToSlide(element)) + .reduce((p, curr) => p.then(() => curr).then(async (slide) => { + if (!slide) { + return; + } + + if (!currentGallery) { + return initGallery([slide], defaultGalleryOptions); + } + + currentGallery.items.push(slide); + currentGallery.invalidateCurrItems(); + currentGallery.updateSize(true); + }), Promise.resolve()); +}; + +Meteor.startup(() => { + $(document).on('click', '.gallery-item', createEventListenerFor('.gallery-item')); +}); diff --git a/app/ui/client/views/app/tests/helpers.tests.js b/app/ui/client/views/app/tests/helpers.tests.js index 110a050454ebe..77487ec1ea4cc 100644 --- a/app/ui/client/views/app/tests/helpers.tests.js +++ b/app/ui/client/views/app/tests/helpers.tests.js @@ -1,6 +1,4 @@ -/* eslint-env mocha */ -import 'babel-polyfill'; -import assert from 'assert'; +import { expect } from 'chai'; import { timeAgo } from '../helpers'; @@ -16,9 +14,9 @@ describe('Helpers', () => { const func = (a) => a; - assert.equal(timeAgo(t1, func, now), '1:00 AM'); - assert.equal(timeAgo(t2, func, now), '10:00 AM'); - assert.equal(timeAgo(t3, func, now), '2:30 PM'); + expect(timeAgo(t1, func, now)).to.be.equal('1:00 AM'); + expect(timeAgo(t2, func, now)).to.be.equal('10:00 AM'); + expect(timeAgo(t3, func, now)).to.be.equal('2:30 PM'); }); it('returns "yesterday" when the passed value is on the day before', () => { @@ -30,9 +28,9 @@ describe('Helpers', () => { const func = (a) => a; - assert.equal(timeAgo(t1, func, now), 'yesterday'); - assert.equal(timeAgo(t2, func, now), 'yesterday'); - assert.equal(timeAgo(t3, func, now), 'yesterday'); + expect(timeAgo(t1, func, now)).to.be.equal('yesterday'); + expect(timeAgo(t2, func, now)).to.be.equal('yesterday'); + expect(timeAgo(t3, func, now)).to.be.equal('yesterday'); }); it('returns formated date when the passed value two or more days before', () => { @@ -46,11 +44,11 @@ describe('Helpers', () => { const func = () => 'should not be called'; - assert.equal(timeAgo(t1, func, now), 'Jun 18, 2018'); - assert.equal(timeAgo(t2, func, now), 'Jun 10, 2018'); - assert.equal(timeAgo(t3, func, now), 'May 10, 2018'); - assert.equal(timeAgo(t4, func, now), 'May 20, 2018'); - assert.equal(timeAgo(t5, func, now), 'Nov 10, 2017'); + expect(timeAgo(t1, func, now)).to.be.equal('Jun 18, 2018'); + expect(timeAgo(t2, func, now)).to.be.equal('Jun 10, 2018'); + expect(timeAgo(t3, func, now)).to.be.equal('May 10, 2018'); + expect(timeAgo(t4, func, now)).to.be.equal('May 20, 2018'); + expect(timeAgo(t5, func, now)).to.be.equal('Nov 10, 2017'); }); }); }); diff --git a/app/ui/index.js b/app/ui/index.ts similarity index 100% rename from app/ui/index.js rename to app/ui/index.ts diff --git a/app/user-data-download/server/cronProcessDownloads.js b/app/user-data-download/server/cronProcessDownloads.js index 8fb19b86b08c6..35c8de44af20b 100644 --- a/app/user-data-download/server/cronProcessDownloads.js +++ b/app/user-data-download/server/cronProcessDownloads.js @@ -10,7 +10,8 @@ import moment from 'moment'; import { v4 as uuidv4 } from 'uuid'; import { settings } from '../../settings/server'; -import { Subscriptions, Rooms, Users, Uploads, Messages, UserDataFiles, ExportOperations, Avatars } from '../../models/server'; +import { Subscriptions, Rooms, Users, Messages } from '../../models/server'; +import { Avatars, ExportOperations, UserDataFiles, Uploads } from '../../models/server/raw'; import { FileUpload } from '../../file-upload/server'; import { DataExport } from './DataExport'; import * as Mailer from '../../mailer'; @@ -186,8 +187,8 @@ const getMessageData = function(msg, hideUsers, userData, usersMap) { return messageObject; }; -export const copyFile = function(attachmentData, assetsPath) { - const file = Uploads.findOneById(attachmentData._id); +export const copyFile = async function(attachmentData, assetsPath) { + const file = await Uploads.findOneById(attachmentData._id); if (!file) { return; } @@ -439,12 +440,12 @@ const generateUserFile = function(exportOperation, userData) { } }; -const generateUserAvatarFile = function(exportOperation, userData) { +const generateUserAvatarFile = async function(exportOperation, userData) { if (!userData) { return; } - const file = Avatars.findOneByName(userData.username); + const file = await Avatars.findOneByName(userData.username); if (!file) { return; } @@ -478,7 +479,7 @@ const continueExportOperation = async function(exportOperation) { } if (!exportOperation.generatedAvatar) { - generateUserAvatarFile(exportOperation, exportOperation.userData); + await generateUserAvatarFile(exportOperation, exportOperation.userData); } if (exportOperation.status === 'exporting-rooms') { @@ -511,9 +512,9 @@ const continueExportOperation = async function(exportOperation) { const generatedFileName = uuidv4(); if (exportOperation.status === 'downloading') { - exportOperation.fileList.forEach((attachmentData) => { - copyFile(attachmentData, exportOperation.assetsPath); - }); + for await (const attachmentData of exportOperation.fileList) { + await copyFile(attachmentData, exportOperation.assetsPath); + } const targetFile = joinPath(zipFolder, `${ generatedFileName }.zip`); if (await fsExists(targetFile)) { @@ -539,17 +540,17 @@ const continueExportOperation = async function(exportOperation) { exportOperation.fileId = fileId; exportOperation.status = 'completed'; - ExportOperations.updateOperation(exportOperation); + await ExportOperations.updateOperation(exportOperation); } - ExportOperations.updateOperation(exportOperation); + await ExportOperations.updateOperation(exportOperation); } catch (e) { console.error(e); } }; async function processDataDownloads() { - const operation = ExportOperations.findOnePending(); + const operation = await ExportOperations.findOnePending(); if (!operation) { return; } @@ -571,7 +572,7 @@ async function processDataDownloads() { await ExportOperations.updateOperation(operation); if (operation.status === 'completed') { - const file = operation.fileId ? UserDataFiles.findOneById(operation.fileId) : UserDataFiles.findLastFileByUser(operation.userId); + const file = operation.fileId ? await UserDataFiles.findOneById(operation.fileId) : await UserDataFiles.findLastFileByUser(operation.userId); if (!file) { return; } diff --git a/app/user-data-download/server/exportDownload.js b/app/user-data-download/server/exportDownload.js index d99eda5c88e60..6ea406de307c2 100644 --- a/app/user-data-download/server/exportDownload.js +++ b/app/user-data-download/server/exportDownload.js @@ -1,12 +1,12 @@ import { WebApp } from 'meteor/webapp'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; -import { UserDataFiles } from '../../models'; +import { UserDataFiles } from '../../models/server/raw'; import { DataExport } from './DataExport'; import { settings } from '../../settings/server'; -WebApp.connectHandlers.use(DataExport.getPath(), function(req, res, next) { +WebApp.connectHandlers.use(DataExport.getPath(), async function(req, res, next) { const match = /^\/([^\/]+)/.exec(req.url); if (!settings.get('UserData_EnableDownload')) { @@ -16,7 +16,7 @@ WebApp.connectHandlers.use(DataExport.getPath(), function(req, res, next) { } if (match && match[1]) { - const file = UserDataFiles.findOneById(match[1]); + const file = await UserDataFiles.findOneById(match[1]); if (file) { if (!DataExport.requestCanAccessFiles(req, file.userId)) { res.setHeader('Content-Type', 'text/html; charset=UTF-8'); diff --git a/app/user-status/client/index.js b/app/user-status/client/index.js index 6c9a73b43f5da..ecada9a17f9fa 100644 --- a/app/user-status/client/index.js +++ b/app/user-status/client/index.js @@ -3,5 +3,5 @@ import './admin/startup'; import './notifications/deleteCustomUserStatus'; import './notifications/updateCustomUserStatus'; -export { userStatus } from './lib/userStatus'; +export { userStatus, UserStatusProps } from './lib/userStatus'; export { deleteCustomUserStatus, updateCustomUserStatus } from './lib/customUserStatus'; diff --git a/app/user-status/client/lib/userStatus.js b/app/user-status/client/lib/userStatus.js deleted file mode 100644 index 71fded4e86d5b..0000000000000 --- a/app/user-status/client/lib/userStatus.js +++ /dev/null @@ -1,36 +0,0 @@ -export const userStatus = { - packages: { - base: { - render(html) { - return html; - }, - }, - }, - - list: { - online: { - name: 'online', - localizeName: true, - id: 'online', - statusType: 'online', - }, - away: { - name: 'away', - localizeName: true, - id: 'away', - statusType: 'away', - }, - busy: { - name: 'busy', - localizeName: true, - id: 'busy', - statusType: 'busy', - }, - invisible: { - name: 'invisible', - localizeName: true, - id: 'offline', - statusType: 'offline', - }, - }, -}; diff --git a/app/user-status/client/lib/userStatus.ts b/app/user-status/client/lib/userStatus.ts new file mode 100644 index 0000000000000..1eb824d169fc1 --- /dev/null +++ b/app/user-status/client/lib/userStatus.ts @@ -0,0 +1,52 @@ +import { UserStatus } from '../../../../definition/UserStatus'; + +type Status = { + name: string; + localizeName: boolean; + id: string; + statusType: UserStatus; +}; + +type UserStatusTypes = { + packages: any; + list: { + [status: string]: Status; + }; +} + +export const userStatus: UserStatusTypes = { + packages: { + base: { + render(html: string): string { + return html; + }, + }, + }, + + list: { + online: { + name: UserStatus.ONLINE, + localizeName: true, + id: UserStatus.ONLINE, + statusType: UserStatus.ONLINE, + }, + away: { + name: UserStatus.AWAY, + localizeName: true, + id: UserStatus.AWAY, + statusType: UserStatus.AWAY, + }, + busy: { + name: UserStatus.BUSY, + localizeName: true, + id: UserStatus.BUSY, + statusType: UserStatus.BUSY, + }, + invisible: { + name: UserStatus.OFFLINE, + localizeName: true, + id: UserStatus.OFFLINE, + statusType: UserStatus.OFFLINE, + }, + }, +}; diff --git a/app/user-status/server/methods/deleteCustomUserStatus.js b/app/user-status/server/methods/deleteCustomUserStatus.js index e81a8140d7180..6935266a74757 100644 --- a/app/user-status/server/methods/deleteCustomUserStatus.js +++ b/app/user-status/server/methods/deleteCustomUserStatus.js @@ -1,21 +1,21 @@ import { Meteor } from 'meteor/meteor'; import { hasPermission } from '../../../authorization/server'; -import { CustomUserStatus } from '../../../models/server'; +import { CustomUserStatus } from '../../../models/server/raw'; import { api } from '../../../../server/sdk/api'; Meteor.methods({ - deleteCustomUserStatus(userStatusID) { + async deleteCustomUserStatus(userStatusID) { if (!hasPermission(this.userId, 'manage-user-status')) { throw new Meteor.Error('not_authorized'); } - const userStatus = CustomUserStatus.findOneById(userStatusID); + const userStatus = await CustomUserStatus.findOneById(userStatusID); if (userStatus == null) { throw new Meteor.Error('Custom_User_Status_Error_Invalid_User_Status', 'Invalid user status', { method: 'deleteCustomUserStatus' }); } - CustomUserStatus.removeById(userStatusID); + await CustomUserStatus.removeById(userStatusID); api.broadcast('user.deleteCustomStatus', userStatus); return true; diff --git a/app/user-status/server/methods/insertOrUpdateUserStatus.js b/app/user-status/server/methods/insertOrUpdateUserStatus.js index a01cc751c525d..ebc0e918acbae 100644 --- a/app/user-status/server/methods/insertOrUpdateUserStatus.js +++ b/app/user-status/server/methods/insertOrUpdateUserStatus.js @@ -2,11 +2,11 @@ import { Meteor } from 'meteor/meteor'; import s from 'underscore.string'; import { hasPermission } from '../../../authorization'; -import { CustomUserStatus } from '../../../models'; +import { CustomUserStatus } from '../../../models/server/raw'; import { api } from '../../../../server/sdk/api'; Meteor.methods({ - insertOrUpdateUserStatus(userStatusData) { + async insertOrUpdateUserStatus(userStatusData) { if (!hasPermission(this.userId, 'manage-user-status')) { throw new Meteor.Error('not_authorized'); } @@ -26,9 +26,9 @@ Meteor.methods({ let matchingResults = []; if (userStatusData._id) { - matchingResults = CustomUserStatus.findByNameExceptId(userStatusData.name, userStatusData._id).fetch(); + matchingResults = await CustomUserStatus.findByNameExceptId(userStatusData.name, userStatusData._id).toArray(); } else { - matchingResults = CustomUserStatus.findByName(userStatusData.name).fetch(); + matchingResults = await CustomUserStatus.findByName(userStatusData.name).toArray(); } if (matchingResults.length > 0) { @@ -47,7 +47,7 @@ Meteor.methods({ statusType: userStatusData.statusType || null, }; - const _id = CustomUserStatus.create(createUserStatus); + const _id = await (await CustomUserStatus.create(createUserStatus)).insertedId; api.broadcast('user.updateCustomStatus', createUserStatus); @@ -56,11 +56,11 @@ Meteor.methods({ // update User status if (userStatusData.name !== userStatusData.previousName) { - CustomUserStatus.setName(userStatusData._id, userStatusData.name); + await CustomUserStatus.setName(userStatusData._id, userStatusData.name); } if (userStatusData.statusType !== userStatusData.previousStatusType) { - CustomUserStatus.setStatusType(userStatusData._id, userStatusData.statusType); + await CustomUserStatus.setStatusType(userStatusData._id, userStatusData.statusType); } api.broadcast('user.updateCustomStatus', userStatusData); diff --git a/app/user-status/server/methods/listCustomUserStatus.js b/app/user-status/server/methods/listCustomUserStatus.js index a47f1ff01bc27..b8ec637d99b6f 100644 --- a/app/user-status/server/methods/listCustomUserStatus.js +++ b/app/user-status/server/methods/listCustomUserStatus.js @@ -1,14 +1,14 @@ import { Meteor } from 'meteor/meteor'; -import { CustomUserStatus } from '../../../models'; +import { CustomUserStatus } from '../../../models/server/raw'; Meteor.methods({ - listCustomUserStatus() { + async listCustomUserStatus() { const currentUserId = Meteor.userId(); if (!currentUserId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'listCustomUserStatus' }); } - return CustomUserStatus.find({}).fetch(); + return CustomUserStatus.find({}).toArray(); }, }); diff --git a/app/utils/client/lib/RestApiClient.d.ts b/app/utils/client/lib/RestApiClient.d.ts new file mode 100644 index 0000000000000..c9d4c225488b6 --- /dev/null +++ b/app/utils/client/lib/RestApiClient.d.ts @@ -0,0 +1,44 @@ +import { Serialized } from '../../../../definition/Serialized'; + +export declare const APIClient: { + delete(endpoint: string, params?: Serialized

): Promise>; + get(endpoint: string, params?: void extends P ? void : Serialized

): Promise>; + post(endpoint: string, params?: Serialized

, body?: B): Promise>; + upload( + endpoint: string, + params?: Serialized

, + formData?: B, + xhrOptions?: { + progress: (amount: number) => void; + error: (ev: ProgressEvent) => void; + } + ): { promise: Promise> }; + getCredentials(): { + 'X-User-Id': string; + 'X-Auth-Token': string; + }; + _jqueryCall( + method?: string, + endpoint?: string, + params?: any, + body?: any, + headers?: Record, + dataType?: string + ): any; + v1: { + delete(endpoint: string, params?: Serialized

): Promise>; + get(endpoint: string, params?: Serialized

): Promise>; + post(endpoint: string, params?: Serialized

, body?: B): Promise>; + put(endpoint: string, params?: Serialized

, body?: B): Promise>; + upload( + endpoint: string, + params?: Serialized

, + formData?: B, + xhrOptions?: { + progress: (amount: number) => void; + error: (ev: ProgressEvent) => void; + } + ): { promise: Promise> }; + }; +}; diff --git a/app/utils/client/lib/RestApiClient.js b/app/utils/client/lib/RestApiClient.js index 9d857af0d580d..38038e0142472 100644 --- a/app/utils/client/lib/RestApiClient.js +++ b/app/utils/client/lib/RestApiClient.js @@ -1,5 +1,6 @@ import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; +import jQuery from 'jquery'; import { process2faReturn } from '../../../../client/lib/2fa/process2faReturn'; import { baseURI } from '../../../../client/lib/baseURI'; @@ -22,6 +23,15 @@ export const APIClient = { return APIClient._jqueryCall('POST', endpoint, params, body); }, + put(endpoint, params, body) { + if (!body) { + body = params; + params = {}; + } + + return APIClient._jqueryCall('PUT', endpoint, params, body); + }, + upload(endpoint, params, formData, xhrOptions) { if (!formData) { formData = params; @@ -168,5 +178,9 @@ export const APIClient = { upload(endpoint, params, formData) { return APIClient.upload(`v1/${ endpoint }`, params, formData); }, + + put(endpoint, params, body) { + return APIClient.put(`v1/${ endpoint }`, params, body); + }, }, }; diff --git a/app/utils/lib/getDefaultSubscriptionPref.js b/app/utils/lib/getDefaultSubscriptionPref.js index 294a7d50a734c..0200cb128e334 100644 --- a/app/utils/lib/getDefaultSubscriptionPref.js +++ b/app/utils/lib/getDefaultSubscriptionPref.js @@ -3,7 +3,7 @@ export const getDefaultSubscriptionPref = (userPref) => { const { desktopNotifications, - mobileNotifications, + pushNotifications, emailNotificationMode, highlights, } = (userPref.settings && userPref.settings.preferences) || {}; @@ -17,8 +17,8 @@ export const getDefaultSubscriptionPref = (userPref) => { subscription.desktopPrefOrigin = 'user'; } - if (mobileNotifications && mobileNotifications !== 'default') { - subscription.mobilePushNotifications = mobileNotifications; + if (pushNotifications && pushNotifications !== 'default') { + subscription.mobilePushNotifications = pushNotifications; subscription.mobilePrefOrigin = 'user'; } diff --git a/app/utils/lib/getURL.tests.js b/app/utils/lib/getURL.tests.js index 73d6d01865501..1cccb926d941a 100644 --- a/app/utils/lib/getURL.tests.js +++ b/app/utils/lib/getURL.tests.js @@ -1,8 +1,4 @@ -/* eslint-disable complexity */ -/* eslint-env mocha */ -import 'babel-polyfill'; -import assert from 'assert'; - +import { expect } from 'chai'; import s from 'underscore.string'; import { _getURL } from './getURL'; @@ -13,17 +9,17 @@ const testPaths = (o, _processPath) => { processPath = (path) => _processPath(o._root_url_path_prefix + path); } - assert.equal(_getURL('', o), processPath('')); - assert.equal(_getURL('/', o), processPath('')); - assert.equal(_getURL('//', o), processPath('')); - assert.equal(_getURL('///', o), processPath('')); - assert.equal(_getURL('/channel', o), processPath('/channel')); - assert.equal(_getURL('/channel/', o), processPath('/channel')); - assert.equal(_getURL('/channel//', o), processPath('/channel')); - assert.equal(_getURL('/channel/123', o), processPath('/channel/123')); - assert.equal(_getURL('/channel/123/', o), processPath('/channel/123')); - assert.equal(_getURL('/channel/123?id=456&name=test', o), processPath('/channel/123?id=456&name=test')); - assert.equal(_getURL('/channel/123/?id=456&name=test', o), processPath('/channel/123?id=456&name=test')); + expect(_getURL('', o)).to.be.equal(processPath('')); + expect(_getURL('/', o)).to.be.equal(processPath('')); + expect(_getURL('//', o)).to.be.equal(processPath('')); + expect(_getURL('///', o)).to.be.equal(processPath('')); + expect(_getURL('/channel', o)).to.be.equal(processPath('/channel')); + expect(_getURL('/channel/', o)).to.be.equal(processPath('/channel')); + expect(_getURL('/channel//', o)).to.be.equal(processPath('/channel')); + expect(_getURL('/channel/123', o)).to.be.equal(processPath('/channel/123')); + expect(_getURL('/channel/123/', o)).to.be.equal(processPath('/channel/123')); + expect(_getURL('/channel/123?id=456&name=test', o)).to.be.equal(processPath('/channel/123?id=456&name=test')); + expect(_getURL('/channel/123/?id=456&name=test', o)).to.be.equal(processPath('/channel/123?id=456&name=test')); }; const getCloudUrl = (_site_url, path) => { @@ -89,12 +85,6 @@ const testCases = (options) => { } } } else if (options._cdn_prefix === '') { - if (options.full && !options.cdn && !options.cloud) { - it('should return with host if full: true', () => { - testPaths(options, (path) => _site_url + path); - }); - } - if (!options.full && options.cdn) { it('should return with cloud host if cdn: true', () => { testPaths(options, (path) => getCloudUrl(_site_url, path)); @@ -106,88 +96,54 @@ const testCases = (options) => { testPaths(options, (path) => getCloudUrl(_site_url, path)); }); } - - if (options.full && options.cdn && !options.cloud) { - it('should return with host if full: true and cdn: true', () => { - testPaths(options, (path) => _site_url + path); - }); - } - } else { - if (options.full && !options.cdn && !options.cloud) { - it('should return with host if full: true', () => { - testPaths(options, (path) => _site_url + path); - }); - } - - if (!options.full && options.cdn && !options.cloud) { - it('should return with cdn prefix if cdn: true', () => { - testPaths(options, (path) => options._cdn_prefix + path); - }); - } - - if (!options.full && !options.cdn) { - it('should return with cloud host if full: fase and cdn: false', () => { - testPaths(options, (path) => getCloudUrl(_site_url, path)); - }); - } - - if (options.full && options.cdn && !options.cloud) { - it('should return with host if full: true and cdn: true', () => { - testPaths(options, (path) => options._cdn_prefix + path); - }); - } + } else if (!options.full && !options.cdn) { + it('should return with cloud host if full: fase and cdn: false', () => { + testPaths(options, (path) => getCloudUrl(_site_url, path)); + }); } }; -const testOptions = (options) => { - testCases({ ...options, cdn: false, full: false, cloud: false }); - testCases({ ...options, cdn: true, full: false, cloud: false }); - testCases({ ...options, cdn: false, full: true, cloud: false }); - testCases({ ...options, cdn: false, full: false, cloud: true }); - testCases({ ...options, cdn: true, full: true, cloud: false }); - testCases({ ...options, cdn: false, full: true, cloud: true }); - testCases({ ...options, cdn: true, full: false, cloud: true }); - testCases({ ...options, cdn: true, full: true, cloud: true }); +const testCasesForOptions = (description, options) => { + describe(description, () => { + testCases({ ...options, cdn: false, full: false, cloud: false }); + testCases({ ...options, cdn: true, full: false, cloud: false }); + testCases({ ...options, cdn: false, full: true, cloud: false }); + testCases({ ...options, cdn: false, full: false, cloud: true }); + testCases({ ...options, cdn: true, full: true, cloud: false }); + testCases({ ...options, cdn: false, full: true, cloud: true }); + testCases({ ...options, cdn: true, full: false, cloud: true }); + testCases({ ...options, cdn: true, full: true, cloud: true }); + }); }; describe('getURL', () => { - describe('getURL with no CDN, no PREFIX for http://localhost:3000/', () => { - testOptions({ - _cdn_prefix: '', - _root_url_path_prefix: '', - _site_url: 'http://localhost:3000/', - }); + testCasesForOptions('getURL with no CDN, no PREFIX for http://localhost:3000/', { + _cdn_prefix: '', + _root_url_path_prefix: '', + _site_url: 'http://localhost:3000/', }); - describe('getURL with no CDN, no PREFIX for http://localhost:3000', () => { - testOptions({ - _cdn_prefix: '', - _root_url_path_prefix: '', - _site_url: 'http://localhost:3000', - }); + testCasesForOptions('getURL with no CDN, no PREFIX for http://localhost:3000', { + _cdn_prefix: '', + _root_url_path_prefix: '', + _site_url: 'http://localhost:3000', }); - describe('getURL with CDN, no PREFIX for http://localhost:3000/', () => { - testOptions({ - _cdn_prefix: 'https://cdn.com', - _root_url_path_prefix: '', - _site_url: 'http://localhost:3000/', - }); + testCasesForOptions('getURL with CDN, no PREFIX for http://localhost:3000/', { + _cdn_prefix: 'https://cdn.com', + _root_url_path_prefix: '', + _site_url: 'http://localhost:3000/', }); - describe('getURL with CDN, PREFIX for http://localhost:3000/', () => { - testOptions({ - _cdn_prefix: 'https://cdn.com', - _root_url_path_prefix: 'sub', - _site_url: 'http://localhost:3000/', - }); + testCasesForOptions('getURL with CDN, PREFIX for http://localhost:3000/', { + _cdn_prefix: 'https://cdn.com', + _root_url_path_prefix: 'sub', + _site_url: 'http://localhost:3000/', }); - describe('getURL with CDN, PREFIX for https://localhost:3000/', () => { - testOptions({ - _cdn_prefix: 'https://cdn.com', - _root_url_path_prefix: 'sub', - _site_url: 'https://localhost:3000/', - }); + testCasesForOptions('getURL with CDN, PREFIX for https://localhost:3000/', { + _cdn_prefix: 'https://cdn.com', + _root_url_path_prefix: 'sub', + _site_url: 'https://localhost:3000/', }); }); diff --git a/app/utils/rocketchat.info b/app/utils/rocketchat.info index 292ffbb927c70..5024baa06f80c 100644 --- a/app/utils/rocketchat.info +++ b/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "4.1.0-develop" + "version": "4.3.0-develop" } diff --git a/app/utils/server/functions/normalizeMessageFileUpload.js b/app/utils/server/functions/normalizeMessageFileUpload.js index 450396b0b7651..948dc047c252c 100644 --- a/app/utils/server/functions/normalizeMessageFileUpload.js +++ b/app/utils/server/functions/normalizeMessageFileUpload.js @@ -1,11 +1,11 @@ import { getURL } from '../../lib/getURL'; import { FileUpload } from '../../../file-upload/server'; -import { Uploads } from '../../../models/server'; +import { Uploads } from '../../../models/server/raw'; -export const normalizeMessageFileUpload = (message) => { +export const normalizeMessageFileUpload = async (message) => { if (message.file && !message.fileUpload) { const jwt = FileUpload.generateJWTToFileUrls({ rid: message.rid, userId: message.u._id, fileId: message.file._id }); - const file = Uploads.findOne({ _id: message.file._id }); + const file = await Uploads.findOne({ _id: message.file._id }); if (!file) { return message; } diff --git a/app/utils/server/lib/cron/Cronjobs.ts b/app/utils/server/lib/cron/Cronjobs.ts index 8ab96121e54f8..7223330b9639b 100644 --- a/app/utils/server/lib/cron/Cronjobs.ts +++ b/app/utils/server/lib/cron/Cronjobs.ts @@ -1,12 +1,6 @@ import { SyncedCron } from 'meteor/littledata:synced-cron'; -type ScheduleType = 'cron' | 'text'; - -export interface ICronJobs { - add(name: string, schedule: string, callback: Function, scheduleType?: ScheduleType): void; - remove(name: string): void; - nextScheduledAtDate(name: string): Date | number | undefined; -} +import { ICronJobs, ScheduleType } from '../../../../../definition/ICronJobs'; class SyncedCronJobs implements ICronJobs { add(name: string, schedule: string, callback: Function, scheduleType: ScheduleType = 'cron'): void { diff --git a/app/version-check/server/functions/checkVersionUpdate.js b/app/version-check/server/functions/checkVersionUpdate.js index 65cd246d663cd..9933081a038cd 100644 --- a/app/version-check/server/functions/checkVersionUpdate.js +++ b/app/version-check/server/functions/checkVersionUpdate.js @@ -42,7 +42,7 @@ export default () => { if (update.exists) { Settings.updateValueById('Update_LatestAvailableVersion', update.lastestVersion.version); - sendMessagesToAdmins({ + Promise.await(sendMessagesToAdmins({ msgs: ({ adminUser }) => [{ msg: `*${ TAPi18n.__('Update_your_RocketChat', adminUser.language) }*\n${ TAPi18n.__('New_version_available_(s)', update.lastestVersion.version, adminUser.language) }\n${ update.lastestVersion.infoUrl }` }], banners: [{ id: `versionUpdate-${ update.lastestVersion.version }`.replace(/\./g, '_'), @@ -52,11 +52,11 @@ export default () => { textArguments: [update.lastestVersion.version], link: update.lastestVersion.infoUrl, }], - }); + })); } if (alerts && alerts.length) { - sendMessagesToAdmins({ + Promise.await(sendMessagesToAdmins({ msgs: ({ adminUser }) => alerts .filter((alert) => !Users.bannerExistsById(adminUser._id, `alert-${ alert.id }`)) .map((alert) => ({ @@ -71,6 +71,6 @@ export default () => { modifiers: alert.modifiers, link: alert.infoUrl, })), - }); + })); } }; diff --git a/app/videobridge/client/tabBar.tsx b/app/videobridge/client/tabBar.tsx index d67fc0b88a204..b8ddf4ae6eb07 100644 --- a/app/videobridge/client/tabBar.tsx +++ b/app/videobridge/client/tabBar.tsx @@ -53,12 +53,13 @@ addAction('video', ({ room }) => { const enabledChannel = useSetting('Jitsi_Enable_Channels'); const enabledTeams = useSetting('Jitsi_Enable_Teams'); + const enabledLiveChat = useSetting('Omnichannel_call_provider') === 'Jitsi'; const groups = useStableArray([ 'direct', 'direct_multiple', 'group', - 'live', + enabledLiveChat && 'live', enabledTeams && 'team', enabledChannel && 'channel', ].filter(Boolean) as ToolboxActionConfig['groups']); diff --git a/app/videobridge/server/methods/bbb.js b/app/videobridge/server/methods/bbb.js index 4721dc9842cec..d4c4db288e326 100644 --- a/app/videobridge/server/methods/bbb.js +++ b/app/videobridge/server/methods/bbb.js @@ -1,5 +1,6 @@ import { Meteor } from 'meteor/meteor'; import { HTTP } from 'meteor/http'; +import { check } from 'meteor/check'; import xml2js from 'xml2js'; import BigBlueButtonApi from '../../../bigbluebutton/server'; @@ -7,6 +8,7 @@ import { SystemLogger } from '../../../../server/lib/logger/system'; import { settings } from '../../../settings/server'; import { Rooms, Users } from '../../../models/server'; import { saveStreamingOptions } from '../../../channel-settings/server'; +import { canAccessRoom } from '../../../authorization/server'; import { API } from '../../../api/server'; const parser = new xml2js.Parser({ @@ -24,11 +26,27 @@ const getBBBAPI = () => { Meteor.methods({ bbbJoin({ rid }) { + check(rid, String); + if (!this.userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'bbbJoin' }); } - if (!Meteor.call('canAccessRoom', rid, this.userId)) { + if (!rid) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'bbbJoin' }); + } + + const user = Users.findOneById(this.userId); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'bbbJoin' }); + } + + const room = Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'bbbJoin' }); + } + + if (!canAccessRoom(room, user)) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'bbbJoin' }); } @@ -38,7 +56,6 @@ Meteor.methods({ const { api } = getBBBAPI(); const meetingID = settings.get('uniqueID') + rid; - const room = Rooms.findOneById(rid); const createUrl = api.urlFor('create', { name: room.t === 'd' ? 'Direct' : room.name, meetingID, @@ -56,8 +73,6 @@ Meteor.methods({ const doc = parseString(createResult.content); if (doc.response.returncode[0]) { - const user = Users.findOneById(this.userId); - const hookApi = api.urlFor('hooks/create', { meetingID, callbackURL: Meteor.absoluteUrl(`api/v1/videoconference.bbb.update/${ meetingID }`), @@ -90,11 +105,17 @@ Meteor.methods({ }, bbbEnd({ rid }) { + check(rid, String); + if (!this.userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'bbbEnd' }); } - if (!Meteor.call('canAccessRoom', rid, this.userId)) { + if (!rid) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'bbbEnd' }); + } + + if (!canAccessRoom({ _id: rid }, { _id: this.userId })) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'bbbEnd' }); } diff --git a/app/videobridge/server/methods/jitsiSetTimeout.js b/app/videobridge/server/methods/jitsiSetTimeout.js index bfb109efd3220..096304923a340 100644 --- a/app/videobridge/server/methods/jitsiSetTimeout.js +++ b/app/videobridge/server/methods/jitsiSetTimeout.js @@ -7,6 +7,13 @@ import { metrics } from '../../../metrics/server'; import * as CONSTANTS from '../../constants'; import { canSendMessage } from '../../../authorization/server'; import { SystemLogger } from '../../../../server/lib/logger/system'; +import { settings } from '../../../settings'; + +// TODO: Access Token missing. This is just a partial solution, it doesn't handle access token generation logic as present in this file - client/views/room/contextualBar/Call/Jitsi/CallJitsWithData.js +const resolveJitsiCallUrl = (room) => { + const rname = settings.get('Jitsi_URL_Room_Hash') ? settings.get('uniqueID') + room._id : encodeURIComponent(room.t === 'd' ? room.usernames.join(' x ') : room.name); + return `${ settings.get('Jitsi_SSL') ? 'https://' : 'http://' }${ settings.get('Jitsi_Domain') }/${ settings.get('Jitsi_URL_Room_Prefix') }${ rname }${ settings.get('Jitsi_URL_Room_Suffix') }`; +}; Meteor.methods({ 'jitsi:updateTimeout': (rid, joiningNow = true) => { @@ -43,6 +50,10 @@ Meteor.methods({ actionLinks: [ { icon: 'icon-videocam', label: TAPi18n.__('Click_to_join'), i18nLabel: 'Click_to_join', method_id: 'joinJitsiCall', params: '' }, ], + customFields: { + ...room.customFields && { ...room.customFields }, + ...room.t === 'l' && { jitsiCallUrl: resolveJitsiCallUrl(room) }, // Note: this is just a temporary solution for the jitsi calls to work in Livechat. In future we wish to create specific events for specific to livechat calls (eg: start, accept, decline, end, etc) and this url info will be passed via there + }, }); message.msg = TAPi18n.__('Started_a_video_call'); callbacks.run('afterSaveMessage', message, { ...room, jitsiTimeout: currentTime + CONSTANTS.TIMEOUT }); diff --git a/app/videobridge/server/settings.ts b/app/videobridge/server/settings.ts index 09b14ddc9b656..b5cbdace307b2 100644 --- a/app/videobridge/server/settings.ts +++ b/app/videobridge/server/settings.ts @@ -139,7 +139,7 @@ settingsRegistry.addGroup('Video Conference', function() { public: true, }); - this.add('Jitsi_Open_New_Window', false, { + this.add('Jitsi_Open_New_Window', true, { type: 'boolean', enableQuery: { _id: 'Jitsi_Enabled', diff --git a/app/webdav/client/actionButton.js b/app/webdav/client/actionButton.js index 6b72d87857114..cbfd32da1c3f8 100644 --- a/app/webdav/client/actionButton.js +++ b/app/webdav/client/actionButton.js @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { t, getURL } from '../../utils'; -import { WebdavAccounts } from '../../models'; +import { WebdavAccounts } from '../../models/client'; import { settings } from '../../settings'; import { MessageAction, modal } from '../../ui-utils'; import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; diff --git a/app/webdav/client/selectWebdavAccount.js b/app/webdav/client/selectWebdavAccount.js index 5acb7f43f9c43..14f771822cc40 100644 --- a/app/webdav/client/selectWebdavAccount.js +++ b/app/webdav/client/selectWebdavAccount.js @@ -3,7 +3,7 @@ import { Template } from 'meteor/templating'; import { modal } from '../../ui-utils'; import { t } from '../../utils'; -import { WebdavAccounts } from '../../models'; +import { WebdavAccounts } from '../../models/client'; import { dispatchToastMessage } from '../../../client/lib/toast'; Template.selectWebdavAccount.helpers({ diff --git a/app/webdav/client/startup/messageBoxActions.js b/app/webdav/client/startup/messageBoxActions.js index a209259d4aaa1..be63edecd5992 100644 --- a/app/webdav/client/startup/messageBoxActions.js +++ b/app/webdav/client/startup/messageBoxActions.js @@ -4,7 +4,7 @@ import { Tracker } from 'meteor/tracker'; import { t } from '../../../utils'; import { settings } from '../../../settings'; import { messageBox, modal } from '../../../ui-utils'; -import { WebdavAccounts } from '../../../models'; +import { WebdavAccounts } from '../../../models/client'; messageBox.actions.add('WebDAV', 'Add Server', { id: 'add-webdav', diff --git a/app/webdav/server/lib/webdavClientAdapter.ts b/app/webdav/server/lib/webdavClientAdapter.ts index 038d0907c8fd8..59e4b70f3f2f9 100644 --- a/app/webdav/server/lib/webdavClientAdapter.ts +++ b/app/webdav/server/lib/webdavClientAdapter.ts @@ -1,6 +1,4 @@ -import { createClient } from 'webdav'; - -import type { WebDavClient, Stat } from '../../../../definition/webdav'; +import { createClient, WebDavClient, Stat } from 'webdav'; export type ServerCredentials = { token?: string; diff --git a/app/webdav/server/methods/addWebdavAccount.js b/app/webdav/server/methods/addWebdavAccount.js index 3bfc6aef34955..fdaf715903fac 100644 --- a/app/webdav/server/methods/addWebdavAccount.js +++ b/app/webdav/server/methods/addWebdavAccount.js @@ -1,8 +1,8 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; -import { settings } from '../../../settings'; -import { WebdavAccounts } from '../../../models'; +import { settings } from '../../../settings/server'; +import { WebdavAccounts } from '../../../models/server/raw'; import { WebdavClientAdapter } from '../lib/webdavClientAdapter'; import { Notifications } from '../../../notifications/server'; @@ -24,7 +24,7 @@ Meteor.methods({ pass: String, })); - const duplicateAccount = WebdavAccounts.findOne({ user_id: userId, server_url: formData.serverURL, username: formData.username }); + const duplicateAccount = await WebdavAccounts.findOneByUserIdServerUrlAndUsername({ user_id: userId, server_url: formData.serverURL, username: formData.username }); if (duplicateAccount !== undefined) { throw new Meteor.Error('duplicated-account', { method: 'addWebdavAccount', @@ -49,7 +49,7 @@ Meteor.methods({ }; await client.stat('/'); - WebdavAccounts.insert(accountData); + await WebdavAccounts.insertOne(accountData); Notifications.notifyUser(userId, 'webdav', { type: 'changed', account: accountData, @@ -89,12 +89,14 @@ Meteor.methods({ }; await client.stat('/'); - WebdavAccounts.upsert({ + await WebdavAccounts.updateOne({ user_id: userId, server_url: data.serverURL, name: data.name, }, { $set: accountData, + }, { + upsert: true, }); Notifications.notifyUser(userId, 'webdav', { type: 'changed', diff --git a/app/webdav/server/methods/getFileFromWebdav.js b/app/webdav/server/methods/getFileFromWebdav.js index aeeda2a2636ec..a63d72c047237 100644 --- a/app/webdav/server/methods/getFileFromWebdav.js +++ b/app/webdav/server/methods/getFileFromWebdav.js @@ -2,7 +2,7 @@ import { Meteor } from 'meteor/meteor'; import { settings } from '../../../settings'; import { getWebdavCredentials } from './getWebdavCredentials'; -import { WebdavAccounts } from '../../../models'; +import { WebdavAccounts } from '../../../models/server/raw'; import { WebdavClientAdapter } from '../lib/webdavClientAdapter'; Meteor.methods({ @@ -14,7 +14,7 @@ Meteor.methods({ throw new Meteor.Error('error-not-allowed', 'WebDAV Integration Not Allowed', { method: 'getFileFromWebdav' }); } - const account = WebdavAccounts.findOne({ _id: accountId, user_id: Meteor.userId() }); + const account = await WebdavAccounts.findOneByIdAndUserId(accountId, Meteor.userId()); if (!account) { throw new Meteor.Error('error-invalid-account', 'Invalid WebDAV Account', { method: 'getFileFromWebdav' }); } diff --git a/app/webdav/server/methods/getWebdavFileList.js b/app/webdav/server/methods/getWebdavFileList.js index 52e3c6e3904a2..e9b5e3526d7ce 100644 --- a/app/webdav/server/methods/getWebdavFileList.js +++ b/app/webdav/server/methods/getWebdavFileList.js @@ -2,7 +2,7 @@ import { Meteor } from 'meteor/meteor'; import { settings } from '../../../settings'; import { getWebdavCredentials } from './getWebdavCredentials'; -import { WebdavAccounts } from '../../../models'; +import { WebdavAccounts } from '../../../models/server/raw'; import { WebdavClientAdapter } from '../lib/webdavClientAdapter'; Meteor.methods({ @@ -15,7 +15,7 @@ Meteor.methods({ throw new Meteor.Error('error-not-allowed', 'WebDAV Integration Not Allowed', { method: 'getWebdavFileList' }); } - const account = WebdavAccounts.findOne({ _id: accountId, user_id: Meteor.userId() }); + const account = await WebdavAccounts.findOneByIdAndUserId(accountId, Meteor.userId()); if (!account) { throw new Meteor.Error('error-invalid-account', 'Invalid WebDAV Account', { method: 'getWebdavFileList' }); } diff --git a/app/webdav/server/methods/getWebdavFilePreview.js b/app/webdav/server/methods/getWebdavFilePreview.js index 2de21cae6fef3..5940d44601b41 100644 --- a/app/webdav/server/methods/getWebdavFilePreview.js +++ b/app/webdav/server/methods/getWebdavFilePreview.js @@ -3,7 +3,7 @@ import { createClient } from 'webdav'; import { settings } from '../../../settings'; import { getWebdavCredentials } from './getWebdavCredentials'; -import { WebdavAccounts } from '../../../models'; +import { WebdavAccounts } from '../../../models/server/raw'; Meteor.methods({ async getWebdavFilePreview(accountId, path) { @@ -15,7 +15,7 @@ Meteor.methods({ throw new Meteor.Error('error-not-allowed', 'WebDAV Integration Not Allowed', { method: 'getWebdavFilePreview' }); } - const account = WebdavAccounts.findOne({ _id: accountId, user_id: Meteor.userId() }); + const account = await WebdavAccounts.findOneByIdAndUserId(accountId, Meteor.userId()); if (!account) { throw new Meteor.Error('error-invalid-account', 'Invalid WebDAV Account', { method: 'getWebdavFilePreview' }); } diff --git a/app/webdav/server/methods/removeWebdavAccount.js b/app/webdav/server/methods/removeWebdavAccount.js index 46f8c7b515f70..dc6ba032cfc7e 100644 --- a/app/webdav/server/methods/removeWebdavAccount.js +++ b/app/webdav/server/methods/removeWebdavAccount.js @@ -1,18 +1,18 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; -import { WebdavAccounts } from '../../../models'; +import { WebdavAccounts } from '../../../models/server/raw'; import { Notifications } from '../../../notifications/server'; Meteor.methods({ - removeWebdavAccount(accountId) { + async removeWebdavAccount(accountId) { if (!Meteor.userId()) { throw new Meteor.Error('error-invalid-user', 'Invalid User', { method: 'removeWebdavAccount' }); } check(accountId, String); - const removed = WebdavAccounts.removeByUserAndId(accountId, Meteor.userId()); + const removed = await WebdavAccounts.removeByUserAndId(accountId, Meteor.userId()); if (removed) { Notifications.notifyUser(Meteor.userId(), 'webdav', { type: 'removed', diff --git a/app/webdav/server/methods/uploadFileToWebdav.ts b/app/webdav/server/methods/uploadFileToWebdav.ts index 345550285e7fd..1f794ea0ad481 100644 --- a/app/webdav/server/methods/uploadFileToWebdav.ts +++ b/app/webdav/server/methods/uploadFileToWebdav.ts @@ -3,7 +3,7 @@ import { Meteor } from 'meteor/meteor'; import { settings } from '../../../settings/server'; import { Logger } from '../../../logger/server'; import { getWebdavCredentials } from './getWebdavCredentials'; -import { WebdavAccounts } from '../../../models/server'; +import { WebdavAccounts } from '../../../models/server/raw'; import { WebdavClientAdapter } from '../lib/webdavClientAdapter'; const logger = new Logger('WebDAV_Upload'); @@ -18,7 +18,7 @@ Meteor.methods({ throw new Meteor.Error('error-not-allowed', 'WebDAV Integration Not Allowed', { method: 'uploadFileToWebdav' }); } - const account = WebdavAccounts.findOne({ _id: accountId }); + const account = await WebdavAccounts.findOneById(accountId); if (!account) { throw new Meteor.Error('error-invalid-account', 'Invalid WebDAV Account', { method: 'uploadFileToWebdav' }); } diff --git a/app/webrtc/client/WebRTCClass.js b/app/webrtc/client/WebRTCClass.js index 1d8485b245d45..66e7a23950b2f 100644 --- a/app/webrtc/client/WebRTCClass.js +++ b/app/webrtc/client/WebRTCClass.js @@ -115,7 +115,7 @@ class WebRTCClass { @param room {String} */ - constructor(selfId, room) { + constructor(selfId, room, autoAccept = false) { this.config = { iceServers: [], }; @@ -145,15 +145,15 @@ class WebRTCClass { this.remoteItems = new ReactiveVar([]); this.remoteItemsById = new ReactiveVar({}); this.callInProgress = new ReactiveVar(false); - this.audioEnabled = new ReactiveVar(true); - this.videoEnabled = new ReactiveVar(true); + this.audioEnabled = new ReactiveVar(false); + this.videoEnabled = new ReactiveVar(false); this.overlayEnabled = new ReactiveVar(false); this.screenShareEnabled = new ReactiveVar(false); this.localUrl = new ReactiveVar(); this.active = false; this.remoteMonitoring = false; this.monitor = false; - this.autoAccept = false; + this.autoAccept = autoAccept; this.navigator = undefined; const userAgent = navigator.userAgent.toLocaleLowerCase(); @@ -169,7 +169,7 @@ class WebRTCClass { this.screenShareAvailable = ['chrome', 'firefox', 'electron'].includes(this.navigator); this.media = { - video: false, + video: true, audio: true, }; this.transport = new this.TransportClass(this); @@ -498,11 +498,12 @@ class WebRTCClass { } const onSuccess = (stream) => { this.localStream = stream; + !this.audioEnabled.get() && this.disableAudio(); + !this.videoEnabled.get() && this.disableVideo(); this.localUrl.set(stream); - this.videoEnabled.set(this.media.video === true); - this.audioEnabled.set(this.media.audio === true); const { peerConnections } = this; Object.entries(peerConnections).forEach(([, peerConnection]) => peerConnection.addStream(stream)); + document.querySelector('video#localVideo').srcObject = stream; callback(null, this.localStream); }; const onError = (error) => { @@ -537,19 +538,10 @@ class WebRTCClass { setAudioEnabled(enabled = true) { if (this.localStream != null) { - if (enabled === true && this.media.audio !== true) { - delete this.localStream; - this.media.audio = true; - this.getLocalUserMedia(() => { - this.stopAllPeerConnections(); - this.joinCall(); - }); - } else { - this.localStream.getAudioTracks().forEach(function(audio) { - audio.enabled = enabled; - }); - this.audioEnabled.set(enabled); - } + this.localStream.getAudioTracks().forEach(function(audio) { + audio.enabled = enabled; + }); + this.audioEnabled.set(enabled); } } @@ -561,21 +553,19 @@ class WebRTCClass { this.setAudioEnabled(true); } + toggleAudio() { + if (this.audioEnabled.get()) { + return this.disableAudio(); + } + return this.enableAudio(); + } + setVideoEnabled(enabled = true) { if (this.localStream != null) { - if (enabled === true && this.media.video !== true) { - delete this.localStream; - this.media.video = true; - this.getLocalUserMedia(() => { - this.stopAllPeerConnections(); - this.joinCall(); - }); - } else { - this.localStream.getVideoTracks().forEach(function(video) { - video.enabled = enabled; - }); - this.videoEnabled.set(enabled); - } + this.localStream.getVideoTracks().forEach(function(video) { + video.enabled = enabled; + }); + this.videoEnabled.set(enabled); } } @@ -610,6 +600,13 @@ class WebRTCClass { this.setVideoEnabled(true); } + toggleVideo() { + if (this.videoEnabled.get()) { + return this.disableVideo(); + } + return this.enableVideo(); + } + stop() { this.active = false; this.monitor = false; @@ -663,7 +660,6 @@ class WebRTCClass { onRemoteCall(data) { if (this.autoAccept === true) { - goToRoomById(data.room); Meteor.defer(() => { this.joinCall({ to: data.from, @@ -735,12 +731,6 @@ class WebRTCClass { */ joinCall(data = {}, ...args) { - if (data.media && data.media.audio) { - this.media.audio = data.media.audio; - } - if (data.media && data.media.video) { - this.media.video = data.media.video; - } data.media = this.media; this.log('joinCall', [data, ...args]); this.getLocalUserMedia(() => { @@ -873,6 +863,7 @@ class WebRTCClass { if (peerConnection.iceConnectionState !== 'closed' && peerConnection.iceConnectionState !== 'failed' && peerConnection.iceConnectionState !== 'disconnected' && peerConnection.iceConnectionState !== 'completed') { peerConnection.addIceCandidate(new RTCIceCandidate(data.candidate)); } + document.querySelector('video#remoteVideo').srcObject = this.remoteItems.get()[0]?.url; } @@ -916,27 +907,41 @@ const WebRTC = new class { this.instancesByRoomId = {}; } - getInstanceByRoomId(rid) { - const subscription = ChatSubscription.findOne({ rid }); - if (!subscription) { - return; - } + getInstanceByRoomId(rid, visitorId = null) { let enabled = false; - switch (subscription.t) { - case 'd': - enabled = settings.get('WebRTC_Enable_Direct'); - break; - case 'p': - enabled = settings.get('WebRTC_Enable_Private'); - break; - case 'c': - enabled = settings.get('WebRTC_Enable_Channel'); + if (!visitorId) { + const subscription = ChatSubscription.findOne({ rid }); + if (!subscription) { + return; + } + switch (subscription.t) { + case 'd': + enabled = settings.get('WebRTC_Enable_Direct'); + break; + case 'p': + enabled = settings.get('WebRTC_Enable_Private'); + break; + case 'c': + enabled = settings.get('WebRTC_Enable_Channel'); + break; + case 'l': + enabled = settings.get('Omnichannel_call_provider') === 'WebRTC'; + } + } else { + enabled = settings.get('Omnichannel_call_provider') === 'WebRTC'; } + enabled = enabled && settings.get('WebRTC_Enabled'); if (enabled === false) { return; } if (this.instancesByRoomId[rid] == null) { - this.instancesByRoomId[rid] = new WebRTCClass(Meteor.userId(), rid); + let uid = Meteor.userId(); + let autoAccept = false; + if (visitorId) { + uid = visitorId; + autoAccept = true; + } + this.instancesByRoomId[rid] = new WebRTCClass(uid, rid, autoAccept); } return this.instancesByRoomId[rid]; } diff --git a/app/webrtc/client/actionLink.tsx b/app/webrtc/client/actionLink.tsx new file mode 100644 index 0000000000000..9d31b54b6cefb --- /dev/null +++ b/app/webrtc/client/actionLink.tsx @@ -0,0 +1,27 @@ +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; +import toastr from 'toastr'; + +import { actionLinks } from '../../action-links/client'; +import { APIClient } from '../../utils/client'; +import { Rooms } from '../../models/client'; +import { IMessage } from '../../../definition/IMessage'; +import { Notifications } from '../../notifications/client'; + +actionLinks.register('joinLivechatWebRTCCall', (message: IMessage) => { + const { callStatus, _id } = Rooms.findOne({ _id: message.rid }); + if (callStatus === 'declined' || callStatus === 'ended') { + toastr.info(TAPi18n.__('Call_Already_Ended')); + return; + } + window.open(`/meet/${ _id }`, _id); +}); + +actionLinks.register('endLivechatWebRTCCall', async (message: IMessage) => { + const { callStatus, _id } = Rooms.findOne({ _id: message.rid }); + if (callStatus === 'declined' || callStatus === 'ended') { + toastr.info(TAPi18n.__('Call_Already_Ended')); + return; + } + await APIClient.v1.put(`livechat/webrtc.call/${ message._id }`, {}, { rid: _id, status: 'ended' }); + Notifications.notifyRoom(_id, 'webrtc', 'callStatus', { callStatus: 'ended' }); +}); diff --git a/app/webrtc/client/index.js b/app/webrtc/client/index.js index 305227a2f105e..32ad3e5304231 100644 --- a/app/webrtc/client/index.js +++ b/app/webrtc/client/index.js @@ -1,3 +1,5 @@ import './adapter'; +import './tabBar'; +import './actionLink'; export * from './WebRTCClass'; diff --git a/app/webrtc/client/tabBar.tsx b/app/webrtc/client/tabBar.tsx new file mode 100644 index 0000000000000..1ae17abd4bf12 --- /dev/null +++ b/app/webrtc/client/tabBar.tsx @@ -0,0 +1,26 @@ +import { useMemo, useCallback } from 'react'; + +import { useSetting } from '../../../client/contexts/SettingsContext'; +import { addAction } from '../../../client/views/room/lib/Toolbox'; +import { APIClient } from '../../utils/client'; + +addAction('webRTCVideo', ({ room }) => { + const enabled = useSetting('WebRTC_Enabled') && (useSetting('Omnichannel_call_provider') === 'WebRTC') && room.servedBy; + + const handleClick = useCallback(async (): Promise => { + if (!room.callStatus || room.callStatus === 'declined' || room.callStatus === 'ended') { + await APIClient.v1.get('livechat/webrtc.call', { rid: room._id }); + } + window.open(`/meet/${ room._id }`, room._id); + }, [room._id, room.callStatus]); + + return useMemo(() => (enabled ? { + groups: ['live'], + id: 'webRTCVideo', + title: 'WebRTC_Call', + icon: 'phone', + action: handleClick, + full: true, + order: 4, + } : null), [enabled, handleClick]); +}); diff --git a/app/webrtc/server/settings.ts b/app/webrtc/server/settings.ts index b0cad64d4579c..d94a0c8fde7c0 100644 --- a/app/webrtc/server/settings.ts +++ b/app/webrtc/server/settings.ts @@ -1,24 +1,34 @@ import { settingsRegistry } from '../../settings/server'; settingsRegistry.addGroup('WebRTC', function() { + this.add('WebRTC_Enabled', false, { + type: 'boolean', + group: 'WebRTC', + public: true, + i18nLabel: 'Enabled', + }); this.add('WebRTC_Enable_Channel', false, { type: 'boolean', group: 'WebRTC', public: true, + enableQuery: { _id: 'WebRTC_Enabled', value: true }, }); this.add('WebRTC_Enable_Private', false, { type: 'boolean', group: 'WebRTC', public: true, + enableQuery: { _id: 'WebRTC_Enabled', value: true }, }); this.add('WebRTC_Enable_Direct', false, { type: 'boolean', group: 'WebRTC', public: true, + enableQuery: { _id: 'WebRTC_Enabled', value: true }, }); return this.add('WebRTC_Servers', 'stun:stun.l.google.com:19302, stun:23.21.150.121, team%40rocket.chat:demo@turn:numb.viagenie.ca:3478', { type: 'string', group: 'WebRTC', public: true, + enableQuery: { _id: 'WebRTC_Enabled', value: true }, }); }); diff --git a/client/.eslintrc.js b/client/.eslintrc.js index 4d6bf74449b7b..da196844a21aa 100644 --- a/client/.eslintrc.js +++ b/client/.eslintrc.js @@ -2,7 +2,7 @@ module.exports = { root: true, extends: ['@rocket.chat/eslint-config', 'prettier'], parser: 'babel-eslint', - plugins: ['react', 'react-hooks', 'prettier'], + plugins: ['react', 'react-hooks', 'prettier', 'testing-library'], rules: { 'import/named': 'error', 'import/order': [ @@ -63,6 +63,8 @@ module.exports = { plugins: ['@typescript-eslint', 'react', 'react-hooks', 'prettier'], rules: { '@typescript-eslint/ban-ts-ignore': 'off', + '@typescript-eslint/explicit-function-return-type': 'warn', + // '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/indent': 'off', '@typescript-eslint/interface-name-prefix': ['error', 'always'], '@typescript-eslint/no-extra-parens': 'off', @@ -133,5 +135,25 @@ module.exports = { 'react/no-multi-comp': 'off', }, }, + { + files: ['**/*.tests.js', '**/*.tests.ts', '**/*.spec.ts', '**/*.spec.tsx'], + extends: ['plugin:testing-library/react'], + rules: { + 'testing-library/no-await-sync-events': 'warn', + 'testing-library/no-manual-cleanup': 'warn', + 'testing-library/prefer-explicit-assert': 'warn', + 'testing-library/prefer-user-event': 'warn', + }, + env: { + mocha: true, + }, + }, + { + files: ['**/*.stories.ts', '**/*.stories.tsx'], + rules: { + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + }, + }, ], }; diff --git a/client/components/Card/Title.tsx b/client/components/Card/Title.tsx index 5f547f649d39e..a4bb2db606073 100644 --- a/client/components/Card/Title.tsx +++ b/client/components/Card/Title.tsx @@ -2,7 +2,7 @@ import { Box } from '@rocket.chat/fuselage'; import React, { FC } from 'react'; const Title: FC = ({ children }) => ( - + {children} ); diff --git a/client/components/FilterByText.tsx b/client/components/FilterByText.tsx index 0c06774c6e6dc..170c0c1944adb 100644 --- a/client/components/FilterByText.tsx +++ b/client/components/FilterByText.tsx @@ -6,19 +6,22 @@ import { useTranslation } from '../contexts/TranslationContext'; type FilterByTextProps = { placeholder?: string; onChange: (filter: { text: string }) => void; - displayButton: boolean; + inputRef?: () => void; +}; + +type FilterByTextPropsWithButton = FilterByTextProps & { + displayButton: true; textButton: string; onButtonClick: () => void; - inputRef: () => void; }; +const isFilterByTextPropsWithButton = (props: any): props is FilterByTextPropsWithButton => + 'displayButton' in props && props.displayButton === true; const FilterByText: FC = ({ placeholder, onChange: setFilter, - displayButton: display = false, - textButton = '', - onButtonClick, inputRef, + children: _, ...props }) => { const t = useTranslation(); @@ -53,9 +56,11 @@ const FilterByText: FC = ({ onChange={handleInputChange} value={text} /> - + {isFilterByTextPropsWithButton(props) && ( + + )} ); }; diff --git a/client/components/GenericModal.tsx b/client/components/GenericModal.tsx index a2ca56fe05ccf..ad69cc0ef9138 100644 --- a/client/components/GenericModal.tsx +++ b/client/components/GenericModal.tsx @@ -8,6 +8,7 @@ type VariantType = 'danger' | 'warning' | 'info' | 'success'; type GenericModalProps = RequiredModalProps & { variant?: VariantType; + children?: ReactNode; cancelText?: string; confirmText?: string; title?: string | ReactElement; @@ -75,7 +76,7 @@ const GenericModal: FC = ({ {title ?? t('Are_you_sure')} - {children} + {children} {dontAskAgain} diff --git a/client/components/GenericTable/GenericTable.tsx b/client/components/GenericTable/GenericTable.tsx index 99124d8186fd5..a7070a5094fc9 100644 --- a/client/components/GenericTable/GenericTable.tsx +++ b/client/components/GenericTable/GenericTable.tsx @@ -1,20 +1,23 @@ -import { Box, Pagination, Table, Tile } from '@rocket.chat/fuselage'; +import { Pagination, Tile } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import React, { useState, useEffect, - useCallback, forwardRef, ReactNode, ReactElement, Key, - RefAttributes, + useMemo, + Ref, } from 'react'; import flattenChildren from 'react-keyed-flatten-children'; import { useTranslation } from '../../contexts/TranslationContext'; -import ScrollableContentWrapper from '../ScrollableContentWrapper'; -import LoadingRow from './LoadingRow'; +import { GenericTable as GenericTableV2 } from './V2/GenericTable'; +import { GenericTableBody } from './V2/GenericTableBody'; +import { GenericTableHeader } from './V2/GenericTableHeader'; +import { GenericTableLoadingTable } from './V2/GenericTableLoadingTable'; +import { usePagination } from './hooks/usePagination'; const defaultParamsValue = { text: '', current: 0, itemsPerPage: 25 } as const; const defaultSetParamsValue = (): void => undefined; @@ -37,14 +40,10 @@ type GenericTableProps< pagination?: boolean; } & FilterProps; -const GenericTable: { - < - FilterProps extends { onChange?: (params: GenericTableParams) => void }, - ResultProps extends { _id?: Key }, - >( - props: GenericTableProps & RefAttributes, - ): ReactElement | null; -} = forwardRef(function GenericTable( +const GenericTable = forwardRef(function GenericTable< + FilterProps extends { onChange?: (params: GenericTableParams) => void }, + ResultProps extends { _id?: Key }, +>( { children, fixed = true, @@ -57,41 +56,31 @@ const GenericTable: { total, pagination = true, ...props - }, - ref, + }: GenericTableProps, + ref: Ref, ) { const t = useTranslation(); const [filter, setFilter] = useState(paramsDefault); - const [itemsPerPage, setItemsPerPage] = useState<25 | 50 | 100>(25); - - const [current, setCurrent] = useState(0); + const { + itemsPerPage, + setItemsPerPage, + current, + setCurrent, + itemsPerPageLabel, + showingResultsLabel, + } = usePagination(); const params = useDebouncedValue(filter, 500); useEffect(() => { - setParams({ ...params, current, itemsPerPage }); + setParams({ text: params.text || '', current, itemsPerPage }); }, [params, current, itemsPerPage, setParams]); - const Loading = useCallback(() => { - const headerCells = flattenChildren(header); - return ( - <> - {Array.from({ length: 10 }, (_, i) => ( - - ))} - - ); - }, [header]); - - const showingResultsLabel = useCallback( - ({ count, current, itemsPerPage }) => - t('Showing_results_of', current + 1, Math.min(current + itemsPerPage, count), count), - [t], - ); + const headerCells = useMemo(() => flattenChildren(header).length, [header]); - const itemsPerPageLabel = useCallback(() => t('Items_per_page:'), [t]); + const isLoading = !results; return ( <> @@ -99,33 +88,23 @@ const GenericTable: { ? renderFilter({ ...props, onChange: setFilter } as any) // TODO: ugh : null} {results && !results.length ? ( - + {t('No_data_found')} ) : ( <> - - - - {header && ( - - {header} - - )} - - {RenderRow && - (results ? ( - results.map((props, index) => ( - - )) - ) : ( - - ))} - {children && (results ? results.map(children) : )} - -
-
-
+ + {header && {header}} + + {isLoading && } + {!isLoading && + ((RenderRow && + results?.map((props, index: number) => ( + + ))) || + (children && results?.map(children)))} + + {pagination && ( = ({ icon, title, description, buttonTitle, >
- + {title} - + {description} diff --git a/client/components/GenericTable/V2/GenericTable.tsx b/client/components/GenericTable/V2/GenericTable.tsx new file mode 100644 index 0000000000000..33b23a5f2dead --- /dev/null +++ b/client/components/GenericTable/V2/GenericTable.tsx @@ -0,0 +1,24 @@ +import { Box, Table } from '@rocket.chat/fuselage'; +import React, { forwardRef, ReactNode } from 'react'; + +import ScrollableContentWrapper from '../../ScrollableContentWrapper'; + +type GenericTableProps = { + fixed?: boolean; + children: ReactNode; +}; + +export const GenericTable = forwardRef(function GenericTable( + { fixed = true, children }, + ref, +) { + return ( + + + + {children} +
+
+
+ ); +}); diff --git a/client/components/GenericTable/V2/GenericTableBody.tsx b/client/components/GenericTable/V2/GenericTableBody.tsx new file mode 100644 index 0000000000000..945688f9efafe --- /dev/null +++ b/client/components/GenericTable/V2/GenericTableBody.tsx @@ -0,0 +1,4 @@ +import { Table } from '@rocket.chat/fuselage'; +import React, { FC } from 'react'; + +export const GenericTableBody: FC = (props) => ; diff --git a/client/components/GenericTable/V2/GenericTableCell.tsx b/client/components/GenericTable/V2/GenericTableCell.tsx new file mode 100644 index 0000000000000..883de10b3f3ad --- /dev/null +++ b/client/components/GenericTable/V2/GenericTableCell.tsx @@ -0,0 +1,6 @@ +import { Table } from '@rocket.chat/fuselage'; +import React, { ComponentProps, FC } from 'react'; + +export const GenericTableCell: FC> = (props) => ( + +); diff --git a/client/components/GenericTable/V2/GenericTableHeader.tsx b/client/components/GenericTable/V2/GenericTableHeader.tsx new file mode 100644 index 0000000000000..90f3a340f5a1e --- /dev/null +++ b/client/components/GenericTable/V2/GenericTableHeader.tsx @@ -0,0 +1,10 @@ +import { Table } from '@rocket.chat/fuselage'; +import React, { FC } from 'react'; + +import { GenericTableRow } from './GenericTableRow'; + +export const GenericTableHeader: FC = ({ children, ...props }) => ( + + {children} + +); diff --git a/client/components/GenericTable/V2/GenericTableHeaderCell.tsx b/client/components/GenericTable/V2/GenericTableHeaderCell.tsx new file mode 100644 index 0000000000000..a4db4fbb31600 --- /dev/null +++ b/client/components/GenericTable/V2/GenericTableHeaderCell.tsx @@ -0,0 +1,30 @@ +import { Box, Table } from '@rocket.chat/fuselage'; +import React, { ComponentProps, ReactElement, useCallback } from 'react'; + +import SortIcon from '../SortIcon'; + +type GenericTableHeaderCellProps = Omit, 'onClick'> & { + active?: boolean; + direction?: 'asc' | 'desc'; + sort?: T; + onClick?: (sort: T) => void; +}; + +export const GenericTableHeaderCell = ({ + children, + active, + direction, + sort, + onClick, + ...props +}: GenericTableHeaderCellProps): ReactElement => { + const fn = useCallback(() => onClick && sort && onClick(sort), [sort, onClick]); + return ( + + + {children} + {sort && } + + + ); +}; diff --git a/client/components/GenericTable/V2/GenericTableLoadingRow.tsx b/client/components/GenericTable/V2/GenericTableLoadingRow.tsx new file mode 100644 index 0000000000000..ecc34e2e5e7f3 --- /dev/null +++ b/client/components/GenericTable/V2/GenericTableLoadingRow.tsx @@ -0,0 +1,25 @@ +import { Box, Skeleton, Table } from '@rocket.chat/fuselage'; +import React, { ReactElement } from 'react'; + +type GenericTableLoadingRowRowProps = { + cols: number; +}; + +export const GenericTableLoadingRow = ({ cols }: GenericTableLoadingRowRowProps): ReactElement => ( + + + + + + + + + + + {Array.from({ length: cols - 1 }, (_, i) => ( + + + + ))} + +); diff --git a/client/components/GenericTable/V2/GenericTableLoadingTable.tsx b/client/components/GenericTable/V2/GenericTableLoadingTable.tsx new file mode 100644 index 0000000000000..e7f2501bc7f4d --- /dev/null +++ b/client/components/GenericTable/V2/GenericTableLoadingTable.tsx @@ -0,0 +1,15 @@ +import React, { ReactElement } from 'react'; + +import { GenericTableLoadingRow } from './GenericTableLoadingRow'; + +export const GenericTableLoadingTable = ({ + headerCells, +}: { + headerCells: number; +}): ReactElement => ( + <> + {Array.from({ length: 10 }, (_, i) => ( + + ))} + +); diff --git a/client/components/GenericTable/V2/GenericTableRow.tsx b/client/components/GenericTable/V2/GenericTableRow.tsx new file mode 100644 index 0000000000000..cd31eb47d65c9 --- /dev/null +++ b/client/components/GenericTable/V2/GenericTableRow.tsx @@ -0,0 +1,6 @@ +import { Table } from '@rocket.chat/fuselage'; +import React, { ComponentProps, FC } from 'react'; + +export const GenericTableRow: FC> = (props) => ( + +); diff --git a/client/components/GenericTable/hooks/useCurrent.ts b/client/components/GenericTable/hooks/useCurrent.ts new file mode 100644 index 0000000000000..fc70a9f252d20 --- /dev/null +++ b/client/components/GenericTable/hooks/useCurrent.ts @@ -0,0 +1,9 @@ +import { useState } from 'react'; + +export const useCurrent = ( + currentInitialValue = 0, +): [number, React.Dispatch>] => { + const [current, setCurrent] = useState(currentInitialValue); + + return [current, setCurrent]; +}; diff --git a/client/components/GenericTable/hooks/useItemsPerPage.ts b/client/components/GenericTable/hooks/useItemsPerPage.ts new file mode 100644 index 0000000000000..3d9c36807a61e --- /dev/null +++ b/client/components/GenericTable/hooks/useItemsPerPage.ts @@ -0,0 +1,11 @@ +import { useState } from 'react'; + +type UseItemsPerPageValue = 25 | 50 | 100; + +export const useItemsPerPage = ( + itemsPerPageInitialValue: UseItemsPerPageValue = 25, +): [UseItemsPerPageValue, React.Dispatch>] => { + const [itemsPerPage, setItemsPerPage] = useState(itemsPerPageInitialValue); + + return [itemsPerPage, setItemsPerPage]; +}; diff --git a/client/components/GenericTable/hooks/useItemsPerPageLabel.ts b/client/components/GenericTable/hooks/useItemsPerPageLabel.ts new file mode 100644 index 0000000000000..79e65d88f3702 --- /dev/null +++ b/client/components/GenericTable/hooks/useItemsPerPageLabel.ts @@ -0,0 +1,8 @@ +import { useCallback } from 'react'; + +import { useTranslation } from '../../../contexts/TranslationContext'; + +export const useItemsPerPageLabel = (): (() => string) => { + const t = useTranslation(); + return useCallback(() => t('Items_per_page:'), [t]); +}; diff --git a/client/components/GenericTable/hooks/usePagination.ts b/client/components/GenericTable/hooks/usePagination.ts new file mode 100644 index 0000000000000..3f0558f4ac995 --- /dev/null +++ b/client/components/GenericTable/hooks/usePagination.ts @@ -0,0 +1,30 @@ +import { useCurrent } from './useCurrent'; +import { useItemsPerPage } from './useItemsPerPage'; +import { useItemsPerPageLabel } from './useItemsPerPageLabel'; +import { useShowingResultsLabel } from './useShowingResultsLabel'; + +export const usePagination = (): { + current: ReturnType[0]; + setCurrent: ReturnType[1]; + itemsPerPage: ReturnType[0]; + setItemsPerPage: ReturnType[1]; + itemsPerPageLabel: ReturnType; + showingResultsLabel: ReturnType; +} => { + const [itemsPerPage, setItemsPerPage] = useItemsPerPage(); + + const [current, setCurrent] = useCurrent(); + + const itemsPerPageLabel = useItemsPerPageLabel(); + + const showingResultsLabel = useShowingResultsLabel(); + + return { + itemsPerPage, + setItemsPerPage, + current, + setCurrent, + itemsPerPageLabel, + showingResultsLabel, + }; +}; diff --git a/client/components/GenericTable/hooks/useShowingResultsLabel.ts b/client/components/GenericTable/hooks/useShowingResultsLabel.ts new file mode 100644 index 0000000000000..b7a54ac084f90 --- /dev/null +++ b/client/components/GenericTable/hooks/useShowingResultsLabel.ts @@ -0,0 +1,19 @@ +import { Pagination } from '@rocket.chat/fuselage'; +import { ComponentProps, useCallback } from 'react'; + +import { useTranslation } from '../../../contexts/TranslationContext'; + +type Props< + T extends ComponentProps['showingResultsLabel'] = ComponentProps< + typeof Pagination + >['showingResultsLabel'], +> = T extends (...args: any[]) => any ? Parameters : never; + +export const useShowingResultsLabel = (): ((...params: Props) => string) => { + const t = useTranslation(); + return useCallback( + ({ count, current, itemsPerPage }) => + t('Showing_results_of', current + 1, Math.min(current + itemsPerPage, count), count), + [t], + ); +}; diff --git a/client/components/GenericTable/hooks/useSort.ts b/client/components/GenericTable/hooks/useSort.ts new file mode 100644 index 0000000000000..6858404208fcd --- /dev/null +++ b/client/components/GenericTable/hooks/useSort.ts @@ -0,0 +1,34 @@ +import { useCallback, useState } from 'react'; + +type Direction = 'asc' | 'desc'; + +export const useSort = ( + by: T, + initialDirection: Direction = 'asc', +): { + sortBy: T; + sortDirection: Direction; + setSort: (sortBy: T, direction?: Direction | undefined) => void; +} => { + const [sort, _setSort] = useState<[T, Direction]>(() => [by, initialDirection]); + + const setSort = useCallback((id: T, direction?: Direction | undefined) => { + _setSort(([sortBy, sortDirection]) => { + if (direction) { + return [id, direction]; + } + + if (sortBy === id) { + return [id, sortDirection === 'asc' ? 'desc' : 'asc']; + } + + return [id, 'asc']; + }); + }, []); + + return { + sortBy: sort[0], + sortDirection: sort[1], + setSort, + }; +}; diff --git a/client/components/GenericTable/index.ts b/client/components/GenericTable/index.ts index 8da6df3fc14e1..3a51504430245 100644 --- a/client/components/GenericTable/index.ts +++ b/client/components/GenericTable/index.ts @@ -4,3 +4,12 @@ import HeaderCell from './HeaderCell'; export default Object.assign(GenericTable, { HeaderCell, }); + +export * from './V2/GenericTable'; +export * from './V2/GenericTableBody'; +export * from './V2/GenericTableCell'; +export * from './V2/GenericTableHeader'; +export * from './V2/GenericTableHeaderCell'; +export * from './V2/GenericTableLoadingRow'; +export * from './V2/GenericTableLoadingTable'; +export * from './V2/GenericTableRow'; diff --git a/client/components/Header/Subtitle.tsx b/client/components/Header/Subtitle.tsx index 95b8365c38676..3b7cf9abcca01 100644 --- a/client/components/Header/Subtitle.tsx +++ b/client/components/Header/Subtitle.tsx @@ -2,7 +2,7 @@ import { Box } from '@rocket.chat/fuselage'; import React, { FC } from 'react'; const Subtitle: FC = (props) => ( - + ); export default Subtitle; diff --git a/client/components/Header/Title.tsx b/client/components/Header/Title.tsx index a7797beeaad23..79775233d0406 100644 --- a/client/components/Header/Title.tsx +++ b/client/components/Header/Title.tsx @@ -2,7 +2,7 @@ import { Box } from '@rocket.chat/fuselage'; import React, { FC } from 'react'; const Title: FC = (props) => ( - + ); export default Title; diff --git a/client/components/MarkdownText.tsx b/client/components/MarkdownText.tsx index 4baefbab19d19..82f4e724347ae 100644 --- a/client/components/MarkdownText.tsx +++ b/client/components/MarkdownText.tsx @@ -31,6 +31,7 @@ const listItemMarked = (text: string): string => { const cleanText = text.replace(/|<\/p>/gi, ''); return `

  • ${cleanText}
  • `; }; +const horizontalRuleMarked = (): string => ''; documentRenderer.link = linkMarked; documentRenderer.listitem = listItemMarked; @@ -38,11 +39,13 @@ documentRenderer.listitem = listItemMarked; inlineRenderer.link = linkMarked; inlineRenderer.paragraph = paragraphMarked; inlineRenderer.listitem = listItemMarked; +inlineRenderer.hr = horizontalRuleMarked; inlineWithoutBreaks.link = linkMarked; inlineWithoutBreaks.paragraph = paragraphMarked; inlineWithoutBreaks.br = brMarked; inlineWithoutBreaks.listitem = listItemMarked; +inlineWithoutBreaks.hr = horizontalRuleMarked; const defaultOptions = { gfm: true, diff --git a/client/components/Message/Actions/Action.tsx b/client/components/Message/Actions/Action.tsx index d15aec9f17d02..fa635c4bd167e 100644 --- a/client/components/Message/Actions/Action.tsx +++ b/client/components/Message/Actions/Action.tsx @@ -12,6 +12,7 @@ type ActionOptions = { i18nLabel?: TranslationKey; label?: string; runAction?: RunAction; + danger?: boolean; }; const resolveLegacyIcon = (legacyIcon: string | undefined): string | undefined => { @@ -22,13 +23,22 @@ const resolveLegacyIcon = (legacyIcon: string | undefined): string | undefined = return legacyIcon && legacyIcon.replace(/^icon-/, ''); }; -const Action: FC = ({ id, icon, i18nLabel, label, mid, runAction }) => { +const Action: FC = ({ id, icon, i18nLabel, label, mid, runAction, danger }) => { const t = useTranslation(); const resolvedIcon = resolveLegacyIcon(icon); return ( - diff --git a/client/components/Message/Actions/Actions.tsx b/client/components/Message/Actions/Actions.tsx index 64fb6dbf6a2e0..a6e59d496055d 100644 --- a/client/components/Message/Actions/Actions.tsx +++ b/client/components/Message/Actions/Actions.tsx @@ -14,19 +14,24 @@ type ActionOptions = { i18nLabel?: TranslationKey; label?: string; runAction?: RunAction; + actionLinksAlignment?: string; }; const Actions: FC<{ actions: Array; runAction: RunAction; mid: string }> = ({ actions, runAction, -}) => ( - - - {actions.map((action) => ( - - ))} - - -); +}) => { + const alignment = actions[0]?.actionLinksAlignment || 'center'; + + return ( + + + {actions.map((action) => ( + + ))} + + + ); +}; export default Actions; diff --git a/client/components/Message/Attachments/Attachment/AuthorName.tsx b/client/components/Message/Attachments/Attachment/AuthorName.tsx index 143caf1748a5d..5790dcf040075 100644 --- a/client/components/Message/Attachments/Attachment/AuthorName.tsx +++ b/client/components/Message/Attachments/Attachment/AuthorName.tsx @@ -2,7 +2,7 @@ import { Box } from '@rocket.chat/fuselage'; import React, { ComponentProps, FC } from 'react'; const AuthorName: FC> = (props) => ( - + ); export default AuthorName; diff --git a/client/components/Message/Attachments/Attachment/Details.tsx b/client/components/Message/Attachments/Attachment/Details.tsx index d1961d5d2cfcd..2cdcf2b3e19b8 100644 --- a/client/components/Message/Attachments/Attachment/Details.tsx +++ b/client/components/Message/Attachments/Attachment/Details.tsx @@ -4,7 +4,7 @@ import React, { FC, ComponentProps } from 'react'; const Details: FC> = ({ ...props }) => ( > = (props) => ( - + ); export default Text; diff --git a/client/components/Message/Attachments/FieldsAttachment/Field.tsx b/client/components/Message/Attachments/FieldsAttachment/Field.tsx index 6ed0bcea845b0..635151e2e82dd 100644 --- a/client/components/Message/Attachments/FieldsAttachment/Field.tsx +++ b/client/components/Message/Attachments/FieldsAttachment/Field.tsx @@ -9,7 +9,7 @@ type FieldProps = { const Field: FC = ({ title, value, ...props }) => ( - + {title} {value} diff --git a/client/components/Message/Attachments/components/Load.tsx b/client/components/Message/Attachments/components/Load.tsx index aaf66518292b6..5a901efb99bca 100644 --- a/client/components/Message/Attachments/components/Load.tsx +++ b/client/components/Message/Attachments/components/Load.tsx @@ -22,7 +22,7 @@ const Load: FC = ({ load, ...props }) => { return ( - + {t('Click_to_load')} diff --git a/client/components/Message/Attachments/components/Retry.tsx b/client/components/Message/Attachments/components/Retry.tsx index 341ae63f0b090..3f6351295343c 100644 --- a/client/components/Message/Attachments/components/Retry.tsx +++ b/client/components/Message/Attachments/components/Retry.tsx @@ -22,7 +22,7 @@ const Retry: FC = ({ retry }) => { return ( - + {t('Retry')} diff --git a/client/components/NotAuthorizedPage.tsx b/client/components/NotAuthorizedPage.tsx index aad02ca26c6cb..7de9cb348085a 100644 --- a/client/components/NotAuthorizedPage.tsx +++ b/client/components/NotAuthorizedPage.tsx @@ -10,7 +10,7 @@ const NotAuthorizedPage = (): ReactElement => { return ( - + {t('You_are_not_authorized_to_view_this_page')} diff --git a/client/components/Omnichannel/hooks/useAgentsList.ts b/client/components/Omnichannel/hooks/useAgentsList.ts index 4b3b894ebc54f..d280aa5a0df34 100644 --- a/client/components/Omnichannel/hooks/useAgentsList.ts +++ b/client/components/Omnichannel/hooks/useAgentsList.ts @@ -37,7 +37,7 @@ export const useAgentsList = ( ...(options.text && { text: options.text }), offset: start, count: end + start, - sort: JSON.stringify({ name: 1 }), + sort: `{ "name": 1 }`, }); const items = agents.map((agent: any) => { diff --git a/client/components/Omnichannel/hooks/useDepartmentsList.ts b/client/components/Omnichannel/hooks/useDepartmentsList.ts index 5447c37d1877f..8545873bd9ff0 100644 --- a/client/components/Omnichannel/hooks/useDepartmentsList.ts +++ b/client/components/Omnichannel/hooks/useDepartmentsList.ts @@ -40,7 +40,7 @@ export const useDepartmentsList = ( text: options.filter, offset: start, count: end + start, - sort: JSON.stringify({ name: 1 }), + sort: `{ "name": 1 }`, }); const items = departments diff --git a/client/components/Omnichannel/modals/CloseChatModal.js b/client/components/Omnichannel/modals/CloseChatModal.js index 0c63646b831b0..a53757fce1970 100644 --- a/client/components/Omnichannel/modals/CloseChatModal.js +++ b/client/components/Omnichannel/modals/CloseChatModal.js @@ -67,7 +67,7 @@ const CloseChatModal = ({ department = {}, onCancel, onConfirm }) => { {t('Closing_chat')} - + {t('Close_room_description')} {t('Comment')} diff --git a/client/components/Omnichannel/modals/ForwardChatModal.js b/client/components/Omnichannel/modals/ForwardChatModal.js index 1c8ba7048bf59..0dce6f6e139d6 100644 --- a/client/components/Omnichannel/modals/ForwardChatModal.js +++ b/client/components/Omnichannel/modals/ForwardChatModal.js @@ -89,7 +89,7 @@ const ForwardChatModal = ({ onForward, onCancel, room, ...props }) => { {t('Forward_chat')} - + {t('Forward_to_department')} diff --git a/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx b/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx index 1489118ffe67c..474f25f6c50e4 100644 --- a/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx +++ b/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx @@ -22,7 +22,7 @@ const ReturnChatQueueModal: FC = ({ {t('Return_to_the_queue')} - {t('Would_you_like_to_return_the_queue')} + {t('Would_you_like_to_return_the_queue')} diff --git a/client/components/Omnichannel/modals/TranscriptModal.tsx b/client/components/Omnichannel/modals/TranscriptModal.tsx index 4a5fc1be54e5b..2d872514db11e 100644 --- a/client/components/Omnichannel/modals/TranscriptModal.tsx +++ b/client/components/Omnichannel/modals/TranscriptModal.tsx @@ -76,7 +76,7 @@ const TranscriptModal: FC = ({ {t('Transcript')} - + {!!transcriptRequest &&

    {t('Livechat_transcript_already_requested_warning')}

    } {t('Email')}* diff --git a/client/components/Page/PageHeader.tsx b/client/components/Page/PageHeader.tsx index 64dafe06611b2..8158657bcc3f3 100644 --- a/client/components/Page/PageHeader.tsx +++ b/client/components/Page/PageHeader.tsx @@ -32,7 +32,7 @@ const PageHeader: FC = ({ children = undefined, title, ...props )} - + {title} {children} diff --git a/client/components/PageSkeleton.tsx b/client/components/PageSkeleton.tsx index 5fbef4d1f99d3..a7f7a0a2b2253 100644 --- a/client/components/PageSkeleton.tsx +++ b/client/components/PageSkeleton.tsx @@ -13,7 +13,7 @@ const PageSkeleton = (): ReactElement => ( - + diff --git a/client/components/RoomForeword.js b/client/components/RoomForeword.js index 79784e72cbb22..fac7f41d15c0d 100644 --- a/client/components/RoomForeword.js +++ b/client/components/RoomForeword.js @@ -45,7 +45,7 @@ const RoomForeword = ({ _id: rid }) => { - + {t('Direct_message_you_have_joined')} diff --git a/client/components/Sidebar/Header.js b/client/components/Sidebar/Header.js index 8a7a0ecc98b5f..80854fe21c503 100644 --- a/client/components/Sidebar/Header.js +++ b/client/components/Sidebar/Header.js @@ -13,7 +13,7 @@ const Header = ({ title, onClose, children = undefined, ...props }) => ( flexGrow={1} > {title && ( - + {title} )} diff --git a/client/components/Sidebar/NavigationItem.js b/client/components/Sidebar/NavigationItem.js index 28e7f4f2da21f..eb67d3c391777 100644 --- a/client/components/Sidebar/NavigationItem.js +++ b/client/components/Sidebar/NavigationItem.js @@ -22,7 +22,7 @@ const NavigationItem = ({ return ( {icon && } - + {label}{' '} {tag && ( diff --git a/client/components/SortList/GroupingList.js b/client/components/SortList/GroupingList.js index 28eecf52dc792..5749a8899148c 100644 --- a/client/components/SortList/GroupingList.js +++ b/client/components/SortList/GroupingList.js @@ -28,7 +28,7 @@ function GroupingList() { return ( <> - + {t('Group_by')} diff --git a/client/components/SortList/SortListItem.js b/client/components/SortList/SortListItem.js index e7b980491fb7a..99ae40572c8c8 100644 --- a/client/components/SortList/SortListItem.js +++ b/client/components/SortList/SortListItem.js @@ -14,7 +14,7 @@ function SortListItem({ text, icon, input }) { - + {text} diff --git a/client/components/SortList/SortModeList.js b/client/components/SortList/SortModeList.js index 69680228c4132..0f9521b8fcba7 100644 --- a/client/components/SortList/SortModeList.js +++ b/client/components/SortList/SortModeList.js @@ -24,7 +24,7 @@ function SortModeList() { return ( <> - + {t('Sort_By')} diff --git a/client/components/SortList/ViewModeList.js b/client/components/SortList/ViewModeList.js index 8bc1735f1a04e..9d9cfd72a0ea8 100644 --- a/client/components/SortList/ViewModeList.js +++ b/client/components/SortList/ViewModeList.js @@ -33,7 +33,7 @@ function ViewModeList() { return ( <> - + {t('Display')} diff --git a/client/components/Subtitle.js b/client/components/Subtitle.js index eaace9306f7a4..aa69bd45a788c 100644 --- a/client/components/Subtitle.js +++ b/client/components/Subtitle.js @@ -6,7 +6,7 @@ function Subtitle(props) { ( ({ selector: JSON.stringify({ term }) }); @@ -40,8 +39,15 @@ const UserAutoCompleteMultiple = (props) => { )) } - renderItem={({ value, ...props }) => ( - )} options={options} /> diff --git a/client/components/UserCard/Info.js b/client/components/UserCard/Info.js index b73cf33125e72..282831c404d68 100644 --- a/client/components/UserCard/Info.js +++ b/client/components/UserCard/Info.js @@ -2,7 +2,7 @@ import { Box } from '@rocket.chat/fuselage'; import React from 'react'; const Info = (props) => ( - + ); export default Info; diff --git a/client/components/UserCard/UserCard.js b/client/components/UserCard/UserCard.js index 59c6da1b38060..dbb1779d5572f 100644 --- a/client/components/UserCard/UserCard.js +++ b/client/components/UserCard/UserCard.js @@ -63,7 +63,7 @@ const UserCard = forwardRef(function UserCard( {nickname && ( - + ({nickname}) )} diff --git a/client/components/UserCard/Username.js b/client/components/UserCard/Username.js index 5e7fa18e073cb..90792452e99c0 100644 --- a/client/components/UserCard/Username.js +++ b/client/components/UserCard/Username.js @@ -10,7 +10,7 @@ const Username = ({ name, status = , title, ...props }) => title={title} flexShrink={0} alignItems='center' - fontScale='s2' + fontScale='h4' color='default' withTruncatedText > diff --git a/client/components/UserStatus/Away.js b/client/components/UserStatus/Away.js deleted file mode 100644 index b1240ee485a11..0000000000000 --- a/client/components/UserStatus/Away.js +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -import UserStatus from './UserStatus'; - -const Away = (props) => ; - -export default Away; diff --git a/client/components/UserStatus/Away.tsx b/client/components/UserStatus/Away.tsx new file mode 100644 index 0000000000000..b4d16c96e5a14 --- /dev/null +++ b/client/components/UserStatus/Away.tsx @@ -0,0 +1,7 @@ +import React, { FC } from 'react'; + +import UserStatus from './UserStatus'; + +const Away: FC = (props) => ; + +export default Away; diff --git a/client/components/UserStatus/Busy.js b/client/components/UserStatus/Busy.js deleted file mode 100644 index 432f562279ef4..0000000000000 --- a/client/components/UserStatus/Busy.js +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -import UserStatus from './UserStatus'; - -const Busy = (props) => ; - -export default Busy; diff --git a/client/components/UserStatus/Busy.tsx b/client/components/UserStatus/Busy.tsx new file mode 100644 index 0000000000000..460b30945e55d --- /dev/null +++ b/client/components/UserStatus/Busy.tsx @@ -0,0 +1,7 @@ +import React, { FC } from 'react'; + +import UserStatus from './UserStatus'; + +const Busy: FC = (props) => ; + +export default Busy; diff --git a/client/components/UserStatus/Loading.js b/client/components/UserStatus/Loading.js deleted file mode 100644 index 429cf6b1f64f7..0000000000000 --- a/client/components/UserStatus/Loading.js +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -import UserStatus from './UserStatus'; - -const Loading = (props) => ; - -export default Loading; diff --git a/client/components/UserStatus/Loading.tsx b/client/components/UserStatus/Loading.tsx new file mode 100644 index 0000000000000..85f6b3aeae711 --- /dev/null +++ b/client/components/UserStatus/Loading.tsx @@ -0,0 +1,7 @@ +import React, { FC } from 'react'; + +import UserStatus from './UserStatus'; + +const Loading: FC = (props) => ; + +export default Loading; diff --git a/client/components/UserStatus/Offline.js b/client/components/UserStatus/Offline.js deleted file mode 100644 index 16d4365d148f5..0000000000000 --- a/client/components/UserStatus/Offline.js +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -import UserStatus from './UserStatus'; - -const Offline = (props) => ; - -export default Offline; diff --git a/client/components/UserStatus/Offline.tsx b/client/components/UserStatus/Offline.tsx new file mode 100644 index 0000000000000..3724c02eb0725 --- /dev/null +++ b/client/components/UserStatus/Offline.tsx @@ -0,0 +1,7 @@ +import React, { FC } from 'react'; + +import UserStatus from './UserStatus'; + +const Offline: FC = (props) => ; + +export default Offline; diff --git a/client/components/UserStatus/Online.js b/client/components/UserStatus/Online.js deleted file mode 100644 index 13190b49bd68a..0000000000000 --- a/client/components/UserStatus/Online.js +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -import UserStatus from './UserStatus'; - -const Online = (props) => ; - -export default Online; diff --git a/client/components/UserStatus/Online.tsx b/client/components/UserStatus/Online.tsx new file mode 100644 index 0000000000000..e80afb8d69d54 --- /dev/null +++ b/client/components/UserStatus/Online.tsx @@ -0,0 +1,7 @@ +import React, { FC } from 'react'; + +import UserStatus from './UserStatus'; + +const Online: FC = (props) => ; + +export default Online; diff --git a/client/components/UserStatus/ReactiveUserStatus.js b/client/components/UserStatus/ReactiveUserStatus.tsx similarity index 51% rename from client/components/UserStatus/ReactiveUserStatus.js rename to client/components/UserStatus/ReactiveUserStatus.tsx index 30a0b19242b5c..8fceb19bff716 100644 --- a/client/components/UserStatus/ReactiveUserStatus.js +++ b/client/components/UserStatus/ReactiveUserStatus.tsx @@ -1,9 +1,16 @@ -import React, { memo } from 'react'; +import React, { memo, ReactElement } from 'react'; +import { IUser } from '../../../definition/IUser'; import { usePresence } from '../../hooks/usePresence'; import UserStatus from './UserStatus'; -const ReactiveUserStatus = ({ uid, ...props }) => { +const ReactiveUserStatus = ({ + uid, + ...props +}: { + uid: IUser['_id']; + props: typeof UserStatus; +}): ReactElement => { const status = usePresence(uid)?.status; return ; }; diff --git a/client/components/VerticalBar/VerticalBarHeader.tsx b/client/components/VerticalBar/VerticalBarHeader.tsx index 4f53dbeec16eb..ece29f07c0f8a 100644 --- a/client/components/VerticalBar/VerticalBarHeader.tsx +++ b/client/components/VerticalBar/VerticalBarHeader.tsx @@ -21,7 +21,7 @@ const VerticalBarHeader: FC<{ children: ReactNode; props?: ComponentProps { + const reader = new FileReader(); + reader.onload = function (e) { + callback(e.target.result); + }; + reader.readAsDataURL(file); + }; + const setUploadedPreview = useCallback( async (file, avatarObj) => { setAvatarObj(avatarObj); - setNewAvatarSource(URL.createObjectURL(file)); + toDataURL(file, (dataurl) => { + setNewAvatarSource(dataurl); + }); }, [setAvatarObj], ); @@ -49,7 +59,7 @@ function UserAvatarEditor({ }; return ( - + {t('Profile_picture')} {count} - {variation} + {variation} - + {description} diff --git a/client/components/data/Growth.stories.js b/client/components/data/Growth.stories.js index eb7c9a4cae668..26fea94a2482f 100644 --- a/client/components/data/Growth.stories.js +++ b/client/components/data/Growth.stories.js @@ -16,7 +16,7 @@ export const Zero = () => {0}; export const Negative = () => {-3}; export const WithTextStyle = () => - ['h1', 's1', 'c1', 'micro'].map((fontScale) => ( + ['h2', 's1', 'c1', 'micro'].map((fontScale) => ( {3} {-3} diff --git a/client/contexts/ServerContext/ServerContext.ts b/client/contexts/ServerContext/ServerContext.ts index d791fea0d2d43..838500ef1864f 100644 --- a/client/contexts/ServerContext/ServerContext.ts +++ b/client/contexts/ServerContext/ServerContext.ts @@ -1,8 +1,15 @@ import { createContext, useCallback, useContext, useMemo } from 'react'; -import { IServerInfo } from '../../../definition/IServerInfo'; +import type { IServerInfo } from '../../../definition/IServerInfo'; import type { Serialized } from '../../../definition/Serialized'; -import type { PathFor, Params, Return, Method } from './endpoints'; +import type { + Method, + PathFor, + OperationParams, + MatchPathPattern, + OperationResult, + PathPattern, +} from '../../../definition/rest'; import { ServerMethodFunction, ServerMethodName, @@ -18,11 +25,11 @@ type ServerContextValue = { methodName: MethodName, ...args: ServerMethodParameters ) => Promise>; - callEndpoint: >( - method: M, - path: P, - params: Params[0], - ) => Promise>>; + callEndpoint: >( + method: TMethod, + path: TPath, + params: Serialized>>, + ) => Promise>>>; uploadToEndpoint: (endpoint: string, params: any, formData: any) => Promise; getStream: ( streamName: string, @@ -70,10 +77,16 @@ export const useMethod = ( ); }; -export const useEndpoint = >( - method: M, - path: P, -): ((params: Params[0]) => Promise>>) => { +type EndpointFunction = ( + params: void extends OperationParams + ? void + : Serialized>, +) => Promise>>; + +export const useEndpoint = >( + method: TMethod, + path: TPath, +): EndpointFunction> => { const { callEndpoint } = useContext(ServerContext); return useCallback((params) => callEndpoint(method, path, params), [callEndpoint, path, method]); diff --git a/client/contexts/ServerContext/endpoints.ts b/client/contexts/ServerContext/endpoints.ts deleted file mode 100644 index 0a57ef4479d02..0000000000000 --- a/client/contexts/ServerContext/endpoints.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { ExtractKeys, ValueOf } from '../../../definition/utils'; -import type { EngagementDashboardEndpoints } from '../../../ee/client/contexts/ServerContext/endpoints/v1/engagementDashboard'; -import type { AppsEndpoints } from './endpoints/apps'; -import type { ChannelsEndpoints } from './endpoints/v1/channels'; -import type { ChatEndpoints } from './endpoints/v1/chat'; -import type { CloudEndpoints } from './endpoints/v1/cloud'; -import type { CustomUserStatusEndpoints } from './endpoints/v1/customUserStatus'; -import type { DmEndpoints } from './endpoints/v1/dm'; -import type { DnsEndpoints } from './endpoints/v1/dns'; -import type { EmojiCustomEndpoints } from './endpoints/v1/emojiCustom'; -import type { GroupsEndpoints } from './endpoints/v1/groups'; -import type { ImEndpoints } from './endpoints/v1/im'; -import type { LDAPEndpoints } from './endpoints/v1/ldap'; -import type { LicensesEndpoints } from './endpoints/v1/licenses'; -import type { MiscEndpoints } from './endpoints/v1/misc'; -import type { OmnichannelEndpoints } from './endpoints/v1/omnichannel'; -import type { RoomsEndpoints } from './endpoints/v1/rooms'; -import type { StatisticsEndpoints } from './endpoints/v1/statistics'; -import type { TeamsEndpoints } from './endpoints/v1/teams'; -import type { UsersEndpoints } from './endpoints/v1/users'; - -type Endpoints = ChatEndpoints & - ChannelsEndpoints & - CloudEndpoints & - CustomUserStatusEndpoints & - DmEndpoints & - DnsEndpoints & - EmojiCustomEndpoints & - GroupsEndpoints & - ImEndpoints & - LDAPEndpoints & - RoomsEndpoints & - TeamsEndpoints & - UsersEndpoints & - EngagementDashboardEndpoints & - AppsEndpoints & - OmnichannelEndpoints & - StatisticsEndpoints & - LicensesEndpoints & - MiscEndpoints; - -type Endpoint = UnionizeEndpoints; - -type UnionizeEndpoints = ValueOf< - { - [P in keyof EE]: UnionizeMethods; - } ->; - -type ExtractOperations = ExtractKeys any>; - -type UnionizeMethods = ValueOf< - { - [M in keyof OO as ExtractOperations]: ( - method: M, - path: OO extends { path: string } ? OO['path'] : P, - ...params: Parameters any>> - ) => ReturnType any>>; - } ->; - -export type Method = Parameters[0]; -export type Path = Parameters[1]; - -export type MethodFor

    = P extends any - ? Parameters any>>[0] - : never; -export type PathFor = M extends any - ? Parameters any>>[1] - : never; - -type Operation> = M extends any - ? P extends any - ? Extract any> - : never - : never; - -type ExtractParams = Q extends [any, any] - ? [undefined?] - : Q extends [any, any, any, ...any[]] - ? [Q[2]] - : never; - -export type Params> = ExtractParams< - Parameters> ->; -export type Return> = ReturnType>; diff --git a/client/contexts/ServerContext/endpoints/v1/emojiCustom.ts b/client/contexts/ServerContext/endpoints/v1/emojiCustom.ts deleted file mode 100644 index 63648e6f3e26c..0000000000000 --- a/client/contexts/ServerContext/endpoints/v1/emojiCustom.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ICustomEmojiDescriptor } from '../../../../../definition/ICustomEmojiDescriptor'; - -export type EmojiCustomEndpoints = { - 'emoji-custom.list': { - GET: (params: { query: string }) => { - emojis?: { - update: ICustomEmojiDescriptor[]; - }; - }; - }; - 'emoji-custom.delete': { - POST: (params: { emojiId: ICustomEmojiDescriptor['_id'] }) => void; - }; -}; diff --git a/client/contexts/ServerContext/endpoints/v1/omnichannel.ts b/client/contexts/ServerContext/endpoints/v1/omnichannel.ts deleted file mode 100644 index 4254db71253ad..0000000000000 --- a/client/contexts/ServerContext/endpoints/v1/omnichannel.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { ILivechatDepartment } from '../../../../../definition/ILivechatDepartment'; -import { ILivechatMonitor } from '../../../../../definition/ILivechatMonitor'; -import { ILivechatTag } from '../../../../../definition/ILivechatTag'; -import { IOmnichannelCannedResponse } from '../../../../../definition/IOmnichannelCannedResponse'; -import { IOmnichannelRoom, IRoom } from '../../../../../definition/IRoom'; -import { ISetting } from '../../../../../definition/ISetting'; -import { IUser } from '../../../../../definition/IUser'; - -export type OmnichannelEndpoints = { - 'livechat/appearance': { - GET: () => { - appearance: ISetting[]; - }; - }; - 'livechat/visitors.info': { - GET: (params: { visitorId: string }) => { - visitor: { - visitorEmails: Array<{ - address: string; - }>; - }; - }; - }; - 'livechat/room.onHold': { - POST: (params: { roomId: IRoom['_id'] }) => void; - }; - 'livechat/monitors.list': { - GET: (params: { text: string; offset: number; count: number }) => { - monitors: ILivechatMonitor[]; - total: number; - }; - }; - 'livechat/tags.list': { - GET: (params: { text: string; offset: number; count: number }) => { - tags: ILivechatTag[]; - total: number; - }; - }; - 'livechat/department': { - GET: (params: { - text: string; - offset?: number; - count?: number; - sort?: string; - onlyMyDepartments?: boolean; - }) => { - departments: ILivechatDepartment[]; - total: number; - }; - }; - 'livechat/department/:_id': { - path: `livechat/department/${string}`; - GET: () => { - department: ILivechatDepartment; - }; - }; - 'livechat/departments.by-unit/': { - GET: (params: { text: string; offset: number; count: number }) => { - departments: ILivechatDepartment[]; - total: number; - }; - }; - 'livechat/custom-fields': { - GET: () => { - customFields: [ - { - _id: string; - label: string; - }, - ]; - }; - }; - 'livechat/rooms': { - GET: (params: { - guest: string; - fname: string; - servedBy: string[]; - status: string; - department: string; - from: string; - to: string; - customFields: any; - current: number; - itemsPerPage: number; - tags: string[]; - }) => { - rooms: IOmnichannelRoom[]; - count: number; - offset: number; - total: number; - }; - }; - 'livechat/users/agent': { - GET: (params: { text?: string; offset?: number; count?: number; sort?: string }) => { - users: { - _id: string; - emails: { - address: string; - verified: boolean; - }[]; - status: string; - name: string; - username: string; - statusLivechat: string; - livechat: { - maxNumberSimultaneousChat: number; - }; - }[]; - count: number; - offset: number; - total: number; - }; - }; - 'canned-responses': { - GET: (params: { - shortcut?: string; - text?: string; - scope?: string; - createdBy?: IUser['username']; - tags?: any; - departmentId?: ILivechatDepartment['_id']; - offset?: number; - count?: number; - }) => { - cannedResponses: IOmnichannelCannedResponse[]; - count?: number; - offset?: number; - total: number; - }; - POST: (params: { - _id?: IOmnichannelCannedResponse['_id']; - shortcut: string; - text: string; - scope: string; - tags?: any; - departmentId?: ILivechatDepartment['_id']; - }) => void; - DELETE: (params: { _id: IOmnichannelCannedResponse['_id'] }) => void; - }; - 'canned-responses/:_id': { - path: `canned-responses/${string}`; - GET: () => { - cannedResponse: IOmnichannelCannedResponse; - }; - }; -}; diff --git a/client/contexts/ServerContext/endpoints/v1/teams.ts b/client/contexts/ServerContext/endpoints/v1/teams.ts deleted file mode 100644 index 70f8a7c10b12d..0000000000000 --- a/client/contexts/ServerContext/endpoints/v1/teams.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { IRoom } from '../../../../../definition/IRoom'; -import type { IRecordsWithTotal, ITeam } from '../../../../../definition/ITeam'; -import type { IUser } from '../../../../../definition/IUser'; - -export type TeamsEndpoints = { - 'teams.addRooms': { - POST: (params: { rooms: IRoom['_id'][]; teamId: string }) => void; - }; - 'teams.info': { - GET: (params: { teamId: IRoom['teamId'] }) => { teamInfo: ITeam }; - }; - 'teams.listRooms': { - GET: (params: { - teamId: ITeam['_id']; - offset?: number; - count?: number; - filter: string; - type: string; - }) => Omit, 'records'> & { - count: number; - offset: number; - rooms: IRecordsWithTotal['records']; - }; - }; - 'teams.listRoomsOfUser': { - GET: (params: { - teamId: ITeam['_id']; - teamName?: string; - userId?: string; - canUserDelete?: boolean; - offset?: number; - count?: number; - }) => Omit, 'records'> & { - count: number; - offset: number; - rooms: IRecordsWithTotal['records']; - }; - }; - 'teams.create': { - POST: (params: { - name: ITeam['name']; - type?: ITeam['type']; - members?: IUser['_id'][]; - room: { - id?: string; - name?: IRoom['name']; - members?: IUser['_id'][]; - readOnly?: boolean; - extraData?: { - teamId?: string; - teamMain?: boolean; - } & { [key: string]: string | boolean }; - options?: { - nameValidationRegex?: string; - creator: string; - subscriptionExtra?: { - open: boolean; - ls: Date; - prid: IRoom['_id']; - }; - } & { - [key: string]: - | string - | { - open: boolean; - ls: Date; - prid: IRoom['_id']; - }; - }; - }; - owner?: IUser['_id']; - }) => { - team: ITeam; - }; - }; -}; diff --git a/client/contexts/ServerContext/index.ts b/client/contexts/ServerContext/index.ts index a2807467fddf4..cd41c0b16c179 100644 --- a/client/contexts/ServerContext/index.ts +++ b/client/contexts/ServerContext/index.ts @@ -1,3 +1,2 @@ export * from './ServerContext'; -export * from './endpoints'; export * from './methods'; diff --git a/client/contexts/ServerContext/methods/saveUserPreferences.ts b/client/contexts/ServerContext/methods/saveUserPreferences.ts index 1f5f67100c97f..7e6b20ad0bf64 100644 --- a/client/contexts/ServerContext/methods/saveUserPreferences.ts +++ b/client/contexts/ServerContext/methods/saveUserPreferences.ts @@ -12,7 +12,7 @@ type UserPreferences = { unreadAlert: boolean; notificationsSoundVolume: number; desktopNotifications: string; - mobileNotifications: string; + pushNotifications: string; enableAutoAway: boolean; highlights: string[]; messageViewMode: number; diff --git a/client/hooks/useCallbacks.js b/client/hooks/useCallbacks.js deleted file mode 100644 index b727689643bac..0000000000000 --- a/client/hooks/useCallbacks.js +++ /dev/null @@ -1,3 +0,0 @@ -import { callbacks } from '../../app/callbacks/lib/callbacks'; - -export const useCallbacks = () => callbacks; diff --git a/client/hooks/useEndpointAction.ts b/client/hooks/useEndpointAction.ts index 65a7f2ccaed32..62990514185ca 100644 --- a/client/hooks/useEndpointAction.ts +++ b/client/hooks/useEndpointAction.ts @@ -1,16 +1,24 @@ import { useCallback } from 'react'; import { Serialized } from '../../definition/Serialized'; +import { + MatchPathPattern, + Method, + OperationParams, + OperationResult, + PathFor, +} from '../../definition/rest'; import { useEndpoint } from '../contexts/ServerContext'; -import { Method, Params, PathFor, Return } from '../contexts/ServerContext/endpoints'; import { useToastMessageDispatch } from '../contexts/ToastMessagesContext'; -export const useEndpointAction = >( - method: M, - path: P, - params: Params[0] = {}, +export const useEndpointAction = >( + method: TMethod, + path: TPath, + params: Serialized>> = {} as Serialized< + OperationParams> + >, successMessage?: string, -): ((extraParams?: Params[1]) => Promise>>) => { +): (() => Promise>>>) => { const sendData = useEndpoint(method, path); const dispatchToastMessage = useToastMessageDispatch(); diff --git a/client/hooks/useEndpointActionExperimental.ts b/client/hooks/useEndpointActionExperimental.ts index b6cb286f46aef..1b5420acae277 100644 --- a/client/hooks/useEndpointActionExperimental.ts +++ b/client/hooks/useEndpointActionExperimental.ts @@ -1,15 +1,26 @@ import { useCallback } from 'react'; import { Serialized } from '../../definition/Serialized'; +import { + MatchPathPattern, + Method, + OperationParams, + OperationResult, + PathFor, +} from '../../definition/rest'; import { useEndpoint } from '../contexts/ServerContext'; -import { Method, Params, PathFor, Return } from '../contexts/ServerContext/endpoints'; import { useToastMessageDispatch } from '../contexts/ToastMessagesContext'; -export const useEndpointActionExperimental = >( - method: M, - path: P, +export const useEndpointActionExperimental = < + TMethod extends Method, + TPath extends PathFor, +>( + method: TMethod, + path: TPath, successMessage?: string, -): ((params: Params[0]) => Promise>>) => { +): (( + params: Serialized>>, +) => Promise>>>) => { const sendData = useEndpoint(method, path); const dispatchToastMessage = useToastMessageDispatch(); diff --git a/client/hooks/useEndpointData.ts b/client/hooks/useEndpointData.ts index 6412cd9d7f3ba..38469217d1723 100644 --- a/client/hooks/useEndpointData.ts +++ b/client/hooks/useEndpointData.ts @@ -1,18 +1,26 @@ import { useCallback, useEffect } from 'react'; import { Serialized } from '../../definition/Serialized'; +import { MatchPathPattern, OperationParams, OperationResult, PathFor } from '../../definition/rest'; import { useEndpoint } from '../contexts/ServerContext'; -import { Params, PathFor, Return } from '../contexts/ServerContext/endpoints'; import { useToastMessageDispatch } from '../contexts/ToastMessagesContext'; import { AsyncState, useAsyncState } from './useAsyncState'; -const defaultParams = {}; - -export const useEndpointData =

    >( - endpoint: P, - params: Params<'GET', P>[0] = defaultParams as Params<'GET', P>[0], - initialValue?: Serialized> | (() => Serialized>), -): AsyncState>> & { reload: () => void } => { +export const useEndpointData = >( + endpoint: TPath, + params: void extends OperationParams<'GET', MatchPathPattern> + ? void + : Serialized< + OperationParams<'GET', MatchPathPattern> + > = undefined as void extends OperationParams<'GET', MatchPathPattern> + ? void + : Serialized>>, + initialValue?: + | Serialized>> + | (() => Serialized>>), +): AsyncState>>> & { + reload: () => void; +} => { const { resolve, reject, reset, ...state } = useAsyncState(initialValue); const dispatchToastMessage = useToastMessageDispatch(); const getData = useEndpoint('GET', endpoint); diff --git a/client/hooks/useTimeFromNow.ts b/client/hooks/useTimeFromNow.ts new file mode 100644 index 0000000000000..6ae2b65d2d512 --- /dev/null +++ b/client/hooks/useTimeFromNow.ts @@ -0,0 +1,5 @@ +import moment from 'moment'; +import { useCallback } from 'react'; + +export const useTimeFromNow = (withSuffix: boolean): ((date: Date) => string) => + useCallback((date) => moment(date).fromNow(!withSuffix), [withSuffix]); diff --git a/client/lib/2fa/utils.ts b/client/lib/2fa/utils.ts index 8331bcd0d4abe..148b10683c2db 100644 --- a/client/lib/2fa/utils.ts +++ b/client/lib/2fa/utils.ts @@ -3,13 +3,16 @@ import { Meteor } from 'meteor/meteor'; export const isTotpRequiredError = ( error: unknown, -): error is Meteor.Error & { error: 'totp-required' } => - (error as { error?: unknown } | undefined)?.error === 'totp-required'; +): error is Meteor.Error & ({ error: 'totp-required' } | { errorType: 'totp-required' }) => + typeof error === 'object' && + ((error as { error?: unknown } | undefined)?.error === 'totp-required' || + (error as { errorType?: unknown } | undefined)?.errorType === 'totp-required'); export const isTotpInvalidError = ( error: unknown, -): error is Meteor.Error & { error: 'totp-invalid' } => - (error as { error?: unknown } | undefined)?.error === 'totp-invalid'; +): error is Meteor.Error & ({ error: 'totp-invalid' } | { errorType: 'totp-invalid' }) => + (error as { error?: unknown } | undefined)?.error === 'totp-invalid' || + (error as { errorType?: unknown } | undefined)?.errorType === 'totp-invalid'; export const isLoginCancelledError = (error: unknown): error is Meteor.Error => error instanceof Meteor.Error && error.error === Accounts.LoginCancelledError.numericError; diff --git a/client/lib/RoomManager.ts b/client/lib/RoomManager.ts index 78a4104bddeb1..f10a06c4257e4 100644 --- a/client/lib/RoomManager.ts +++ b/client/lib/RoomManager.ts @@ -132,7 +132,7 @@ export const RoomManager = new (class RoomManager extends Emitter<{ } })(); -const subscribeVistedRooms: Subscription = { +const subscribeVisitedRooms: Subscription = { getCurrentValue: () => RoomManager.visitedRooms(), subscribe(callback) { return RoomManager.on('changed', callback); @@ -166,7 +166,7 @@ export const useHandleRoom = (rid: IRoom['_id']): AsyncState return state; }; -export const useVisitedRooms = (): IRoom['_id'][] => useSubscription(subscribeVistedRooms); +export const useVisitedRooms = (): IRoom['_id'][] => useSubscription(subscribeVisitedRooms); export const useOpenedRoom = (): IRoom['_id'] | undefined => useSubscription(subscribeOpenedRoom); diff --git a/client/lib/createRouteGroup.ts b/client/lib/createRouteGroup.ts index 3b39890facb8a..a1c0d160cf2ab 100644 --- a/client/lib/createRouteGroup.ts +++ b/client/lib/createRouteGroup.ts @@ -1,4 +1,5 @@ -import { FlowRouter } from 'meteor/kadira:flow-router'; +import { FlowRouter, Group, RouteOptions } from 'meteor/kadira:flow-router'; +import { ReactiveVar } from 'meteor/reactive-var'; import { Tracker } from 'meteor/tracker'; import { ComponentType, createElement, lazy, ReactNode } from 'react'; @@ -8,15 +9,82 @@ import { createTemplateForComponent } from './portals/createTemplateForComponent type RouteRegister = { ( path: string, - params: Parameters[1] & - ( - | {} - | { - lazyRouteComponent: () => Promise<{ default: ComponentType }>; - props: Record; - } - ), - ): void; + options: RouteOptions & { + lazyRouteComponent: () => Promise<{ default: ComponentType }>; + props?: Record; + ready?: boolean; + }, + ): [register: () => void, unregister: () => void]; + (path: string, options: RouteOptions): void; +}; + +const registerLazyComponentRoute = ( + routeGroup: Group, + importRouter: () => Promise<{ + default: ComponentType<{ + renderRoute?: () => ReactNode; + }>; + }>, + path: string, + { + lazyRouteComponent, + props, + ready = true, + ...rest + }: RouteOptions & { + lazyRouteComponent: () => Promise<{ default: ComponentType }>; + props?: Record; + ready?: boolean; + }, +): [register: () => void, unregister: () => void] => { + const enabled = new ReactiveVar(ready ? true : undefined); + let computation: Tracker.Computation | undefined; + + const handleEnter = ( + _context: unknown, + _redirect: (pathDef: string) => void, + stop: () => void, + ): void => { + const _enabled = Tracker.nonreactive(() => enabled.get()); + if (_enabled === false) { + stop(); + return; + } + + computation = Tracker.autorun(() => { + const _enabled = enabled.get(); + + if (_enabled === false) { + FlowRouter.go('/'); + } + }); + }; + + const handleExit = (): void => { + computation?.stop(); + }; + + const RouteComponent = lazy(lazyRouteComponent); + const renderRoute = (): ReactNode => createElement(RouteComponent, props); + + routeGroup.route(path, { + ...rest, + triggersEnter: [handleEnter, ...(rest.triggersEnter ?? [])], + triggersExit: [handleExit, ...(rest.triggersExit ?? [])], + action() { + const center = createTemplateForComponent( + Tracker.nonreactive(() => FlowRouter.getRouteName()), + importRouter, + { + attachment: 'at-parent', + props: () => ({ renderRoute }), + }, + ); + appLayout.render('main', { center }); + }, + }); + + return [(): void => enabled.set(true), (): void => enabled.set(false)]; }; export const createRouteGroup = ( @@ -33,32 +101,31 @@ export const createRouteGroup = ( prefix, }); - const registerRoute: RouteRegister = (path, options) => { + function registerRoute( + path: string, + options: RouteOptions & { + lazyRouteComponent: () => Promise<{ default: ComponentType }>; + props?: Record; + ready?: boolean; + }, + ): [register: () => void, unregister: () => void]; + function registerRoute(path: string, options: RouteOptions): void; + function registerRoute( + path: string, + options: + | RouteOptions + | (RouteOptions & { + lazyRouteComponent: () => Promise<{ default: ComponentType }>; + props?: Record; + ready?: boolean; + }), + ): [register: () => void, unregister: () => void] | void { if ('lazyRouteComponent' in options) { - const { lazyRouteComponent, props, ...rest } = options; - - const RouteComponent = lazy(lazyRouteComponent); - const renderRoute = (): ReactNode => createElement(RouteComponent, props); - - routeGroup.route(path, { - ...rest, - action() { - const center = createTemplateForComponent( - Tracker.nonreactive(() => FlowRouter.getRouteName()), - importRouter, - { - attachment: 'at-parent', - props: () => ({ renderRoute }), - }, - ); - appLayout.render('main', { center }); - }, - }); - return; + return registerLazyComponentRoute(routeGroup, importRouter, path, options); } routeGroup.route(path, options); - }; + } registerRoute('/', { name: `${name}-index`, diff --git a/client/lib/createSidebarItems.ts b/client/lib/createSidebarItems.ts index 17f3cc9a3bf85..0a2808e70b4bb 100644 --- a/client/lib/createSidebarItems.ts +++ b/client/lib/createSidebarItems.ts @@ -2,6 +2,9 @@ import type { Subscription } from 'use-subscription'; type SidebarItem = { i18nLabel: string; + href?: string; + icon?: string; + permissionGranted?: boolean | (() => boolean); }; export const createSidebarItems = ( diff --git a/client/lib/download.spec.ts b/client/lib/download.spec.ts index 12a9809afdca3..042495a4ed7e0 100644 --- a/client/lib/download.spec.ts +++ b/client/lib/download.spec.ts @@ -1,85 +1,47 @@ -import 'jsdom-global/register'; -import chai from 'chai'; -import chaiSpies from 'chai-spies'; -import { after, before, describe, it } from 'mocha'; +import { expect, spy } from 'chai'; +import { describe, it } from 'mocha'; import { download, downloadAs, downloadCsvAs, downloadJsonAs } from './download'; -chai.use(chaiSpies); - -const withURL = (): void => { - let createObjectURL: typeof URL.createObjectURL; - let revokeObjectURL: typeof URL.revokeObjectURL; - - before(() => { - const blobs = new Map(); - - createObjectURL = window.URL.createObjectURL; - revokeObjectURL = window.URL.revokeObjectURL; - - window.URL.createObjectURL = (blob: Blob): string => { - const uuid = Math.random().toString(36).slice(2); - const url = `blob://${uuid}`; - blobs.set(url, blob); - return url; - }; - - window.URL.revokeObjectURL = (url: string): void => { - blobs.delete(url); - }; - }); - - after(() => { - window.URL.createObjectURL = createObjectURL; - window.URL.revokeObjectURL = revokeObjectURL; - }); -}; - describe('download', () => { it('should work', () => { - const listener = chai.spy(); + const listener = spy(); document.addEventListener('click', listener, false); download('about:blank', 'blank'); document.removeEventListener('click', listener, false); - chai.expect(listener).to.have.been.called(); + expect(listener).to.have.been.called(); }); }); describe('downloadAs', () => { - withURL(); - it('should work', () => { - const listener = chai.spy(); + const listener = spy(); document.addEventListener('click', listener, false); downloadAs({ data: [] }, 'blank'); document.removeEventListener('click', listener, false); - chai.expect(listener).to.have.been.called(); + expect(listener).to.have.been.called(); }); }); describe('downloadJsonAs', () => { - withURL(); - it('should work', () => { - const listener = chai.spy(); + const listener = spy(); document.addEventListener('click', listener, false); downloadJsonAs({}, 'blank'); document.removeEventListener('click', listener, false); - chai.expect(listener).to.have.been.called(); + expect(listener).to.have.been.called(); }); }); describe('downloadCsvAs', () => { - withURL(); - it('should work', () => { - const listener = chai.spy(); + const listener = spy(); document.addEventListener('click', listener, false); downloadCsvAs( @@ -91,6 +53,6 @@ describe('downloadCsvAs', () => { ); document.removeEventListener('click', listener, false); - chai.expect(listener).to.have.been.called(); + expect(listener).to.have.been.called(); }); }); diff --git a/client/lib/download.ts b/client/lib/download.ts index 8edb28bf2f8b1..b0e361f1ff62a 100644 --- a/client/lib/download.ts +++ b/client/lib/download.ts @@ -37,7 +37,7 @@ export const downloadJsonAs = (jsonObject: unknown, basename: string): void => { ); }; -export const downloadCsvAs = (csvData: unknown[][], basename: string): void => { +export const downloadCsvAs = (csvData: readonly (readonly unknown[])[], basename: string): void => { const escapeCell = (cell: unknown): string => `"${String(cell).replace(/"/g, '""')}"`; const content = csvData.reduce( (content, row) => `${content + row.map(escapeCell).join(';')}\n`, diff --git a/client/lib/meteorCallWrapper.ts b/client/lib/meteorCallWrapper.ts index 4fbb7f4aaeabd..622e2df9b5edb 100644 --- a/client/lib/meteorCallWrapper.ts +++ b/client/lib/meteorCallWrapper.ts @@ -35,7 +35,7 @@ function wrapMeteorDDPCalls(): void { ); const restParams = { - message: DDPCommon.stringifyDDP(message), + message: DDPCommon.stringifyDDP({ ...message }), }; const processResult = (_message: any): void => { @@ -51,7 +51,7 @@ function wrapMeteorDDPCalls(): void { .then(({ message: _message }) => { processResult(_message); if (message.method === 'login') { - const parsedMessage = DDPCommon.parseDDP(_message); + const parsedMessage = DDPCommon.parseDDP(_message) as { result?: { token?: string } }; if (parsedMessage.result?.token) { Meteor.loginWithToken(parsedMessage.result.token); } diff --git a/client/lib/minimongo/bson.spec.ts b/client/lib/minimongo/bson.spec.ts index 1f2c5048f4248..f71a38f95141c 100644 --- a/client/lib/minimongo/bson.spec.ts +++ b/client/lib/minimongo/bson.spec.ts @@ -1,4 +1,4 @@ -import chai from 'chai'; +import { expect } from 'chai'; import { describe, it } from 'mocha'; import { getBSONType, compareBSONValues } from './bson'; @@ -6,32 +6,32 @@ import { BSONType } from './types'; describe('getBSONType', () => { it('should work', () => { - chai.expect(getBSONType(1)).to.be.equals(BSONType.Double); - chai.expect(getBSONType('xyz')).to.be.equals(BSONType.String); - chai.expect(getBSONType({})).to.be.equals(BSONType.Object); - chai.expect(getBSONType([])).to.be.equals(BSONType.Array); - chai.expect(getBSONType(new Uint8Array())).to.be.equals(BSONType.BinData); - chai.expect(getBSONType(undefined)).to.be.equals(BSONType.Object); - chai.expect(getBSONType(null)).to.be.equals(BSONType.Null); - chai.expect(getBSONType(false)).to.be.equals(BSONType.Boolean); - chai.expect(getBSONType(/.*/)).to.be.equals(BSONType.Regex); - chai.expect(getBSONType(() => true)).to.be.equals(BSONType.JavaScript); - chai.expect(getBSONType(new Date(0))).to.be.equals(BSONType.Date); + expect(getBSONType(1)).to.be.equals(BSONType.Double); + expect(getBSONType('xyz')).to.be.equals(BSONType.String); + expect(getBSONType({})).to.be.equals(BSONType.Object); + expect(getBSONType([])).to.be.equals(BSONType.Array); + expect(getBSONType(new Uint8Array())).to.be.equals(BSONType.BinData); + expect(getBSONType(undefined)).to.be.equals(BSONType.Object); + expect(getBSONType(null)).to.be.equals(BSONType.Null); + expect(getBSONType(false)).to.be.equals(BSONType.Boolean); + expect(getBSONType(/.*/)).to.be.equals(BSONType.Regex); + expect(getBSONType(() => true)).to.be.equals(BSONType.JavaScript); + expect(getBSONType(new Date(0))).to.be.equals(BSONType.Date); }); }); describe('compareBSONValues', () => { it('should work for the same types', () => { - chai.expect(compareBSONValues(2, 3)).to.be.equals(-1); - chai.expect(compareBSONValues('xyz', 'abc')).to.be.equals(1); - chai.expect(compareBSONValues({}, {})).to.be.equals(0); - chai.expect(compareBSONValues(true, false)).to.be.equals(1); - chai.expect(compareBSONValues(new Date(0), new Date(1))).to.be.equals(-1); + expect(compareBSONValues(2, 3)).to.be.equals(-1); + expect(compareBSONValues('xyz', 'abc')).to.be.equals(1); + expect(compareBSONValues({}, {})).to.be.equals(0); + expect(compareBSONValues(true, false)).to.be.equals(1); + expect(compareBSONValues(new Date(0), new Date(1))).to.be.equals(-1); }); it('should work for different types', () => { - chai.expect(compareBSONValues(2, null)).to.be.equals(1); - chai.expect(compareBSONValues('xyz', {})).to.be.equals(-1); - chai.expect(compareBSONValues(false, 3)).to.be.equals(1); + expect(compareBSONValues(2, null)).to.be.equals(1); + expect(compareBSONValues('xyz', {})).to.be.equals(-1); + expect(compareBSONValues(false, 3)).to.be.equals(1); }); }); diff --git a/client/lib/minimongo/comparisons.spec.ts b/client/lib/minimongo/comparisons.spec.ts index eb32433d9a81b..3048223f51ac6 100644 --- a/client/lib/minimongo/comparisons.spec.ts +++ b/client/lib/minimongo/comparisons.spec.ts @@ -1,4 +1,4 @@ -import chai from 'chai'; +import { expect } from 'chai'; import { describe, it } from 'mocha'; import { equals, isObject, flatSome, some, isEmptyArray } from './comparisons'; @@ -6,57 +6,57 @@ import { equals, isObject, flatSome, some, isEmptyArray } from './comparisons'; describe('Comparisons service', () => { describe('equals', () => { it('should return true if two numbers are equal', () => { - chai.expect(equals(1, 1)).to.be.equal(true); + expect(equals(1, 1)).to.be.equal(true); }); it('should return false if arguments are null or undefined', () => { - chai.expect(equals(undefined, null)).to.be.equal(false); - chai.expect(equals(null, undefined)).to.be.equal(false); + expect(equals(undefined, null)).to.be.equal(false); + expect(equals(null, undefined)).to.be.equal(false); }); it('should return false if arguments arent objects and they are not the same', () => { - chai.expect(equals('not', 'thesame')).to.be.equal(false); + expect(equals('not', 'thesame')).to.be.equal(false); }); it('should return true if date objects provided have the same value', () => { const currentDate = new Date(); - chai.expect(equals(currentDate, currentDate)).to.be.equal(true); + expect(equals(currentDate, currentDate)).to.be.equal(true); }); it('should return true if 2 equal UInt8Array are provided', () => { const arr1 = new Uint8Array([1, 2]); const arr2 = new Uint8Array([1, 2]); - chai.expect(equals(arr1, arr2)).to.be.equal(true); + expect(equals(arr1, arr2)).to.be.equal(true); }); it('should return true if 2 equal arrays are provided', () => { const arr1 = [1, 2, 4]; const arr2 = [1, 2, 4]; - chai.expect(equals(arr1, arr2)).to.be.equal(true); + expect(equals(arr1, arr2)).to.be.equal(true); }); it('should return false if 2 arrays with different length are provided', () => { const arr1 = [1, 4, 5]; const arr2 = [1, 4, 5, 7]; - chai.expect(equals(arr1, arr2)).to.be.equal(false); + expect(equals(arr1, arr2)).to.be.equal(false); }); it('should return true if the objects provided are "equal"', () => { const obj = { a: 1 }; const obj2 = obj; - chai.expect(equals(obj, obj2)).to.be.equal(true); + expect(equals(obj, obj2)).to.be.equal(true); }); it('should return true if both objects have the same keys', () => { const obj = { a: 1 }; const obj2 = { a: 1 }; - chai.expect(equals(obj, obj2)).to.be.equal(true); + expect(equals(obj, obj2)).to.be.equal(true); }); }); @@ -65,14 +65,14 @@ describe('Comparisons service', () => { const obj = {}; const func = (a: any): any => a; - chai.expect(isObject(obj)).to.be.equal(true); - chai.expect(isObject(func)).to.be.equal(true); + expect(isObject(obj)).to.be.equal(true); + expect(isObject(func)).to.be.equal(true); }); it('should return false for other data types', () => { - chai.expect(isObject(1)).to.be.equal(false); - chai.expect(isObject(true)).to.be.equal(false); - chai.expect(isObject('212')).to.be.equal(false); + expect(isObject(1)).to.be.equal(false); + expect(isObject(true)).to.be.equal(false); + expect(isObject('212')).to.be.equal(false); }); }); @@ -81,14 +81,14 @@ describe('Comparisons service', () => { const arr = [1, 2, 4, 6, 9]; const isEven = (v: number): boolean => v % 2 === 0; - chai.expect(flatSome(arr, isEven)).to.be.equal(true); + expect(flatSome(arr, isEven)).to.be.equal(true); }); it('should run the function on the value when its not an array', () => { const val = 1; const isEven = (v: number): boolean => v % 2 === 0; - chai.expect(flatSome(val, isEven)).to.be.equal(false); + expect(flatSome(val, isEven)).to.be.equal(false); }); }); @@ -102,7 +102,7 @@ describe('Comparisons service', () => { return v % 2 === 0; }; - chai.expect(some(arr, isEven)).to.be.equal(true); + expect(some(arr, isEven)).to.be.equal(true); }); it('should run the function on the value when its not an array', () => { @@ -114,21 +114,21 @@ describe('Comparisons service', () => { return v % 2 === 0; }; - chai.expect(some(val, isEven)).to.be.equal(false); + expect(some(val, isEven)).to.be.equal(false); }); }); describe('isEmptyArray', () => { it('should return true if array is empty', () => { - chai.expect(isEmptyArray([])).to.be.equal(true); + expect(isEmptyArray([])).to.be.equal(true); }); it('should return false if value is not an array', () => { - chai.expect(isEmptyArray(1)).to.be.equal(false); + expect(isEmptyArray(1)).to.be.equal(false); }); it('should return false if array is not empty', () => { - chai.expect(isEmptyArray([1, 2])).to.be.equal(false); + expect(isEmptyArray([1, 2])).to.be.equal(false); }); }); }); diff --git a/client/lib/minimongo/lookups.spec.ts b/client/lib/minimongo/lookups.spec.ts index 1056a3d7f5c89..3bae10346b628 100644 --- a/client/lib/minimongo/lookups.spec.ts +++ b/client/lib/minimongo/lookups.spec.ts @@ -1,15 +1,17 @@ -import chai from 'chai'; +import { expect } from 'chai'; import { describe, it } from 'mocha'; import { createLookupFunction } from './lookups'; describe('createLookupFunction', () => { it('should work', () => { - chai.expect(createLookupFunction('a.x')({ a: { x: 1 } })).to.be.deep.equals([1]); - chai.expect(createLookupFunction('a.x')({ a: { x: [1] } })).to.be.deep.equals([[1]]); - chai.expect(createLookupFunction('a.x')({ a: 5 })).to.be.deep.equals([undefined]); - chai - .expect(createLookupFunction('a.x')({ a: [{ x: 1 }, { x: [2] }, { y: 3 }] })) - .to.be.deep.equals([1, [2], undefined]); + expect(createLookupFunction('a.x')({ a: { x: 1 } })).to.be.deep.equals([1]); + expect(createLookupFunction('a.x')({ a: { x: [1] } })).to.be.deep.equals([[1]]); + expect(createLookupFunction('a.x')({ a: 5 })).to.be.deep.equals([undefined]); + expect(createLookupFunction('a.x')({ a: [{ x: 1 }, { x: [2] }, { y: 3 }] })).to.be.deep.equals([ + 1, + [2], + undefined, + ]); }); }); diff --git a/client/lib/queryClient.ts b/client/lib/queryClient.ts new file mode 100644 index 0000000000000..0fd037d304c0d --- /dev/null +++ b/client/lib/queryClient.ts @@ -0,0 +1,3 @@ +import { QueryClient } from 'react-query'; + +export const queryClient = new QueryClient(); diff --git a/client/lib/userData.ts b/client/lib/userData.ts index 2b37d1957f5a1..76488ead80137 100644 --- a/client/lib/userData.ts +++ b/client/lib/userData.ts @@ -5,14 +5,39 @@ import { Users } from '../../app/models/client'; import { Notifications } from '../../app/notifications/client'; import { APIClient } from '../../app/utils/client'; import type { IUser, IUserDataEvent } from '../../definition/IUser'; +import { Serialized } from '../../definition/Serialized'; export const isSyncReady = new ReactiveVar(false); -type RawUserData = Omit & { - _updatedAt: string; -}; +type RawUserData = Serialized< + Pick< + IUser, + | '_id' + | 'type' + | 'name' + | 'username' + | 'emails' + | 'status' + | 'statusDefault' + | 'statusText' + | 'statusConnection' + | 'avatarOrigin' + | 'utcOffset' + | 'language' + | 'settings' + | 'roles' + | 'active' + | 'defaultRoom' + | 'customFields' + | 'statusLivechat' + | 'oauth' + | 'createdAt' + | '_updatedAt' + | 'avatarETag' + > +>; -const updateUser = (userData: IUser & { _updatedAt: Date }): void => { +const updateUser = (userData: IUser): void => { const user: IUser = Users.findOne({ _id: userData._id }); if (!user || !user._updatedAt || user._updatedAt.getTime() < userData._updatedAt.getTime()) { @@ -57,6 +82,7 @@ export const synchronizeUserData = async (uid: Meteor.User['_id']): Promise( }); }); -const callEndpoint = >( - method: M, - path: P, - params: Params[0], -): Promise>> => { +const callEndpoint = >( + method: TMethod, + path: TPath, + params: Serialized>>, +): Promise>>> => { const api = path[0] === '/' ? APIClient : APIClient.v1; const endpointPath = path[0] === '/' ? path.slice(1) : path; @@ -62,18 +65,6 @@ const uploadToEndpoint = (endpoint: string, params: any, formData: any): Promise return APIClient.v1.upload(endpoint, params, formData).promise; }; -declare module 'meteor/meteor' { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Meteor { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace StreamerCentral { - const instances: { - [name: string]: typeof Meteor.Streamer; - }; - } - } -} - const getStream = ( streamName: string, options: {} = {}, diff --git a/client/providers/UserProvider.tsx b/client/providers/UserProvider.tsx index 45a91b6adf97a..ebf37f035fb7e 100644 --- a/client/providers/UserProvider.tsx +++ b/client/providers/UserProvider.tsx @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import React, { useMemo, FC } from 'react'; -import { callbacks } from '../../app/callbacks/client'; +import { callbacks } from '../../app/callbacks/lib/callbacks'; import { Subscriptions, Rooms } from '../../app/models/client'; import { getUserPreference } from '../../app/utils/client'; import { IRoom } from '../../definition/IRoom'; diff --git a/client/sidebar/header/EditStatusModal.tsx b/client/sidebar/header/EditStatusModal.tsx index d16eab0f96907..981114746acf4 100644 --- a/client/sidebar/header/EditStatusModal.tsx +++ b/client/sidebar/header/EditStatusModal.tsx @@ -68,7 +68,7 @@ const EditStatusModal = ({ {t('Edit_Status')} - + {t('StatusMessage')} diff --git a/client/sidebar/header/UserDropdown.js b/client/sidebar/header/UserDropdown.tsx similarity index 70% rename from client/sidebar/header/UserDropdown.js rename to client/sidebar/header/UserDropdown.tsx index c9a058bb50ebc..bccce0352a39b 100644 --- a/client/sidebar/header/UserDropdown.js +++ b/client/sidebar/header/UserDropdown.tsx @@ -1,11 +1,13 @@ import { Box, Margins, Divider, Option } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { FlowRouter } from 'meteor/kadira:flow-router'; -import React from 'react'; +import React, { ReactElement } from 'react'; -import { callbacks } from '../../../app/callbacks/client'; +import { callbacks } from '../../../app/callbacks/lib/callbacks'; import { popover, AccountBox, SideNav } from '../../../app/ui-utils/client'; import { userStatus } from '../../../app/user-status/client'; +import { IUser } from '../../../definition/IUser'; +import { UserStatus as UserStatusEnum } from '../../../definition/UserStatus'; import MarkdownText from '../../components/MarkdownText'; import { UserStatus } from '../../components/UserStatus'; import UserAvatar from '../../components/avatar/UserAvatar'; @@ -16,6 +18,7 @@ import { useSetting } from '../../contexts/SettingsContext'; import { useTranslation } from '../../contexts/TranslationContext'; import { useLogout } from '../../contexts/UserContext'; import { useReactiveValue } from '../../hooks/useReactiveValue'; +import { useUserDisplayName } from '../../hooks/useUserDisplayName'; import { imperativeModal } from '../../lib/imperativeModal'; import EditStatusModal from './EditStatusModal'; @@ -34,34 +37,44 @@ const ADMIN_PERMISSIONS = [ 'manage-incoming-integrations', 'manage-own-outgoing-integrations', 'manage-own-incoming-integrations', + 'view-engagement-dashboard', ]; -const style = { - marginLeft: '-16px', - marginRight: '-16px', -}; - -const setStatus = (status) => { +const setStatus = (status: IUser['status']): void => { AccountBox.setStatus(status); callbacks.run('userStatusManuallySet', status); }; -const getItems = () => AccountBox.getItems(); +const getItems = (): ReturnType => AccountBox.getItems(); + +const translateStatusName = (t: ReturnType, name: string): string => { + const isDefaultStatus = (name: string): name is UserStatusEnum => name in UserStatusEnum; + if (isDefaultStatus(name)) { + return t(name); + } + + return name; +}; + +type UserDropdownProps = { + user: IUser; + onClose: () => void; +}; -const UserDropdown = ({ user, onClose }) => { +const UserDropdown = ({ user, onClose }: UserDropdownProps): ReactElement => { const t = useTranslation(); const accountRoute = useRoute('account'); const adminRoute = useRoute('admin'); + const logout = useLogout(); const { sidebar } = useLayout(); - const logout = useLogout(); + const { username, avatarETag, status, statusText } = user; - const { name, username, avatarETag, status, statusText } = user; + const displayName = useUserDisplayName(user); - const useRealName = useSetting('UI_Use_Real_Name'); const filterInvisibleStatus = !useSetting('Accounts_AllowInvisibleStatusOption') - ? (key) => userStatus.list[key].name !== 'invisible' - : () => true; + ? (key: keyof typeof userStatus['list']): boolean => userStatus.list[key].name !== 'invisible' + : (): boolean => true; const showAdmin = useAtLeastOnePermission(ADMIN_PERMISSIONS); @@ -96,7 +109,7 @@ const UserDropdown = ({ user, onClose }) => { - + { display='flex' overflow='hidden' flexDirection='column' - fontScale='p1' + fontScale='p3' mb='neg-x4' flexGrow={1} flexShrink={1} @@ -113,18 +126,21 @@ const UserDropdown = ({ user, onClose }) => { - {useRealName ? name || username : username} + {displayName} - - + + - -

    + {t('Status')} @@ -132,16 +148,16 @@ const UserDropdown = ({ user, onClose }) => { .filter(filterInvisibleStatus) .map((key, i) => { const status = userStatus.list[key]; - const name = status.localizeName ? t(status.name) : status.name; + const name = status.localizeName ? translateStatusName(t, status.name) : status.name; const modifier = status.statusType || user.status; return ( ); })} -
    + +
    {(accountBoxItems.length || showAdmin) && ( <> -
    + {showAdmin && ( - )} {accountBoxItems.map((item, i) => { let action; if (item.href || item.sideNav) { - action = () => { + action = (): void => { if (item.href) { FlowRouter.go(item.href); popover.close(); @@ -177,17 +197,19 @@ const UserDropdown = ({ user, onClose }) => { }; } - return + ); })} -
    +
    )} -
    -
    + + + +
    ); }; diff --git a/client/sidebar/header/actions/CreateRoomListItem.js b/client/sidebar/header/actions/CreateRoomListItem.js index 23ebf864e5da7..8c42f2d3aff3c 100644 --- a/client/sidebar/header/actions/CreateRoomListItem.js +++ b/client/sidebar/header/actions/CreateRoomListItem.js @@ -14,7 +14,7 @@ export default function CreateRoomListItem({ text, icon, action }) { - + {text} diff --git a/client/sidebar/sections/Omnichannel.tsx b/client/sidebar/sections/Omnichannel.tsx index 04e9d12bdfe21..616e36ebb345e 100644 --- a/client/sidebar/sections/Omnichannel.tsx +++ b/client/sidebar/sections/Omnichannel.tsx @@ -2,17 +2,19 @@ import { Box, Sidebar } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import React, { memo, ReactElement, useState, useCallback, useRef } from 'react'; +import { hasPermission } from '../../../app/authorization/client'; import { ClientLogger } from '../../../lib/ClientLogger'; import { VoipEvents } from '../../components/voip/SimpleVoipUser'; +import { useLayout } from '../../contexts/LayoutContext'; import { useIsVoipLibReady, useVoipUser, useOmnichannelShowQueueLink, useOmnichannelQueueLink, - useOmnichannelDirectoryLink, useOmnichannelAgentAvailable, useOmnichannelVoipCallAvailable, } from '../../contexts/OmnichannelContext'; +import { useRoute } from '../../contexts/RouterContext'; import { useMethod } from '../../contexts/ServerContext'; import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext'; import { useTranslation } from '../../contexts/TranslationContext'; @@ -27,10 +29,10 @@ const OmnichannelSection = (props: typeof Box): ReactElement => { const voipLibIsReady = useIsVoipLibReady(); const voipLib = useVoipUser(); const agentAvailable = useOmnichannelAgentAvailable(); - + const { sidebar } = useLayout(); const showOmnichannelQueueLink = useOmnichannelShowQueueLink(); const queueLink = useOmnichannelQueueLink(); - const directoryLink = useOmnichannelDirectoryLink(); + const directoryRoute = useRoute('omnichannel-directory'); const voipCallIcon = { title: !registered ? t('Enable') : t('Disable'), @@ -106,6 +108,11 @@ const OmnichannelSection = (props: typeof Box): ReactElement => { onUnregistrationError, ]); + const handleDirectory = useMutableCallback(() => { + sidebar.toggle(); + directoryRoute.push({}); + }); + return ( {t('Omnichannel')} @@ -115,7 +122,9 @@ const OmnichannelSection = (props: typeof Box): ReactElement => { )} - + {hasPermission(['view-omnichannel-contact-center']) && ( + + )}{' '} ); diff --git a/client/startup/banners.ts b/client/startup/banners.ts index 57e0d9c7daa9e..64ba23041accc 100644 --- a/client/startup/banners.ts +++ b/client/startup/banners.ts @@ -4,14 +4,15 @@ import { Tracker } from 'meteor/tracker'; import { Notifications } from '../../app/notifications/client'; import { APIClient } from '../../app/utils/client'; import { IBanner, BannerPlatform } from '../../definition/IBanner'; +import { Serialized } from '../../definition/Serialized'; import * as banners from '../lib/banners'; const fetchInitialBanners = async (): Promise => { - const response = (await APIClient.get('v1/banners', { - platform: BannerPlatform.Web, - })) as { + const response: Serialized<{ banners: IBanner[]; - }; + }> = await APIClient.get('v1/banners', { + platform: BannerPlatform.Web, + }); for (const banner of response.banners) { banners.open({ @@ -22,11 +23,11 @@ const fetchInitialBanners = async (): Promise => { }; const handleBanner = async (event: { bannerId: string }): Promise => { - const response = (await APIClient.get(`v1/banners/${event.bannerId}`, { - platform: BannerPlatform.Web, - })) as { + const response: Serialized<{ banners: IBanner[]; - }; + }> = await APIClient.get(`v1/banners/${event.bannerId}`, { + platform: BannerPlatform.Web, + }); if (!response.banners.length) { return banners.closeById(event.bannerId); diff --git a/client/startup/renderMessage/autolinker.ts b/client/startup/renderMessage/autolinker.ts index bf240aab3ea76..174da6e915a0c 100644 --- a/client/startup/renderMessage/autolinker.ts +++ b/client/startup/renderMessage/autolinker.ts @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { callbacks } from '../../../app/callbacks/client'; +import { callbacks } from '../../../app/callbacks/lib/callbacks'; import { settings } from '../../../app/settings/client'; Meteor.startup(() => { diff --git a/client/startup/renderMessage/autotranslate.ts b/client/startup/renderMessage/autotranslate.ts index 9142d4670a364..be727cd743db5 100644 --- a/client/startup/renderMessage/autotranslate.ts +++ b/client/startup/renderMessage/autotranslate.ts @@ -2,7 +2,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; import { hasPermission } from '../../../app/authorization/client'; -import { callbacks } from '../../../app/callbacks/client'; +import { callbacks } from '../../../app/callbacks/lib/callbacks'; import { settings } from '../../../app/settings/client'; Meteor.startup(() => { diff --git a/client/startup/renderMessage/emoji.ts b/client/startup/renderMessage/emoji.ts index a5abc9d827739..ddb3bf318b45f 100644 --- a/client/startup/renderMessage/emoji.ts +++ b/client/startup/renderMessage/emoji.ts @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { callbacks } from '../../../app/callbacks/client'; +import { callbacks } from '../../../app/callbacks/lib/callbacks'; import { getUserPreference } from '../../../app/utils/client'; Meteor.startup(() => { diff --git a/client/startup/renderMessage/hexcolor.ts b/client/startup/renderMessage/hexcolor.ts index c24b9dc559e96..aba80d6f0a0ac 100644 --- a/client/startup/renderMessage/hexcolor.ts +++ b/client/startup/renderMessage/hexcolor.ts @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { callbacks } from '../../../app/callbacks/client'; +import { callbacks } from '../../../app/callbacks/lib/callbacks'; import { settings } from '../../../app/settings/client'; Meteor.startup(() => { diff --git a/client/startup/renderMessage/highlightWords.ts b/client/startup/renderMessage/highlightWords.ts index 928565d238c54..d95887d18b3aa 100644 --- a/client/startup/renderMessage/highlightWords.ts +++ b/client/startup/renderMessage/highlightWords.ts @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { callbacks } from '../../../app/callbacks/client'; +import { callbacks } from '../../../app/callbacks/lib/callbacks'; import { getUserPreference } from '../../../app/utils/client'; Meteor.startup(() => { diff --git a/client/startup/renderMessage/issuelink.ts b/client/startup/renderMessage/issuelink.ts index 7a465e15b19a8..643615a6b6845 100644 --- a/client/startup/renderMessage/issuelink.ts +++ b/client/startup/renderMessage/issuelink.ts @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { callbacks } from '../../../app/callbacks/client'; +import { callbacks } from '../../../app/callbacks/lib/callbacks'; import { settings } from '../../../app/settings/client'; Meteor.startup(() => { diff --git a/client/startup/renderMessage/katex.ts b/client/startup/renderMessage/katex.ts index 4f5d042d1b3a5..d48bb471a9b55 100644 --- a/client/startup/renderMessage/katex.ts +++ b/client/startup/renderMessage/katex.ts @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { callbacks } from '../../../app/callbacks/client'; +import { callbacks } from '../../../app/callbacks/lib/callbacks'; import { settings } from '../../../app/settings/client'; Meteor.startup(() => { diff --git a/client/startup/renderMessage/markdown.ts b/client/startup/renderMessage/markdown.ts index e38f62bb89bc5..e00aee6fb9378 100644 --- a/client/startup/renderMessage/markdown.ts +++ b/client/startup/renderMessage/markdown.ts @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { callbacks } from '../../../app/callbacks/client'; +import { callbacks } from '../../../app/callbacks/lib/callbacks'; import { settings } from '../../../app/settings/client'; Meteor.startup(() => { diff --git a/client/startup/renderMessage/mentionsMessage.ts b/client/startup/renderMessage/mentionsMessage.ts index e7dc900bc038b..6db6445998957 100644 --- a/client/startup/renderMessage/mentionsMessage.ts +++ b/client/startup/renderMessage/mentionsMessage.ts @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { callbacks } from '../../../app/callbacks/client'; +import { callbacks } from '../../../app/callbacks/lib/callbacks'; import { Users } from '../../../app/models/client'; import { settings } from '../../../app/settings/client'; diff --git a/client/startup/renderNotification/markdown.ts b/client/startup/renderNotification/markdown.ts index 80b28bed11cc4..c2f8179852b45 100644 --- a/client/startup/renderNotification/markdown.ts +++ b/client/startup/renderNotification/markdown.ts @@ -1,6 +1,6 @@ import { Meteor } from 'meteor/meteor'; -import { callbacks } from '../../../app/callbacks/client'; +import { callbacks } from '../../../app/callbacks/lib/callbacks'; import { settings } from '../../../app/settings/client'; Meteor.startup(() => { diff --git a/client/startup/routes.ts b/client/startup/routes.ts index 2c17a3bb003d6..aa6e5dc8ea1b6 100644 --- a/client/startup/routes.ts +++ b/client/startup/routes.ts @@ -1,10 +1,13 @@ import { FlowRouter } from 'meteor/kadira:flow-router'; import { Meteor } from 'meteor/meteor'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import { Session } from 'meteor/session'; import { Tracker } from 'meteor/tracker'; import { lazy } from 'react'; +import toastr from 'toastr'; import { KonchatNotification } from '../../app/ui/client'; +import { APIClient } from '../../app/utils/client'; import { IUser } from '../../definition/IUser'; import { appLayout } from '../lib/appLayout'; import { createTemplateForComponent } from '../lib/portals/createTemplateForComponent'; @@ -14,6 +17,7 @@ import { handleError } from '../lib/utils/handleError'; const SetupWizardRoute = lazy(() => import('../views/setupWizard/SetupWizardRoute')); const MailerUnsubscriptionPage = lazy(() => import('../views/mailer/MailerUnsubscriptionPage')); const NotFoundPage = lazy(() => import('../views/notFound/NotFoundPage')); +const MeetPage = lazy(() => import('../views/meet/MeetPage')); FlowRouter.wait(); @@ -50,6 +54,25 @@ FlowRouter.route('/login', { }, }); +FlowRouter.route('/meet/:rid', { + name: 'meet', + + async action(_params, queryParams) { + if (queryParams?.token !== undefined) { + // visitor login + const visitor = await APIClient.v1.get(`livechat/visitor/${queryParams?.token}`); + if (visitor?.visitor) { + return appLayout.render({ component: MeetPage }); + } + return toastr.error(TAPi18n.__('Visitor_does_not_exist')); + } + if (!Meteor.userId()) { + FlowRouter.go('home'); + } + appLayout.render({ component: MeetPage }); + }, +}); + FlowRouter.route('/home', { name: 'home', diff --git a/client/startup/streamMessage/autotranslate.ts b/client/startup/streamMessage/autotranslate.ts index c543998999b98..f788b1565109d 100644 --- a/client/startup/streamMessage/autotranslate.ts +++ b/client/startup/streamMessage/autotranslate.ts @@ -2,7 +2,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; import { hasPermission } from '../../../app/authorization/client'; -import { callbacks } from '../../../app/callbacks/client'; +import { callbacks } from '../../../app/callbacks/lib/callbacks'; import { settings } from '../../../app/settings/client'; Meteor.startup(() => { diff --git a/client/startup/userStatusManuallySet.ts b/client/startup/userStatusManuallySet.ts index 46c052c88395c..b969e621ba1d8 100644 --- a/client/startup/userStatusManuallySet.ts +++ b/client/startup/userStatusManuallySet.ts @@ -1,6 +1,6 @@ import { Meteor } from 'meteor/meteor'; -import { callbacks } from '../../app/callbacks/client'; +import { callbacks } from '../../app/callbacks/lib/callbacks'; import { UserStatus } from '../../definition/UserStatus'; import { fireGlobalEvent } from '../lib/utils/fireGlobalEvent'; diff --git a/client/types/less-browser.d.ts b/client/types/less-browser.d.ts deleted file mode 100644 index d9bc07eff65b1..0000000000000 --- a/client/types/less-browser.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare function createLess(window: Window, options: Less.Options): LessStatic; - -declare module 'less/browser' { - export = createLess; -} diff --git a/client/types/main.d.ts b/client/types/main.d.ts deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/client/types/meteor-mongo.d.ts b/client/types/meteor-mongo.d.ts deleted file mode 100644 index fa23ee6be63fb..0000000000000 --- a/client/types/meteor-mongo.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -declare module 'meteor/mongo' { - namespace Mongo { - // eslint-disable-next-line @typescript-eslint/interface-name-prefix - interface CollectionStatic { - new ( - name: string | null, - options?: { - connection?: object | null; - idGeneration?: string; - transform?: Function | null; - }, - ): Collection; - } - } -} diff --git a/client/views/InfoPanel/Label.tsx b/client/views/InfoPanel/Label.tsx index ec0d6cf4e3454..7d6402fcc2476 100644 --- a/client/views/InfoPanel/Label.tsx +++ b/client/views/InfoPanel/Label.tsx @@ -2,7 +2,7 @@ import { Box } from '@rocket.chat/fuselage'; import React, { ComponentProps, FC } from 'react'; const Label: FC> = (props) => ( - + ); export default Label; diff --git a/client/views/InfoPanel/Text.tsx b/client/views/InfoPanel/Text.tsx index efd146d8802dc..1aafff9971d23 100644 --- a/client/views/InfoPanel/Text.tsx +++ b/client/views/InfoPanel/Text.tsx @@ -7,7 +7,7 @@ const wordBreak = css` `; const Text: FC> = (props) => ( - + ); export default Text; diff --git a/client/views/InfoPanel/Title.tsx b/client/views/InfoPanel/Title.tsx index 8fdfa8966bce1..67fb15f44fa93 100644 --- a/client/views/InfoPanel/Title.tsx +++ b/client/views/InfoPanel/Title.tsx @@ -12,7 +12,7 @@ const Title: FC = ({ title, icon }) => ( title={title} flexShrink={0} alignItems='center' - fontScale='s2' + fontScale='h4' color='default' withTruncatedText > diff --git a/client/views/account/preferences/MyDataModal.tsx b/client/views/account/preferences/MyDataModal.tsx index 5c6bf956f4a3a..ab4cd905b3613 100644 --- a/client/views/account/preferences/MyDataModal.tsx +++ b/client/views/account/preferences/MyDataModal.tsx @@ -19,7 +19,7 @@ const MyDataModal: FC = ({ onCancel, title, text, ...props }) {title} - + {text} diff --git a/client/views/account/preferences/PreferencesNotificationsSection.js b/client/views/account/preferences/PreferencesNotificationsSection.js index 615b063d8e3ee..6419f8df5ffe8 100644 --- a/client/views/account/preferences/PreferencesNotificationsSection.js +++ b/client/views/account/preferences/PreferencesNotificationsSection.js @@ -50,7 +50,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }) => { { desktopNotificationRequireInteraction: userDesktopNotificationRequireInteraction, desktopNotifications: userDesktopNotifications, - mobileNotifications: userMobileNotifications, + pushNotifications: userMobileNotifications, emailNotificationMode: userEmailNotificationMode, }, onChange, @@ -59,14 +59,14 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }) => { const { desktopNotificationRequireInteraction, desktopNotifications, - mobileNotifications, + pushNotifications, emailNotificationMode, } = values; const { handleDesktopNotificationRequireInteraction, handleDesktopNotifications, - handleMobileNotifications, + handlePushNotifications, handleEmailNotificationMode, } = handlers; @@ -171,8 +171,8 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }) => { {t('Notification_Push_Default_For')} }> - - {channels && !channels.length && - {t('No_data_found')} - } - {(!channels || channels.length) - && - - - {'#'} - {t('Channel')} - {t('Created')} - {t('Last_active')} - {t('Messages_sent')} - - - - {channels && channels.map(({ t, name, createdAt, updatedAt, messagesCount, messagesVariation }, i) => - - {i + 1}. - - - {(t === 'd' && ) - || (t === 'c' && ) - || (t === 'p' && )} - - {name} - - - {moment(createdAt).format('L')} - - - {moment(updatedAt).format('L')} - - - {messagesCount} {messagesVariation} - - )} - {!channels && Array.from({ length: 5 }, (_, i) => - - - - - - - - - - - - - - - - - )} - -
    } - t('Items_per_page:')} - showingResultsLabel={({ count, current, itemsPerPage }) => - t('Showing_results_of', current + 1, Math.min(current + itemsPerPage, count), count)} - count={(data && data.total) || 0} - onSetItemsPerPage={setItemsPerPage} - onSetCurrent={setCurrent} - /> -
    - ; -}; - -export default TableSection; diff --git a/ee/app/engagement-dashboard/client/components/ChannelsTab/index.js b/ee/app/engagement-dashboard/client/components/ChannelsTab/index.js deleted file mode 100644 index db12bcb932aa2..0000000000000 --- a/ee/app/engagement-dashboard/client/components/ChannelsTab/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -import TableSection from './TableSection'; - -const ChannelsTab = () => ; - -export default ChannelsTab; diff --git a/ee/app/engagement-dashboard/client/components/EngagementDashboardPage.js b/ee/app/engagement-dashboard/client/components/EngagementDashboardPage.js deleted file mode 100644 index 18f3819309648..0000000000000 --- a/ee/app/engagement-dashboard/client/components/EngagementDashboardPage.js +++ /dev/null @@ -1,42 +0,0 @@ -import { Box, Select, Tabs } from '@rocket.chat/fuselage'; -import React, { useMemo, useState } from 'react'; - -import { useTranslation } from '../../../../../client/contexts/TranslationContext'; -import Page from '../../../../../client/components/Page'; -import UsersTab from './UsersTab'; -import MessagesTab from './MessagesTab'; -import ChannelsTab from './ChannelsTab'; - -export const EngagementDashboardPage = ({ - tab = 'users', - onSelectTab, -}) => { - const t = useTranslation(); - const timezoneOptions = useMemo(() => [ - ['utc', t('UTC_Timezone')], - ['local', t('Local_Timezone')], - ], [t]); - - const [timezoneId, setTimezoneId] = useState('utc'); - const handleTimezoneChange = (timezoneId) => setTimezoneId(timezoneId); - - const handleTabClick = useMemo(() => (onSelectTab ? (tab) => () => onSelectTab(tab) : () => undefined), [onSelectTab]); - - return - - } - > - - - - - - - - {pie - ? - - - - - - {t('Value_messages', { value })} - } - /> - - - - - - - - - - - {t('Private_Chats')} - - - - {t('Private_Channels')} - - - - {t('Public_Channels')} - - - - - - - : } - - - - - - - {table ? {t('Most_popular_channels_top_5')} : } - - {table && !table.length && - {t('Not_enough_data')} - } - {(!table || !!table.length) && - - - {'#'} - {t('Channel')} - {t('Number_of_messages')} - - - - {table && table.map(({ i, t, name, messages }) => - {i + 1}. - - - {(t === 'd' && ) - || (t === 'c' && ) - || (t === 'p' && )} - - {name} - - {messages} - )} - {!table && Array.from({ length: 5 }, (_, i) => - - - - - - - - - - )} - -
    } -
    -
    -
    -
    -
    -
    - ; -}; - -export default MessagesPerChannelSection; diff --git a/ee/app/engagement-dashboard/client/components/MessagesTab/MessagesSentSection.js b/ee/app/engagement-dashboard/client/components/MessagesTab/MessagesSentSection.js deleted file mode 100644 index 7871259261a72..0000000000000 --- a/ee/app/engagement-dashboard/client/components/MessagesTab/MessagesSentSection.js +++ /dev/null @@ -1,186 +0,0 @@ -import { ResponsiveBar } from '@nivo/bar'; -import { Box, Flex, Select, Skeleton, ActionButton } from '@rocket.chat/fuselage'; -import moment from 'moment'; -import React, { useMemo, useState } from 'react'; - -import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; -import { useEndpointData } from '../../../../../../client/hooks/useEndpointData'; -import CounterSet from '../../../../../../client/components/data/CounterSet'; -import { Section } from '../Section'; -import { downloadCsvAs } from '../../../../../../client/lib/download'; - -const MessagesSentSection = () => { - const t = useTranslation(); - - const periodOptions = useMemo(() => [ - ['last 7 days', t('Last_7_days')], - ['last 30 days', t('Last_30_days')], - ['last 90 days', t('Last_90_days')], - ], [t]); - - const [periodId, setPeriodId] = useState('last 7 days'); - - const period = useMemo(() => { - switch (periodId) { - case 'last 7 days': - return { - start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(7, 'days'), - end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), - }; - - case 'last 30 days': - return { - start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(30, 'days'), - end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), - }; - - case 'last 90 days': - return { - start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(90, 'days'), - end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), - }; - } - }, [periodId]); - - const handlePeriodChange = (periodId) => setPeriodId(periodId); - - const params = useMemo(() => ({ - start: period.start.toISOString(), - end: period.end.toISOString(), - }), [period]); - - const { value: data } = useEndpointData('engagement-dashboard/messages/messages-sent', params); - - const [ - countFromPeriod, - variatonFromPeriod, - countFromYesterday, - variationFromYesterday, - values, - ] = useMemo(() => { - if (!data) { - return []; - } - - const values = Array.from({ length: moment(period.end).diff(period.start, 'days') + 1 }, (_, i) => ({ - date: moment(period.start).add(i, 'days').toISOString(), - newMessages: 0, - })); - for (const { day, messages } of data.days) { - const i = moment(day).diff(period.start, 'days'); - if (i >= 0) { - values[i].newMessages += messages; - } - } - - return [ - data.period.count, - data.period.variation, - data.yesterday.count, - data.yesterday.variation, - values, - ]; - }, [data, period]); - - const downloadData = () => { - const data = [ - ['Date', 'Messages'], - ...values.map(({ date, newMessages }) => [date, newMessages]), - ]; - downloadCsvAs(data, `MessagesSentSection_start_${ params.start }_end_${ params.end }`); - }; - - return
    } - > - , - variation: data ? variatonFromPeriod : 0, - description: periodOptions.find(([id]) => id === periodId)[1], - }, - { - count: data ? countFromYesterday : , - variation: data ? variationFromYesterday : 0, - description: t('Yesterday'), - }, - ]} - /> - - {data - ? - - - - moment(date).format(values.length === 7 ? 'dddd' : 'DD/MM'), - }} - axisLeft={{ - tickSize: 0, - // TODO: Get it from theme - tickPadding: 4, - tickRotation: 0, - }} - animate={true} - motionStiffness={90} - motionDamping={15} - theme={{ - // TODO: Get it from theme - axis: { - ticks: { - text: { - fill: '#9EA2A8', - fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif', - fontSize: '10px', - fontStyle: 'normal', - fontWeight: '600', - letterSpacing: '0.2px', - lineHeight: '12px', - }, - }, - }, - tooltip: { - backgroundColor: '#1F2329', - boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)', - borderRadius: 2, - padding: 4, - }, - }} - tooltip={({ value, indexValue }) => - {t('Value_users', { value })}, {formatDate(indexValue)} - } - /> - - - - - : } - -
    ; -}; - -export default NewUsersSection; diff --git a/ee/app/engagement-dashboard/client/components/UsersTab/UsersByTimeOfTheDaySection.js b/ee/app/engagement-dashboard/client/components/UsersTab/UsersByTimeOfTheDaySection.js deleted file mode 100644 index 415a012141424..0000000000000 --- a/ee/app/engagement-dashboard/client/components/UsersTab/UsersByTimeOfTheDaySection.js +++ /dev/null @@ -1,211 +0,0 @@ -import { ResponsiveHeatMap } from '@nivo/heatmap'; -import { Box, Flex, Select, Skeleton, ActionButton } from '@rocket.chat/fuselage'; -import moment from 'moment'; -import React, { useMemo, useState } from 'react'; - -import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; -import { useEndpointData } from '../../../../../../client/hooks/useEndpointData'; -import { Section } from '../Section'; -import { downloadCsvAs } from '../../../../../../client/lib/download'; - -const UsersByTimeOfTheDaySection = ({ timezone }) => { - const t = useTranslation(); - const utc = timezone === 'utc'; - - const periodOptions = useMemo(() => [ - ['last 7 days', t('Last_7_days')], - ['last 30 days', t('Last_30_days')], - ['last 90 days', t('Last_90_days')], - ], [t]); - - const [periodId, setPeriodId] = useState('last 7 days'); - - const period = useMemo(() => { - switch (periodId) { - case 'last 7 days': - return { - start: utc - ? moment.utc().startOf('day').subtract(7, 'days') - : moment().startOf('day').subtract(8, 'days'), - end: utc - ? moment.utc().endOf('day').subtract(1, 'days') - : moment().endOf('day'), - }; - - case 'last 30 days': - return { - start: utc - ? moment.utc().startOf('day').subtract(30, 'days') - : moment().startOf('day').subtract(31, 'days'), - end: utc - ? moment.utc().endOf('day').subtract(1, 'days') - : moment().endOf('day'), - }; - - case 'last 90 days': - return { - start: utc - ? moment.utc().startOf('day').subtract(90, 'days') - : moment().startOf('day').subtract(91, 'days'), - end: utc - ? moment.utc().endOf('day').subtract(1, 'days') - : moment().endOf('day'), - }; - } - }, [periodId, utc]); - - const handlePeriodChange = (periodId) => setPeriodId(periodId); - - const params = useMemo(() => ({ - start: period.start.toISOString(), - end: period.end.toISOString(), - }), [period]); - - const { value: data } = useEndpointData('engagement-dashboard/users/users-by-time-of-the-day-in-a-week', useMemo(() => params, [params])); - - const [ - dates, - values, - ] = useMemo(() => { - if (!data) { - return []; - } - - const dates = Array.from({ length: utc - ? moment(period.end).diff(period.start, 'days') + 1 - : moment(period.end).diff(period.start, 'days') - 1 }, - (_, i) => moment(period.start).endOf('day').add(utc ? i : i + 1, 'days')); - - const values = Array.from({ length: 24 }, (_, hour) => ({ - hour: String(hour), - ...dates.map((date) => ({ [date.toISOString()]: 0 })) - .reduce((obj, elem) => ({ ...obj, ...elem }), {}), - })); - - const timezoneOffset = moment().utcOffset() / 60; - - for (const { users, hour, day, month, year } of data.week) { - const date = utc - ? moment.utc([year, month - 1, day, hour]) - : moment([year, month - 1, day, hour]).add(timezoneOffset, 'hours'); - - if (utc || (!date.isSame(period.end) && !date.clone().startOf('day').isSame(period.start))) { - values[date.hour()][date.endOf('day').toISOString()] += users; - } - } - - return [ - dates.map((date) => date.toISOString()), - values, - ]; - }, [data, period.end, period.start, utc]); - - const downloadData = () => { - const _data = [ - ['Date', 'Users'], - ...data.week.map(({ - users, - hour, - day, - month, - year, - }) => ({ - date: moment([year, month - 1, day, hour, 0, 0, 0]), - users, - })) - .sort((a, b) => a > b) - .map(({ date, users }) => [date.toISOString(), users]), - ]; - downloadCsvAs(_data, `UsersByTimeOfTheDaySection_start_${ params.start }_end_${ params.end }`); - }; - return
    + + + + {t('Users')} + + + {t('Messages')} + + + {t('Channels')} + + + + + {(tab === 'users' && ) || + (tab === 'messages' && ) || + (tab === 'channels' && )} + + + + ); +}; + +export default EngagementDashboardPage; diff --git a/ee/client/views/admin/engagementDashboard/EngagementDashboardRoute.tsx b/ee/client/views/admin/engagementDashboard/EngagementDashboardRoute.tsx new file mode 100644 index 0000000000000..86967d5a17495 --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/EngagementDashboardRoute.tsx @@ -0,0 +1,43 @@ +import React, { ReactElement, useEffect } from 'react'; + +import NotAuthorizedPage from '../../../../../client/components/NotAuthorizedPage'; +import { usePermission } from '../../../../../client/contexts/AuthorizationContext'; +import { useCurrentRoute, useRoute } from '../../../../../client/contexts/RouterContext'; +import EngagementDashboardPage from './EngagementDashboardPage'; + +const isValidTab = (tab: string | undefined): tab is 'users' | 'messages' | 'channels' => + typeof tab === 'string' && ['users', 'messages', 'channels'].includes(tab); + +const EngagementDashboardRoute = (): ReactElement | null => { + const canViewEngagementDashboard = usePermission('view-engagement-dashboard'); + const engagementDashboardRoute = useRoute('engagement-dashboard'); + const [routeName, routeParams] = useCurrentRoute(); + const { tab } = routeParams ?? {}; + + useEffect(() => { + if (routeName !== 'engagement-dashboard') { + return; + } + + if (!isValidTab(tab)) { + engagementDashboardRoute.replace({ tab: 'users' }); + } + }, [routeName, engagementDashboardRoute, tab]); + + if (!isValidTab(tab)) { + return null; + } + + if (!canViewEngagementDashboard) { + return ; + } + + return ( + engagementDashboardRoute.push({ tab })} + /> + ); +}; + +export default EngagementDashboardRoute; diff --git a/ee/client/views/admin/engagementDashboard/Section.tsx b/ee/client/views/admin/engagementDashboard/Section.tsx new file mode 100644 index 0000000000000..72a37f7474546 --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/Section.tsx @@ -0,0 +1,30 @@ +import { Box, Flex, InputBox, Margins } from '@rocket.chat/fuselage'; +import React, { ReactElement, ReactNode } from 'react'; + +type SectionProps = { + children?: ReactNode; + title?: ReactNode; + filter?: ReactNode; +}; + +const Section = ({ + children, + title = undefined, + filter = , +}: SectionProps): ReactElement => ( + + + + {title && ( + + {title} + + )} + {filter && {filter}} + + {children} + + +); + +export default Section; diff --git a/ee/client/views/admin/engagementDashboard/channels/ChannelsTab.stories.tsx b/ee/client/views/admin/engagementDashboard/channels/ChannelsTab.stories.tsx new file mode 100644 index 0000000000000..b4da7fb6d1df9 --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/channels/ChannelsTab.stories.tsx @@ -0,0 +1,14 @@ +import { Margins } from '@rocket.chat/fuselage'; +import { Meta, Story } from '@storybook/react'; +import React from 'react'; + +import ChannelsTab from './ChannelsTab'; + +export default { + title: 'admin/engagementDashboard/ChannelsTab', + component: ChannelsTab, + decorators: [(fn) => ], +} as Meta; + +export const Default: Story = () => ; +Default.storyName = 'ChannelsTab'; diff --git a/ee/client/views/admin/engagementDashboard/channels/ChannelsTab.tsx b/ee/client/views/admin/engagementDashboard/channels/ChannelsTab.tsx new file mode 100644 index 0000000000000..f2ac3cbd21735 --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/channels/ChannelsTab.tsx @@ -0,0 +1,148 @@ +import { Box, Icon, Margins, Pagination, Skeleton, Table, Tile } from '@rocket.chat/fuselage'; +import moment from 'moment'; +import React, { ReactElement, useMemo, useState } from 'react'; + +import Growth from '../../../../../../client/components/data/Growth'; +import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; +import Section from '../Section'; +import DownloadDataButton from '../data/DownloadDataButton'; +import PeriodSelector from '../data/PeriodSelector'; +import { usePeriodSelectorState } from '../data/usePeriodSelectorState'; +import { useChannelsList } from './useChannelsList'; + +const ChannelsTab = (): ReactElement => { + const [period, periodSelectorProps] = usePeriodSelectorState( + 'last 7 days', + 'last 30 days', + 'last 90 days', + ); + + const t = useTranslation(); + + const [current, setCurrent] = useState(0); + const [itemsPerPage, setItemsPerPage] = useState<25 | 50 | 100>(25); + + const { data } = useChannelsList({ + period, + offset: current, + count: itemsPerPage, + }); + + const channels = useMemo(() => { + if (!data) { + return; + } + + return data?.channels?.map( + ({ room: { t, name, usernames, ts, _updatedAt }, messages, diffFromLastWeek }) => ({ + t, + name: name || usernames?.join(' × '), + createdAt: ts, + updatedAt: _updatedAt, + messagesCount: messages, + messagesVariation: diffFromLastWeek, + }), + ); + }, [data]); + + return ( +
    + + + data?.channels?.map(({ room: { t, name, usernames, ts, _updatedAt }, messages }) => [ + t, + name || usernames?.join(' × '), + messages, + _updatedAt, + ts, + ]) + } + /> + + } + > + + {channels && !channels.length && ( + + {t('No_data_found')} + + )} + {(!channels || channels.length) && ( + + + + {'#'} + {t('Channel')} + {t('Created')} + {t('Last_active')} + {t('Messages_sent')} + + + + {channels && + channels.map( + ({ t, name, createdAt, updatedAt, messagesCount, messagesVariation }, i) => ( + + {i + 1}. + + + {(t === 'd' && ) || + (t === 'c' && ) || + (t === 'p' && )} + + {name} + + {moment(createdAt).format('L')} + {moment(updatedAt).format('L')} + + {messagesCount} {messagesVariation} + + + ), + )} + {!channels && + Array.from({ length: 5 }, (_, i) => ( + + + + + + + + + + + + + + + + + + ))} + +
    + )} + t('Items_per_page:')} + showingResultsLabel={({ count, current, itemsPerPage }): string => + t('Showing_results_of', current + 1, Math.min(current + itemsPerPage, count), count) + } + count={(data && data.total) || 0} + onSetItemsPerPage={setItemsPerPage} + onSetCurrent={setCurrent} + /> +
    +
    + ); +}; + +export default ChannelsTab; diff --git a/ee/client/views/admin/engagementDashboard/channels/useChannelsList.ts b/ee/client/views/admin/engagementDashboard/channels/useChannelsList.ts new file mode 100644 index 0000000000000..955f8834427c7 --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/channels/useChannelsList.ts @@ -0,0 +1,38 @@ +import { useQuery } from 'react-query'; + +import { getFromRestApi } from '../../../../lib/getFromRestApi'; +import { getPeriodRange, Period } from '../data/periods'; + +type UseChannelsListOptions = { + period: Period['key']; + offset: number; + count: number; +}; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useChannelsList = ({ period, offset, count }: UseChannelsListOptions) => + useQuery( + ['admin/engagement-dashboard/channels/list', { period, offset, count }], + async () => { + const { start, end } = getPeriodRange(period); + + const response = await getFromRestApi('/v1/engagement-dashboard/channels/list')({ + start: start.toISOString(), + end: end.toISOString(), + offset, + count, + }); + + return response + ? { + ...response, + start, + end, + } + : undefined; + }, + { + keepPreviousData: true, + refetchInterval: 5 * 60 * 1000, + }, + ); diff --git a/ee/client/views/admin/engagementDashboard/data/DownloadDataButton.tsx b/ee/client/views/admin/engagementDashboard/data/DownloadDataButton.tsx new file mode 100644 index 0000000000000..13a9d6da21d75 --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/data/DownloadDataButton.tsx @@ -0,0 +1,60 @@ +import { Box, ActionButton } from '@rocket.chat/fuselage'; +import React, { ComponentProps, ReactElement } from 'react'; + +import { useToastMessageDispatch } from '../../../../../../client/contexts/ToastMessagesContext'; +import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; +import { downloadCsvAs } from '../../../../../../client/lib/download'; + +type RowFor = readonly unknown[] & { + length: THeaders['length']; +}; + +type DownloadDataButtonProps = { + attachmentName: string; + headers: RowFor; + dataAvailable: boolean; + dataExtractor: () => Promise[] | undefined> | RowFor[] | undefined; +} & Omit, 'attachmentName' | 'headers' | 'data'>; + +const DownloadDataButton = ({ + attachmentName, + headers, + dataAvailable, + dataExtractor, + ...props +}: DownloadDataButtonProps): ReactElement => { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const handleClick = (): void => { + if (!dataAvailable) { + return; + } + + Promise.resolve(dataExtractor()) + .then((data) => { + if (!data) { + return; + } + + downloadCsvAs([headers, ...data], attachmentName); + }) + .catch((error) => { + dispatchToastMessage({ type: 'error', message: error }); + }); + }; + + return ( + + ); +}; + +export default DownloadDataButton; diff --git a/ee/client/views/admin/engagementDashboard/data/LegendSymbol.stories.tsx b/ee/client/views/admin/engagementDashboard/data/LegendSymbol.stories.tsx new file mode 100644 index 0000000000000..2252e1b6c9226 --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/data/LegendSymbol.stories.tsx @@ -0,0 +1,34 @@ +import { Box, Margins } from '@rocket.chat/fuselage'; +import { Meta, Story } from '@storybook/react'; +import React, { ReactElement } from 'react'; + +import LegendSymbol from './LegendSymbol'; +import { monochromaticColors, polychromaticColors } from './colors'; + +export default { + title: 'admin/engagementDashboard/data/LegendSymbol', + component: LegendSymbol, + decorators: [(fn): ReactElement => ], +} as Meta; + +export const withoutColor: Story = () => ( + + + Legend text + +); + +export const withColor: Story = () => ( + <> + {monochromaticColors.map((color) => ( + + {color} + + ))} + {polychromaticColors.map((color) => ( + + {color} + + ))} + +); diff --git a/ee/client/views/admin/engagementDashboard/data/LegendSymbol.tsx b/ee/client/views/admin/engagementDashboard/data/LegendSymbol.tsx new file mode 100644 index 0000000000000..8a688eb26d33f --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/data/LegendSymbol.tsx @@ -0,0 +1,25 @@ +import { Box, Margins } from '@rocket.chat/fuselage'; +import React, { CSSProperties, ReactElement } from 'react'; + +type LegendSymbolProps = { + color?: CSSProperties['backgroundColor']; +}; + +const LegendSymbol = ({ color = 'currentColor' }: LegendSymbolProps): ReactElement => ( + + +); + +export default LegendSymbol; diff --git a/ee/client/views/admin/engagementDashboard/data/PeriodSelector.tsx b/ee/client/views/admin/engagementDashboard/data/PeriodSelector.tsx new file mode 100644 index 0000000000000..e1c7b59e1bbf4 --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/data/PeriodSelector.tsx @@ -0,0 +1,34 @@ +import { Select } from '@rocket.chat/fuselage'; +import React, { ReactElement, useMemo } from 'react'; + +import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; +import { getPeriod, Period } from './periods'; + +type PeriodSelectorProps = { + periods: TPeriod[]; + value: TPeriod; + onChange: (value: TPeriod) => void; +}; + +const PeriodSelector = ({ + periods, + value, + onChange, +}: PeriodSelectorProps): ReactElement => { + const t = useTranslation(); + + const options = useMemo<[string, string][]>( + () => periods.map((period) => [period, t(...getPeriod(period).label)]), + [periods, t], + ); + + return ( +